Pārlūkot izejas kodu

fix tmux pane idle lifecycle

Alvin Unreal 3 mēneši atpakaļ
vecāks
revīzija
a16756afa5

+ 187 - 3
src/multiplexer/session-manager.test.ts

@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, mock, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import { MultiplexerSessionManager } from './session-manager';
 
 // Define the mock multiplexer
@@ -57,6 +57,8 @@ function createDeferred<T>() {
 }
 
 describe('MultiplexerSessionManager', () => {
+  const realDateNow = Date.now;
+
   beforeEach(() => {
     mockMultiplexer.spawnPane.mockReset();
     mockMultiplexer.spawnPane.mockResolvedValue({
@@ -67,6 +69,11 @@ describe('MultiplexerSessionManager', () => {
     mockMultiplexer.closePane.mockResolvedValue(true);
     mockMultiplexer.isInsideSession.mockReset();
     mockMultiplexer.isInsideSession.mockReturnValue(true);
+    Date.now = realDateNow;
+  });
+
+  afterEach(() => {
+    Date.now = realDateNow;
   });
 
   describe('constructor', () => {
@@ -209,12 +216,37 @@ describe('MultiplexerSessionManager', () => {
   });
 
   describe('polling and closure', () => {
-    test('closes pane when session becomes idle', async () => {
+    test('does not close pane on early idle status event', async () => {
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c-early-idle', parentID: 'p1' } },
+      });
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-early-idle',
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('closes pane when idle persists after busy, grace, and debounce', async () => {
       const ctx = createMockContext();
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
         paneId: 'p-1',
       });
+      let now = 1_000;
+      Date.now = () => now;
 
       const manager = new MultiplexerSessionManager(
         ctx,
@@ -226,17 +258,126 @@ describe('MultiplexerSessionManager', () => {
         type: 'session.created',
         properties: { info: { id: 'c1', parentID: 'p1' } },
       });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: { sessionID: 'c1', status: { type: 'busy' } },
+      });
 
-      // Mock status
       ctx.client.session.status.mockResolvedValue({
         data: { c1: { type: 'idle' } },
       });
 
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      now += 16_000;
       await (manager as any).pollSessions();
 
       expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p-1');
     });
 
+    test('busy status clears a pending idle debounce', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c-idle-busy', parentID: 'p1' } },
+      });
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-idle-busy',
+          status: { type: 'idle' },
+        },
+      });
+
+      now += 16_000;
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-idle-busy',
+          status: { type: 'busy' },
+        },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-idle-busy',
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('does not close on missing status during initial grace period', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c-missing-grace', parentID: 'p1' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-missing-grace',
+          status: { type: 'busy' },
+        },
+      });
+
+      ctx.client.session.status.mockResolvedValue({ data: {} });
+      await (manager as any).pollSessions();
+      now += 16_000;
+      await (manager as any).pollSessions();
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('closes on missing status only after busy, grace, and missing debounce', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c-missing-close', parentID: 'p1' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-missing-close',
+          status: { type: 'busy' },
+        },
+      });
+
+      ctx.client.session.status.mockResolvedValue({ data: {} });
+      now += 16_000;
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      now += 7_500;
+      await (manager as any).pollSessions();
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalled();
+    });
+
     test('does not close on transient status absence', async () => {
       const ctx = createMockContext();
       const manager = new MultiplexerSessionManager(
@@ -284,10 +425,21 @@ describe('MultiplexerSessionManager', () => {
         },
       });
 
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-789',
+          status: { type: 'busy' },
+        },
+      });
+
+      (manager as any).sessions.get('child-789').createdAt -= 16_000;
       ctx.client.session.status.mockResolvedValue({
         data: { 'child-789': { type: 'idle' } },
       });
       await (manager as any).pollSessions();
+      (manager as any).sessions.get('child-789').idleSince -= 16_000;
+      await (manager as any).pollSessions();
 
       await manager.onSessionStatus({
         type: 'session.status',
@@ -310,6 +462,8 @@ describe('MultiplexerSessionManager', () => {
 
     test('respawns after in-flight idle close when busy resumes same session', async () => {
       const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
@@ -340,6 +494,24 @@ describe('MultiplexerSessionManager', () => {
         },
       });
 
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-close-race',
+          status: { type: 'busy' },
+        },
+      });
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-close-race',
+          status: { type: 'idle' },
+        },
+      });
+
+      now += 16_000;
+
       const idlePromise = manager.onSessionStatus({
         type: 'session.status',
         properties: {
@@ -375,6 +547,8 @@ describe('MultiplexerSessionManager', () => {
 
     test('does not respawn after in-flight close if session is deleted', async () => {
       const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
@@ -405,6 +579,16 @@ describe('MultiplexerSessionManager', () => {
         },
       });
 
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-delete-race',
+          status: { type: 'idle' },
+        },
+      });
+
+      now += 16_000;
+
       const idlePromise = manager.onSessionStatus({
         type: 'session.status',
         properties: {

+ 67 - 4
src/multiplexer/session-manager.ts

@@ -18,6 +18,8 @@ interface TrackedSession {
   directory: string;
   createdAt: number;
   lastSeenAt: number;
+  hasSeenBusy: boolean;
+  idleSince?: number;
   missingSince?: number;
 }
 
@@ -44,6 +46,8 @@ interface SessionEvent {
 type CloseReason = 'idle' | 'deleted' | 'missing' | 'timeout';
 
 const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
+const SESSION_IDLE_GRACE_MS = 15 * 1000;
+const SESSION_IDLE_DEBOUNCE_MS = POLL_INTERVAL_BACKGROUND_MS * 2;
 const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 
 /**
@@ -177,6 +181,7 @@ export class MultiplexerSessionManager {
         directory,
         createdAt: now,
         lastSeenAt: now,
+        hasSeenBusy: false,
       });
 
       log('[multiplexer-session-manager] pane spawned', {
@@ -198,11 +203,12 @@ export class MultiplexerSessionManager {
     if (!sessionId) return;
 
     if (event.properties?.status?.type === 'idle') {
-      await this.closeSession(sessionId, 'idle');
+      await this.closeIfIdleConfirmed(sessionId, Date.now());
       return;
     }
 
     if (event.properties?.status?.type === 'busy') {
+      this.markBusy(sessionId);
       await this.respawnIfKnown(sessionId);
     }
   }
@@ -263,19 +269,28 @@ export class MultiplexerSessionManager {
         if (status) {
           tracked.lastSeenAt = now;
           tracked.missingSince = undefined;
-        } else if (!tracked.missingSince) {
+          if (status.type === 'busy') {
+            this.markBusy(sessionId, now);
+          }
+        } else if (
+          tracked.hasSeenBusy &&
+          now - tracked.createdAt >= SESSION_IDLE_GRACE_MS &&
+          !tracked.missingSince
+        ) {
           tracked.missingSince = now;
         }
 
+        const idleConfirmed = isIdle && this.markIdleAndCheck(tracked, now);
         const missingTooLong =
+          tracked.hasSeenBusy &&
           !!tracked.missingSince &&
           now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
         const isTimedOut = now - tracked.createdAt > SESSION_TIMEOUT_MS;
 
-        if (isIdle || missingTooLong || isTimedOut) {
+        if (idleConfirmed || missingTooLong || isTimedOut) {
           sessionsToClose.push({
             sessionId,
-            reason: isIdle ? 'idle' : isTimedOut ? 'timeout' : 'missing',
+            reason: idleConfirmed ? 'idle' : isTimedOut ? 'timeout' : 'missing',
           });
         }
       }
@@ -330,6 +345,53 @@ export class MultiplexerSessionManager {
     await closePromise;
   }
 
+  private async closeIfIdleConfirmed(
+    sessionId: string,
+    now: number,
+  ): Promise<void> {
+    const tracked = this.sessions.get(sessionId);
+    if (!tracked) return;
+
+    if (this.markIdleAndCheck(tracked, now)) {
+      await this.closeSession(sessionId, 'idle');
+    }
+  }
+
+  private markIdleAndCheck(tracked: TrackedSession, now: number): boolean {
+    tracked.lastSeenAt = now;
+    tracked.missingSince = undefined;
+
+    if (!tracked.hasSeenBusy) {
+      log('[multiplexer-session-manager] idle ignored before busy observed', {
+        sessionId: tracked.sessionId,
+      });
+      return false;
+    }
+
+    if (!tracked.idleSince) {
+      tracked.idleSince = now;
+      log('[multiplexer-session-manager] idle observed, waiting to confirm', {
+        sessionId: tracked.sessionId,
+      });
+      return false;
+    }
+
+    return (
+      now - tracked.createdAt >= SESSION_IDLE_GRACE_MS &&
+      now - tracked.idleSince >= SESSION_IDLE_DEBOUNCE_MS
+    );
+  }
+
+  private markBusy(sessionId: string, now = Date.now()): void {
+    const tracked = this.sessions.get(sessionId);
+    if (tracked) {
+      tracked.hasSeenBusy = true;
+      tracked.idleSince = undefined;
+      tracked.missingSince = undefined;
+      tracked.lastSeenAt = now;
+    }
+  }
+
   private async respawnIfKnown(sessionId: string): Promise<void> {
     if (!this.enabled || !this.multiplexer) return;
     const closing = this.closingSessions.get(sessionId);
@@ -407,6 +469,7 @@ export class MultiplexerSessionManager {
         directory: known.directory,
         createdAt: now,
         lastSeenAt: now,
+        hasSeenBusy: true,
       });
 
       log('[multiplexer-session-manager] pane respawned on busy', {