diff --git a/src/tui/index.ts b/src/tui/index.ts index 61d6fdca..8ca4c8a4 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -257,12 +257,13 @@ async function isHealthy(origin: string): Promise { } /** - * Origins this project's running servers have published, confirmed healthy. + * Origins this process published for the project, confirmed healthy. + * + * Worker threads share process.pid; an attached TUI needs the instance-scoped scrape. * * A record is only a hint about where to look: `/health` decides whether * anything is actually serving there. That also covers a server that died - * without cleaning up (its record is pruned by the reader) and an unrelated - * service squatting the port. + * without cleaning up and an unrelated service squatting the port. */ async function healthyRuntimeOrigins(api: TuiPluginApi): Promise { let directory: string | undefined @@ -275,6 +276,7 @@ async function healthyRuntimeOrigins(api: TuiPluginApi): Promise { } const healthy: string[] = [] for (const record of originsForProject(directory, worktree)) { + if (record.pid !== process.pid) continue for (const origin of candidateOrigins(record)) { if (await isHealthy(origin)) { healthy.push(origin) @@ -359,8 +361,8 @@ async function bootstrapOrigin(api: TuiPluginApi): Promise { * Resolve the PTY server origin, cheapest and quietest first: * * 1. cached, while it still answers - * 2. an origin record this project published, confirmed by `/health` — the - * common case, and it costs no session + * 2. an origin record this process published for this project, confirmed by + * `/health` — the same-process case, and it costs no session * 3. the instance-scoped scrape through a throwaway session — when nothing is * advertised (starting one, if asked) or when several records match and only * the caller's own opencode server can say which is ours diff --git a/test/tui-index.test.ts b/test/tui-index.test.ts index 11af0d5e..e6939e5e 100644 --- a/test/tui-index.test.ts +++ b/test/tui-index.test.ts @@ -16,8 +16,8 @@ // effects, not rendering. // ============================================================================= -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' -import { mkdtempSync, rmSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, jest, mock, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { writeOrigin } from '../src/shared/runtime.ts' @@ -276,6 +276,46 @@ describe('pty sidebar tui factory', () => { } }) + test('does not adopt a healthy origin published by another process for the same project', async () => { + let sessionRequests = 0 + const listener = Bun.serve({ + hostname: '::1', + port: 0, + fetch: (req) => { + if (new URL(req.url).pathname === '/api/sessions') { + sessionRequests++ + return Response.json([]) + } + return Response.json({ status: 'healthy', sessions: { total: 0, active: 0 } }) + }, + }) + writeFileSync( + join(runtimeDir, 'foreign.json'), + JSON.stringify({ + pid: process.pid + 1, + hostname: '::1', + port: listener.port, + directory: PROJECT, + }) + ) + const realFetch = globalThis.fetch + const { api, sidebar, disposeNow } = stubApi() + jest.useFakeTimers() + try { + // Dispatch in-process so discovery settles on microtasks, not network timing. + globalThis.fetch = (async (input) => listener.fetch(String(input))) as typeof fetch + await plugin.tui(api as never) + while (textNodesOf(sidebar()).includes('▶ PTY starting…')) await Promise.resolve() + expect(sessionRequests).toBe(0) + expect(textNodesOf(sidebar())).toContain('▶ PTY not running — click to start') + } finally { + globalThis.fetch = realFetch + listener.stop(true) + disposeNow() + jest.useRealTimers() + } + }) + test('defers a start asked for while a refresh is in flight', async () => { // Hold the load refresh open so the ask lands while one is provably in // flight. The overlap guard must DEFER an explicit ask, never drop it — the