a job's controls can no longer freeze the platform

The server went down. It had not: it was STOPPED — eleven threads in state T, port
9000 still listening with connections queueing behind a process that would never
accept them. Indistinguishable from a crash unless you look, which is why it took a
terminal to see. `kill -CONT` brought it back with nothing lost.

I did it, with one missing line. `restartBackgroundTask` opened the job's output
file to hand the child a descriptor and never closed it, so the PLATFORM became a
holder of that file. `pidsHoldingFile` asks `fuser` who holds it — that is how Stop
finds a job with no recorded pid — so the platform was in the answer. The next Pause
sent SIGSTOP to it.

Every part worked as designed. Pause froze exactly what was holding the file, and
one of those things was the thing doing the freezing.

Two fixes, and the second matters more than the first:

**Close the descriptor.** The child gets its own duplicate at spawn, so closing
costs it nothing. That removes this instance.

**Never signal our own pids, whatever `fuser` says.** `pidsHoldingFile` now filters
`process.pid` and `process.ppid`. The leak is fixed; this is what makes the next one
survivable. Nothing that manages a job should be able to name the process doing the
managing — a rule worth having independently of the bug that suggested it.

Verified: after a restart this process holds the output file zero times, `fuser`
returns only the spawned shell and its child, and our own pid is filtered even when
present.

tsgo clean, frontend builds, 901 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 14:28:16 +00:00
co-authored by Claude Opus 5
parent bc2bafe24a
commit bda5eb15de
2 changed files with 19 additions and 2 deletions
+8 -1
View File
@@ -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));
}
/**
+11 -1
View File
@@ -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) => {