Skip to content

Commit 2771ff3

Browse files
committed
fix(desktop): explain why a second server cannot start
The server lock at <PYTHINKER_CODE_HOME>/server/lock is global rather than per-port, so a CLI server on any port stops the desktop Host from starting. The Host exited non-zero and the desktop reported only 'Host exited before readiness (code 1, signal null)', which names neither the conflicting process nor a way out. Parse the Host's canonical conflict line and show a Retry/Quit dialog naming the process, port and start time. Do not stop the other server: it may be serving live sessions, and the choice belongs to the user. Keep the single-instance rule itself. Two servers sharing one Pythinker home would race on session state.json writes, truncate each other's wire.jsonl rewrites, and both claim the same next sequence in the shared event journal, so refusing to start is correct. Also honor an error's declared exit code in 'server run': ServerLockedError uses 2 to mark a lock conflict, which the catch-all flattened to 1.
1 parent b544e55 commit 2771ff3

4 files changed

Lines changed: 67 additions & 1 deletion

File tree

apps/desktop/src/host-supervisor.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@ export function isPortInUseError(message: string): boolean {
2929
return /EADDRINUSE|address already in use/iu.test(message)
3030
}
3131

32+
/** The live server described by the Host's single-instance conflict line. */
33+
export interface RunningServerConflict {
34+
readonly pid: number
35+
readonly port: number
36+
readonly startedAt: string
37+
}
38+
39+
/**
40+
* Recognize the Host's single-instance lock conflict.
41+
*
42+
* The server refuses to start while another one holds the lock at
43+
* `<PYTHINKER_CODE_HOME>/server/lock`, and that lock is global rather than
44+
* per-port, so a CLI server on any port blocks the desktop Host. Without this
45+
* the conflict surfaces only as a generic non-zero exit, which tells the user
46+
* nothing about which process to stop.
47+
* @param message - Host output, including the diagnostic appended on failure.
48+
* @returns The conflicting server's details, or undefined for other failures.
49+
*/
50+
export function parseRunningServerConflict(message: string): RunningServerConflict | undefined {
51+
const match = /server already running \(pid=(\d+), port=(\d+), started=([^)]*)\)/u.exec(message)
52+
if (match === null) return undefined
53+
return { pid: Number(match[1]), port: Number(match[2]), startedAt: match[3]! }
54+
}
55+
3256
/** Incremental parser for the Web Host's canonical readiness line. */
3357
export interface ReadinessParser {
3458
/**

apps/desktop/src/main.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import {
2828
createHostSupervisor,
2929
isPortInUseError,
30+
parseRunningServerConflict,
3031
resolveDesktopPort,
3132
spawnPythinkerServer,
3233
type HostSupervisor,
@@ -345,6 +346,22 @@ async function boot(): Promise<void> {
345346
} catch (error) {
346347
track('desktop_server_failed')
347348
const message = error instanceof Error ? error.message : String(error)
349+
350+
const conflict = parseRunningServerConflict(message)
351+
if (conflict !== undefined) {
352+
const conflicted = await dialog.showMessageBox({
353+
type: 'error',
354+
buttons: ['Retry', 'Quit'],
355+
defaultId: 0,
356+
cancelId: 1,
357+
title: `${APP_NAME} cannot start its server`,
358+
message: `Another Pythinker server is already running (process ${String(conflict.pid)} on port ${String(conflict.port)}, started ${conflict.startedAt}). Only one server can run at a time, because they would share the same session files. Stop that server, then retry.`,
359+
})
360+
if (conflicted.response === 0) continue
361+
await requestAppQuit()
362+
return
363+
}
364+
348365
if (!isPortInUseError(message)) throw error
349366

350367
const result = await dialog.showMessageBox({

apps/desktop/tests/host-supervisor.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,28 @@ describe('desktop Host port', () => {
139139
)).toBe(true)
140140
expect(hostSupervisor.isPortInUseError('desktop Host exited before readiness (code 1, signal null)')).toBe(false)
141141
})
142+
143+
it('detects output that reports a single-instance lock conflict', () => {
144+
// Verbatim failure observed when a CLI server held the global lock.
145+
const observed = [
146+
'desktop Host exited before readiness (code 1, signal null)',
147+
'Host output:',
148+
'server already running (pid=78405, port=58700, started=2026-08-17T18:06:12.341Z)',
149+
].join('\n')
150+
151+
expect(hostSupervisor.parseRunningServerConflict(observed)).toEqual({
152+
pid: 78_405,
153+
port: 58_700,
154+
startedAt: '2026-08-17T18:06:12.341Z',
155+
})
156+
})
157+
158+
it('ignores failures that are not a lock conflict', () => {
159+
expect(hostSupervisor.parseRunningServerConflict(
160+
'listen EADDRINUSE: address already in use 127.0.0.1:24827',
161+
)).toBeUndefined()
162+
expect(hostSupervisor.parseRunningServerConflict('server already running')).toBeUndefined()
163+
})
142164
})
143165

144166
describe('desktop Host supervisor', () => {

apps/pythinker-code/src/cli/sub/server/run.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean })
138138
`${error instanceof Error ? error.message : String(error)}\n`,
139139
);
140140
} finally {
141-
process.exit(1);
141+
// Errors that declare an exit code mean it: ServerLockedError uses 2 so
142+
// callers can tell a single-instance conflict from a generic failure.
143+
const declared = (error as { readonly exitCode?: unknown }).exitCode;
144+
process.exit(typeof declared === 'number' ? declared : 1);
142145
}
143146
}
144147
});

0 commit comments

Comments
 (0)