Browse Source

fix(zellij): await availability probe instead of caching in-flight false

mygo 1 week ago
parent
commit
d938a568f1
2 changed files with 68 additions and 4 deletions
  1. 51 0
      src/multiplexer/zellij/index.test.ts
  2. 17 4
      src/multiplexer/zellij/index.ts

+ 51 - 0
src/multiplexer/zellij/index.test.ts

@@ -271,6 +271,57 @@ describe('ZellijMultiplexer', () => {
         false,
       );
     });
+
+    test('a second availability check awaits the in-flight probe instead of returning false early', async () => {
+      const { ZellijMultiplexer } = await importFreshZellij();
+      const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+      let releaseWhich!: () => void;
+      const whichGate = new Promise<void>((resolve) => {
+        releaseWhich = resolve;
+      });
+
+      crossSpawnMock.mockImplementation((command: string[]) => {
+        if (command[0] === 'which' || command[0] === 'where') {
+          return {
+            ...createSpawnResult(0, '/usr/bin/zellij\n'),
+            exited: whichGate.then(() => 0),
+          };
+        }
+        if (command.includes('--version')) {
+          return createSpawnResult(0, 'zellij 0.44.1\n');
+        }
+        return createSpawnResult();
+      });
+
+      const first = zellij.isAvailable();
+      // Second call while the binary probe is still pending: it must join the
+      // in-flight probe rather than short-circuit to an early false.
+      const second = zellij.isAvailable();
+
+      let secondSettled = false;
+      void second.then(() => {
+        secondSettled = true;
+      });
+
+      // Flush microtasks deterministically; the probe is gated on `which`, so
+      // the second call must not settle before the probe completes.
+      for (let i = 0; i < 32; i++) {
+        await Promise.resolve();
+      }
+      expect(secondSettled).toBe(false);
+
+      releaseWhich();
+      await expect(first).resolves.toBe(true);
+      await expect(second).resolves.toBe(true);
+      expect(secondSettled).toBe(true);
+
+      // Only one probe ran: both calls shared the same in-flight promise.
+      const discoveryCalls = commands().filter(
+        (c) => c[0] === 'which' || c[0] === 'where',
+      );
+      expect(discoveryCalls).toHaveLength(1);
+    });
   });
 
   test('current-tab mode spawns a pane in the parent OpenCode tab', async () => {

+ 17 - 4
src/multiplexer/zellij/index.ts

@@ -51,7 +51,7 @@ export class ZellijMultiplexer implements Multiplexer {
   readonly type = 'zellij' as const;
 
   private binaryPath: string | null = null;
-  private hasChecked = false;
+  private availabilityPromise: Promise<boolean> | null = null;
   private agentTabId: string | null = null;
   private firstPaneId: string | null = null;
   private firstPaneUsed = false;
@@ -79,10 +79,23 @@ export class ZellijMultiplexer implements Multiplexer {
   }
 
   async isAvailable(): Promise<boolean> {
-    if (this.hasChecked) {
-      return this.binaryPath !== null;
+    // Cache the in-flight probe itself, not just the result: if availability
+    // is checked while the first probe is still running (e.g. an early
+    // sub-agent event racing the plugin's own startup check), the caller
+    // awaits the same promise instead of seeing hasChecked=true with
+    // binaryPath still null and wrongly concluding the backend is absent.
+    if (this.availabilityPromise) {
+      return this.availabilityPromise;
     }
-    this.hasChecked = true;
+    this.availabilityPromise = this.probeAvailability();
+    return this.availabilityPromise;
+  }
+
+  /**
+   * Resolve the zellij binary and gate on its version. Runs at most once per
+   * adapter instance (the promise is cached by isAvailable).
+   */
+  private async probeAvailability(): Promise<boolean> {
     const binaryPath = await findBinary('zellij');
     if (binaryPath && (await this.hasSupportedVersion(binaryPath))) {
       this.binaryPath = binaryPath;