Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/tui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,13 @@ async function isHealthy(origin: string): Promise<boolean> {
}

/**
* 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<string[]> {
let directory: string | undefined
Expand All @@ -275,6 +276,7 @@ async function healthyRuntimeOrigins(api: TuiPluginApi): Promise<string[]> {
}
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)
Expand Down Expand Up @@ -359,8 +361,8 @@ async function bootstrapOrigin(api: TuiPluginApi): Promise<string | null> {
* 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
Expand Down
44 changes: 42 additions & 2 deletions test/tui-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down