diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 9bbb8891..d33404fb 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -744,10 +744,17 @@ export async function pidsHoldingFile(who: ChatIdentity, path: string): Promise< const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'ignore' }); const out = await new Response(proc.stdout).text(); await proc.exited; + // The platform's OWN pids are removed, always. + // + // Not defensive programming for its own sake: a leaked descriptor made the server a holder of a job's + // output file, and the next Pause `SIGSTOP`ped the whole platform through this list. The leak is fixed, + // and this is the guard that means the next one cannot do the same thing. Nothing that manages a job + // should ever be able to name the process doing the managing. + const self = new Set([process.pid, process.ppid]); return out .split(/\s+/) .map((token) => Number(token.replace(/\D/g, ''))) - .filter((n) => Number.isInteger(n) && n > 1); + .filter((n) => Number.isInteger(n) && n > 1 && !self.has(n)); } /** diff --git a/src/servers/api/chat/task-restart.ts b/src/servers/api/chat/task-restart.ts index e945df50..f3971ca1 100644 --- a/src/servers/api/chat/task-restart.ts +++ b/src/servers/api/chat/task-restart.ts @@ -1,4 +1,4 @@ -import { openSync } from 'node:fs'; +import { closeSync, openSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; import { runAsArgv } from '../../os-user'; import { readTextAs } from '../../read-as-user'; @@ -170,9 +170,19 @@ export async function restartBackgroundTask( // Appended, never truncated: the log view is reading this file, and a restart is a new chapter in a job's // life rather than a new job. + // The fd is CLOSED immediately after the spawn, and that one line is load-bearing. + // + // Leaving it open made the platform a holder of the job's output file — and `pidsHoldingFile` asks + // `fuser` who holds it. So the next Pause sent `SIGSTOP` to the platform itself: eleven threads + // stopped, port 9000 still listening with connections queueing behind it and nobody accepting. The + // server was not down, it was frozen, which looks identical from outside and is why it took a terminal + // to see. `kill -CONT` brought it back untouched. + // + // The child gets its own duplicate of the descriptor at spawn, so closing here costs it nothing. const fd = openSync(output, 'a'); const argv = who.osUser ? runAsArgv(who.osUser, ['sh', '-c', command]) : ['sh', '-c', command]; const proc = Bun.spawn(argv, { cwd, stdout: fd, stderr: fd, stdin: 'ignore' }); + closeSync(fd); restarted.set(taskId, { proc }); void proc.exited.then((code) => {