فهرست منبع

fix: debounce tmux layout updates

dhaern 3 ماه پیش
والد
کامیت
66251f894a
2فایلهای تغییر یافته به همراه219 افزوده شده و 25 حذف شده
  1. 148 0
      src/multiplexer/tmux/index.test.ts
  2. 71 25
      src/multiplexer/tmux/index.ts

+ 148 - 0
src/multiplexer/tmux/index.test.ts

@@ -0,0 +1,148 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+  kill: () => boolean;
+  exitCode: number | null;
+  proc: never;
+};
+
+const logMock = mock(() => {});
+const crossSpawnMock = mock((_command: string[]) => createSpawnResult());
+
+mock.module('../../utils/logger', () => ({
+  log: logMock,
+}));
+
+mock.module('../../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+function createSpawnResult(
+  exitCode = 0,
+  stdout = '',
+  stderr = '',
+): SpawnResult {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(stdout),
+    stderr: () => Promise.resolve(stderr),
+    kill: () => true,
+    exitCode,
+    proc: {} as never,
+  };
+}
+
+async function importFreshTmux() {
+  return import(`./index?test=${importCounter++}`);
+}
+
+async function wait(ms: number): Promise<void> {
+  await new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function commands(): string[][] {
+  return crossSpawnMock.mock.calls.map((call) => call[0] as string[]);
+}
+
+describe('TmuxMultiplexer', () => {
+  const originalTmux = process.env.TMUX;
+  const originalTmuxPane = process.env.TMUX_PANE;
+
+  beforeEach(() => {
+    process.env.TMUX = '/tmp/tmux-test/default,1,0';
+    process.env.TMUX_PANE = '%1';
+
+    logMock.mockClear();
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') return createSpawnResult(0, '/usr/bin/tmux\n');
+      if (command[1] === '-V') return createSpawnResult(0, 'tmux 3.6a');
+      if (command[1] === 'split-window') {
+        return createSpawnResult(0, '%2\n');
+      }
+      return createSpawnResult();
+    });
+  });
+
+  afterEach(() => {
+    process.env.TMUX = originalTmux;
+    process.env.TMUX_PANE = originalTmuxPane;
+  });
+
+  test('coalesces layout application after bursty pane spawns', async () => {
+    const { TmuxMultiplexer } = await importFreshTmux();
+    const tmux = new TmuxMultiplexer('main-vertical', 60);
+
+    await tmux.spawnPane('session-1', 'First worker', 'http://localhost:4096', '/repo');
+    await tmux.spawnPane('session-2', 'Second worker', 'http://localhost:4096', '/repo');
+
+    expect(
+      commands().filter((command) => command.includes('select-layout')),
+    ).toHaveLength(0);
+
+    await wait(300);
+
+    const layoutCommands = commands().filter((command) =>
+      command.includes('select-layout'),
+    );
+    const sizeCommands = commands().filter((command) =>
+      command.includes('set-window-option'),
+    );
+
+    expect(layoutCommands).toHaveLength(2);
+    expect(sizeCommands).toHaveLength(1);
+    expect(sizeCommands[0]).toContain('main-pane-width');
+    expect(sizeCommands[0]).toContain('60%');
+  });
+
+  test('logs and stops layout sequence when a tmux layout command fails', async () => {
+    const { TmuxMultiplexer } = await importFreshTmux();
+    const tmux = new TmuxMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') return createSpawnResult(0, '/usr/bin/tmux\n');
+      if (command[1] === '-V') return createSpawnResult(0, 'tmux 3.6a');
+      if (command.includes('select-layout')) {
+        return createSpawnResult(1, '', 'layout failed');
+      }
+      return createSpawnResult();
+    });
+
+    await tmux.applyLayout('main-vertical', 60);
+
+    expect(
+      commands().filter((command) => command.includes('set-window-option')),
+    ).toHaveLength(0);
+    expect(logMock).toHaveBeenCalledWith('[tmux] command failed', {
+      command: 'select-layout',
+      args: ['/usr/bin/tmux', 'select-layout', '-t', '%1', 'main-vertical'],
+      exitCode: 1,
+      stderr: 'layout failed',
+    });
+    expect(logMock).not.toHaveBeenCalledWith(
+      '[tmux] applyLayout: applied',
+      expect.anything(),
+    );
+  });
+
+  test('direct applyLayout cancels a pending debounced layout', async () => {
+    const { TmuxMultiplexer } = await importFreshTmux();
+    const tmux = new TmuxMultiplexer('main-vertical', 60);
+
+    await tmux.spawnPane('session-1', 'First worker', 'http://localhost:4096', '/repo');
+    await tmux.applyLayout('tiled', 60);
+    await wait(300);
+
+    const layoutCommands = commands().filter((command) =>
+      command.includes('select-layout'),
+    );
+
+    expect(layoutCommands).toHaveLength(1);
+    expect(layoutCommands[0]).toContain('tiled');
+  });
+});

+ 71 - 25
src/multiplexer/tmux/index.ts

@@ -7,6 +7,8 @@ import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
 import type { Multiplexer, PaneResult } from '../types';
 
+const TMUX_LAYOUT_DEBOUNCE_MS = 150;
+
 export class TmuxMultiplexer implements Multiplexer {
   readonly type = 'tmux' as const;
 
@@ -15,6 +17,8 @@ export class TmuxMultiplexer implements Multiplexer {
   private storedLayout: MultiplexerLayout;
   private storedMainPaneSize: number;
   private targetPane = process.env.TMUX_PANE;
+  private layoutTimer?: ReturnType<typeof setTimeout>;
+  private layoutGeneration = 0;
 
   constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
     this.storedLayout = layout;
@@ -101,8 +105,8 @@ export class TmuxMultiplexer implements Multiplexer {
         );
         await renameProc.exited;
 
-        // Apply layout
-        await this.applyLayout(this.storedLayout, this.storedMainPaneSize);
+        // Rebalance panes after bursts of child sessions settle.
+        this.scheduleLayout();
 
         log('[tmux] spawnPane: SUCCESS', { paneId });
         return { success: true, paneId };
@@ -152,8 +156,8 @@ export class TmuxMultiplexer implements Multiplexer {
       log('[tmux] closePane: result', { exitCode, stderr: stderr.trim() });
 
       if (exitCode === 0) {
-        // Reapply layout to rebalance
-        await this.applyLayout(this.storedLayout, this.storedMainPaneSize);
+        // Rebalance panes after bursts of child sessions settle.
+        this.scheduleLayout();
         return true;
       }
 
@@ -169,6 +173,32 @@ export class TmuxMultiplexer implements Multiplexer {
   async applyLayout(
     layout: MultiplexerLayout,
     mainPaneSize: number,
+  ): Promise<void> {
+    if (this.layoutTimer) {
+      clearTimeout(this.layoutTimer);
+      this.layoutTimer = undefined;
+    }
+
+    this.layoutGeneration++;
+    await this.applyLayoutNow(layout, mainPaneSize);
+  }
+
+  private scheduleLayout(): void {
+    if (this.layoutTimer) clearTimeout(this.layoutTimer);
+
+    const gen = ++this.layoutGeneration;
+    this.layoutTimer = setTimeout(() => {
+      this.layoutTimer = undefined;
+      if (this.layoutGeneration === gen) {
+        void this.applyLayoutNow(this.storedLayout, this.storedMainPaneSize);
+      }
+    }, TMUX_LAYOUT_DEBOUNCE_MS);
+    this.layoutTimer.unref?.();
+  }
+
+  private async applyLayoutNow(
+    layout: MultiplexerLayout,
+    mainPaneSize: number,
   ): Promise<void> {
     const tmux = await this.getBinary();
     if (!tmux) return;
@@ -179,44 +209,34 @@ export class TmuxMultiplexer implements Multiplexer {
 
     try {
       // Apply the layout
-      const layoutProc = crossSpawn(
-        [tmux, 'select-layout', ...this.targetArgs(), layout],
-        {
-          stdout: 'pipe',
-          stderr: 'pipe',
-        },
+      const layoutResult = await this.runTmux(
+        tmux,
+        ['select-layout', ...this.targetArgs(), layout],
       );
-      await layoutProc.exited;
+      if (layoutResult !== 0) return;
 
       // For main-* layouts, set the main pane size
       if (layout === 'main-horizontal' || layout === 'main-vertical') {
         const sizeOption =
           layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
 
-        const sizeProc = crossSpawn(
+        const sizeResult = await this.runTmux(
+          tmux,
           [
-            tmux,
             'set-window-option',
             ...this.targetArgs(),
             sizeOption,
             `${mainPaneSize}%`,
           ],
-          {
-            stdout: 'pipe',
-            stderr: 'pipe',
-          },
         );
-        await sizeProc.exited;
+        if (sizeResult !== 0) return;
 
         // Reapply layout to use the new size
-        const reapplyProc = crossSpawn(
-          [tmux, 'select-layout', ...this.targetArgs(), layout],
-          {
-            stdout: 'pipe',
-            stderr: 'pipe',
-          },
+        const reapplyResult = await this.runTmux(
+          tmux,
+          ['select-layout', ...this.targetArgs(), layout],
         );
-        await reapplyProc.exited;
+        if (reapplyResult !== 0) return;
       }
 
       log('[tmux] applyLayout: applied', { layout, mainPaneSize });
@@ -225,6 +245,32 @@ export class TmuxMultiplexer implements Multiplexer {
     }
   }
 
+  private async runTmux(
+    tmux: string,
+    args: string[],
+  ): Promise<number> {
+    const proc = crossSpawn([tmux, ...args], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    const [exitCode, , stderr] = await Promise.all([
+      proc.exited,
+      proc.stdout(),
+      proc.stderr(),
+    ]);
+
+    if (exitCode !== 0) {
+      log('[tmux] command failed', {
+        command: args[0],
+        args: [tmux, ...args],
+        exitCode,
+        stderr: stderr.trim(),
+      });
+    }
+
+    return exitCode;
+  }
+
   private async getBinary(): Promise<string | null> {
     await this.isAvailable();
     return this.binaryPath;