Преглед на файлове

fix: prevent duplicate tmux pane spawns

Alvin Unreal преди 3 месеца
родител
ревизия
4b17f68cbf
променени са 2 файла, в които са добавени 183 реда и са изтрити 75 реда
  1. 84 0
      src/multiplexer/session-manager.test.ts
  2. 99 75
      src/multiplexer/session-manager.ts

+ 84 - 0
src/multiplexer/session-manager.test.ts

@@ -46,6 +46,16 @@ const defaultMultiplexerConfig = {
   main_pane_size: 60,
 };
 
+function createDeferred<T>() {
+  let resolve!: (value: T) => void;
+  let reject!: (reason?: unknown) => void;
+  const promise = new Promise<T>((res, rej) => {
+    resolve = res;
+    reject = rej;
+  });
+  return { promise, resolve, reject };
+}
+
 describe('MultiplexerSessionManager', () => {
   beforeEach(() => {
     mockMultiplexer.spawnPane.mockReset();
@@ -161,6 +171,41 @@ describe('MultiplexerSessionManager', () => {
 
       expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
     });
+
+    test('does not spawn twice for duplicate create events while spawning', async () => {
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+      const deferred = createDeferred<{ success: true; paneId: string }>();
+
+      mockMultiplexer.spawnPane.mockImplementationOnce(() => deferred.promise);
+
+      const event = {
+        type: 'session.created',
+        properties: {
+          info: {
+            id: 'child-race',
+            parentID: 'parent-race',
+            title: 'Race Worker',
+          },
+        },
+      };
+
+      const firstCreate = manager.onSessionCreated(event);
+      const secondCreate = manager.onSessionCreated(event);
+
+      await Promise.resolve();
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+
+      deferred.resolve({ success: true, paneId: 'p-race' });
+
+      await Promise.all([firstCreate, secondCreate]);
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+    });
   });
 
   describe('polling and closure', () => {
@@ -331,6 +376,45 @@ describe('MultiplexerSessionManager', () => {
         'p-existing',
       );
     });
+
+    test('does not respawn while initial pane spawn is still in progress', async () => {
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+      const deferred = createDeferred<{ success: true; paneId: string }>();
+
+      mockMultiplexer.spawnPane.mockImplementationOnce(() => deferred.promise);
+
+      const createPromise = manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: {
+            id: 'child-busy-race',
+            parentID: 'parent-busy-race',
+            title: 'Busy Worker',
+            directory: '/task/dir',
+          },
+        },
+      });
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-busy-race',
+          status: { type: 'busy' },
+        },
+      });
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+
+      deferred.resolve({ success: true, paneId: 'p-busy-race' });
+
+      await createPromise;
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+    });
   });
 
   describe('cleanup', () => {

+ 99 - 75
src/multiplexer/session-manager.ts

@@ -57,6 +57,7 @@ export class MultiplexerSessionManager {
   private multiplexer: Multiplexer | null = null;
   private sessions = new Map<string, TrackedSession>();
   private knownSessions = new Map<string, KnownSession>();
+  private spawningSessions = new Set<string>();
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
 
@@ -100,54 +101,67 @@ export class MultiplexerSessionManager {
       directory,
     });
 
-    if (this.sessions.has(sessionId)) {
-      log('[multiplexer-session-manager] session already tracked', {
+    if (this.isTrackedOrSpawning(sessionId)) {
+      log('[multiplexer-session-manager] session already tracked or spawning', {
         sessionId,
       });
       return;
     }
 
-    const serverRunning = await isServerRunning(this.serverUrl);
-    if (!serverRunning) {
-      log('[multiplexer-session-manager] server not running, skipping', {
-        serverUrl: this.serverUrl,
-      });
-      return;
-    }
+    this.spawningSessions.add(sessionId);
 
-    log('[multiplexer-session-manager] child session created, spawning pane', {
-      sessionId,
-      parentId,
-      title,
-    });
+    try {
+      const serverRunning = await isServerRunning(this.serverUrl);
+      if (!serverRunning) {
+        log('[multiplexer-session-manager] server not running, skipping', {
+          serverUrl: this.serverUrl,
+        });
+        return;
+      }
+
+      if (this.sessions.has(sessionId)) {
+        return;
+      }
+
+      log(
+        '[multiplexer-session-manager] child session created, spawning pane',
+        {
+          sessionId,
+          parentId,
+          title,
+        },
+      );
 
-    const paneResult = await this.multiplexer
-      .spawnPane(sessionId, title, this.serverUrl, directory)
-      .catch((err) => {
-        log('[multiplexer-session-manager] failed to spawn pane', {
-          error: String(err),
+      const paneResult = await this.multiplexer
+        .spawnPane(sessionId, title, this.serverUrl, directory)
+        .catch((err) => {
+          log('[multiplexer-session-manager] failed to spawn pane', {
+            error: String(err),
+          });
+          return { success: false, paneId: undefined };
         });
-        return { success: false, paneId: undefined };
-      });
 
-    if (paneResult.success && paneResult.paneId) {
-      const now = Date.now();
-      this.sessions.set(sessionId, {
-        sessionId,
-        paneId: paneResult.paneId,
-        parentId,
-        title,
-        directory,
-        createdAt: now,
-        lastSeenAt: now,
-      });
+      if (paneResult.success && paneResult.paneId) {
+        const now = Date.now();
+        this.sessions.set(sessionId, {
+          sessionId,
+          paneId: paneResult.paneId,
+          parentId,
+          title,
+          directory,
+          createdAt: now,
+          lastSeenAt: now,
+        });
 
-      log('[multiplexer-session-manager] pane spawned', {
-        sessionId,
-        paneId: paneResult.paneId,
-      });
+        log('[multiplexer-session-manager] pane spawned', {
+          sessionId,
+          paneId: paneResult.paneId,
+        });
 
-      this.startPolling();
+        this.startPolling();
+      }
+    } finally {
+      this.spawningSessions.delete(sessionId);
     }
   }
 
@@ -265,62 +279,72 @@ export class MultiplexerSessionManager {
 
   private async respawnIfKnown(sessionId: string): Promise<void> {
     if (!this.enabled || !this.multiplexer) return;
-    if (this.sessions.has(sessionId)) return;
+    if (this.isTrackedOrSpawning(sessionId)) return;
 
     const known = this.knownSessions.get(sessionId);
     if (!known) return;
 
-    const serverRunning = await isServerRunning(this.serverUrl);
-    if (!serverRunning) {
+    this.spawningSessions.add(sessionId);
+
+    try {
+      const serverRunning = await isServerRunning(this.serverUrl);
+      if (!serverRunning) {
+        log(
+          '[multiplexer-session-manager] server not running, skipping busy respawn',
+          {
+            serverUrl: this.serverUrl,
+            sessionId,
+          },
+        );
+        return;
+      }
+
+      if (this.sessions.has(sessionId)) return;
+
       log(
-        '[multiplexer-session-manager] server not running, skipping busy respawn',
+        '[multiplexer-session-manager] child session busy again, respawning pane',
         {
-          serverUrl: this.serverUrl,
           sessionId,
+          parentId: known.parentId,
+          title: known.title,
         },
       );
-      return;
-    }
 
-    if (this.sessions.has(sessionId)) return;
+      const paneResult = await this.multiplexer
+        .spawnPane(sessionId, known.title, this.serverUrl, known.directory)
+        .catch((err) => {
+          log('[multiplexer-session-manager] failed to respawn pane', {
+            error: String(err),
+          });
+          return { success: false, paneId: undefined };
+        });
+
+      if (!paneResult.success || !paneResult.paneId) return;
 
-    log(
-      '[multiplexer-session-manager] child session busy again, respawning pane',
-      {
+      const now = Date.now();
+      this.sessions.set(sessionId, {
         sessionId,
+        paneId: paneResult.paneId,
         parentId: known.parentId,
         title: known.title,
-      },
-    );
-
-    const paneResult = await this.multiplexer
-      .spawnPane(sessionId, known.title, this.serverUrl, known.directory)
-      .catch((err) => {
-        log('[multiplexer-session-manager] failed to respawn pane', {
-          error: String(err),
-        });
-        return { success: false, paneId: undefined };
+        directory: known.directory,
+        createdAt: now,
+        lastSeenAt: now,
       });
 
-    if (!paneResult.success || !paneResult.paneId) return;
-
-    const now = Date.now();
-    this.sessions.set(sessionId, {
-      sessionId,
-      paneId: paneResult.paneId,
-      parentId: known.parentId,
-      title: known.title,
-      directory: known.directory,
-      createdAt: now,
-      lastSeenAt: now,
-    });
+      log('[multiplexer-session-manager] pane respawned on busy', {
+        sessionId,
+        paneId: paneResult.paneId,
+      });
 
-    log('[multiplexer-session-manager] pane respawned on busy', {
-      sessionId,
-      paneId: paneResult.paneId,
-    });
+      this.startPolling();
+    } finally {
+      this.spawningSessions.delete(sessionId);
+    }
+  }
 
-    this.startPolling();
+  private isTrackedOrSpawning(sessionId: string): boolean {
+    return this.sessions.has(sessionId) || this.spawningSessions.has(sessionId);
   }
 
   async cleanup(): Promise<void> {