Browse Source

feat: add SessionLifecycle coordinator

Michael Henke 1 month ago
parent
commit
8ea743bd12
2 changed files with 116 additions and 0 deletions
  1. 53 0
      src/hooks/session-lifecycle.test.ts
  2. 63 0
      src/hooks/session-lifecycle.ts

+ 53 - 0
src/hooks/session-lifecycle.test.ts

@@ -0,0 +1,53 @@
+import { describe, expect, test } from 'bun:test';
+import { SessionLifecycle } from './session-lifecycle';
+
+const noop = () => {};
+
+describe('SessionLifecycle', () => {
+  test('dispatchSessionDeleted runs callbacks in order', () => {
+    const lc = new SessionLifecycle(noop);
+    const ran: string[] = [];
+    lc.onSessionDeleted((id) => ran.push(`a:${id}`));
+    lc.onSessionDeleted((id) => ran.push(`b:${id}`));
+    lc.dispatchSessionDeleted('s1');
+    expect(ran).toEqual(['a:s1', 'b:s1']);
+  });
+
+  test('dispatchSessionDeleted continues after callback error', () => {
+    const lc = new SessionLifecycle(() => {});
+    const ran: string[] = [];
+    lc.onSessionDeleted(() => {
+      throw new Error('fail');
+    });
+    lc.onSessionDeleted((id) => ran.push(id));
+    lc.dispatchSessionDeleted('s1');
+    expect(ran).toEqual(['s1']);
+  });
+
+  test('consumePending is atomic', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    expect(lc.consumePending('s1')).toBe(true);
+    expect(lc.consumePending('s1')).toBe(false);
+  });
+
+  test('hasPendingSession after consume', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    lc.consumePending('s1');
+    expect(lc.hasPendingSession('s1')).toBe(true);
+  });
+
+  test('hasPendingSession false for unknown session', () => {
+    const lc = new SessionLifecycle(noop);
+    expect(lc.hasPendingSession('s1')).toBe(false);
+  });
+
+  test('clearSession removes all state', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    lc.consumePending('s1');
+    lc.clearSession('s1');
+    expect(lc.hasPendingSession('s1')).toBe(false);
+  });
+});

+ 63 - 0
src/hooks/session-lifecycle.ts

@@ -0,0 +1,63 @@
+export class SessionLifecycle {
+  static readonly PENDING_TTL_MS = 5 * 60 * 1000;
+
+  #cleanupCallbacks: Array<(sessionId: string) => void> = [];
+  #pendingSessionIds = new Set<string>();
+  #everPendingSessionIds = new Set<string>();
+  #pendingTimestamps = new Map<string, number>();
+  #log: (msg: string, meta?: Record<string, unknown>) => void;
+
+  constructor(log: (msg: string, meta?: Record<string, unknown>) => void) {
+    this.#log = log;
+  }
+
+  onSessionDeleted(callback: (sessionId: string) => void): void {
+    this.#cleanupCallbacks.push(callback);
+  }
+
+  dispatchSessionDeleted(sessionId: string): void {
+    for (const cb of this.#cleanupCallbacks) {
+      try {
+        cb(sessionId);
+      } catch (error) {
+        this.#log(
+          `[session-lifecycle] cleanup callback failed for session ${sessionId}`,
+          { error },
+        );
+      }
+    }
+  }
+
+  markPending(sessionId: string): void {
+    this.#pendingSessionIds.add(sessionId);
+    this.#everPendingSessionIds.add(sessionId);
+    this.#pendingTimestamps.set(sessionId, Date.now());
+  }
+
+  /** Atomic — only one caller gets true per markPending call. */
+  consumePending(sessionId: string): boolean {
+    const had = this.#pendingSessionIds.has(sessionId);
+    this.#pendingSessionIds.delete(sessionId);
+    this.#pendingTimestamps.delete(sessionId);
+    return had;
+  }
+
+  hasPendingSession(sessionId: string): boolean {
+    const ts = this.#pendingTimestamps.get(sessionId);
+    if (ts && Date.now() - ts > SessionLifecycle.PENDING_TTL_MS) {
+      this.#pendingTimestamps.delete(sessionId);
+      this.#pendingSessionIds.delete(sessionId);
+      return false;
+    }
+    return (
+      this.#everPendingSessionIds.has(sessionId) &&
+      !this.#pendingSessionIds.has(sessionId)
+    );
+  }
+
+  clearSession(sessionId: string): void {
+    this.#pendingSessionIds.delete(sessionId);
+    this.#everPendingSessionIds.delete(sessionId);
+    this.#pendingTimestamps.delete(sessionId);
+  }
+}