Skip to content

Commit d20995b

Browse files
committed
fix(groups): end a stop the child ignores, and let the room see why it died (#1164)
Three defects the merge review of #1153 left as follow-ups, all in the external room driver. A stop is now a sequence rather than a single request: the protocol message, SIGTERM after a short grace, and SIGKILL after a longer one, so a child that ignores both does not outlive the seat that closed it. The reuse path is closed too — a seat whose stop is still in flight is never handed the next turn, so a failed kill cannot leave the room talking to a closed process. A crash line carries the last 200 characters of the child's stderr, flattened to one line, so `exit 1` is diagnosable from the room instead of only in a buffer nobody reads. The report moved to the child's `close` event, which is the point where its last stderr lines have been read. The timeout state reads as "timed out" rather than "process timeout".
1 parent 7fe7149 commit d20995b

6 files changed

Lines changed: 300 additions & 39 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The external room driver now ends a stop it is ignored: after `{ type: 'stop' }` and a `SIGTERM` grace it escalates to `SIGKILL`, and a seat whose stop is still in flight is never handed the next turn, so a child cannot outlive its membership. A crash line carries the last ~200 characters of the child's `stderr` (flattened and capped) instead of only `exit N`, and the room-turn timeout reads as "timed out" rather than "process timeout" (#1164).

‎core/src/host/room-participant-external.js‎

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ const INTERPRETERS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'mksh', 'cmd',
1717
const MAX_COMMAND = 32
1818
const MAX_ENV = 32
1919
const MAX_OUT = 4096
20+
// A stop is a request, a signal is a demand, a SIGKILL is the end: a child that ignores the
21+
// first two would otherwise outlive the seat that closed it (#1164).
22+
const TERM_GRACE_MS = 50
23+
const KILL_GRACE_MS = 500
24+
// 'close' is not guaranteed: a grandchild can hold the inherited stdio open, so a crash is
25+
// reported from 'exit' after this much stderr has arrived (#1164).
26+
const DRAIN_MS = 100
27+
// Only the last words of a crashing child are worth a room line (#1164).
28+
const STDERR_TAIL = 200
2029

2130
const oneLine = value => String(value ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200)
2231

@@ -63,6 +72,15 @@ export function createExternalRoomParticipant(rt) {
6372
const keyOf = (room, member) => `${room.id}\u0000${member.id}`
6473
const sessionIdOf = (room, member) => `external:${room.id}:${member.id}`
6574

75+
// A state is a sentence the room reads, not a state name: `timeout` reads as `timed out` (#1164).
76+
function stateLine(member, state, detail) {
77+
if (state === 'crashed') return `${member.name}'s external process crashed${detail ? ` (${detail})` : ''}. Its turns in this room have no answer until it starts again.`
78+
return `${member.name}'s external process ${state === 'timeout' ? 'timed out' : state}${detail ? ` (${detail})` : ''}.`
79+
}
80+
81+
// A crash line carries the child's last words, flattened and capped, so `exit 1` is diagnosable
82+
// from the room itself (#1164).
83+
const tailOf = (entry) => String(entry.stderr ?? '').slice(-STDERR_TAIL).replace(/[\r\n]+/g, ' ').trim().slice(0, STDERR_TAIL)
6684
// The room sees what happened to its participant, not just a turn that never came back (#198).
6785
async function report(room, member, state, detail) {
6886
rt.warn?.('ds-bot: external Bot %s in %s: %s%s', member.name, room.name, state, detail ? ` (${detail})` : '')
@@ -73,9 +91,7 @@ export function createExternalRoomParticipant(rt) {
7391
authorId: member.id,
7492
authorKind: 'bot',
7593
kind: 'external',
76-
text: state === 'crashed'
77-
? `${member.name}'s external process crashed${detail ? ` (${detail})` : ''}. Its turns in this room have no answer until it starts again.`
78-
: `${member.name}'s external process ${state}${detail ? ` (${detail})` : ''}.`,
94+
text: stateLine(member, state, detail),
7995
})
8096
} catch (error) {
8197
rt.warn?.('ds-bot: the state of %s in %s was not posted: %s', member.name, room.name, error)
@@ -96,11 +112,14 @@ export function createExternalRoomParticipant(rt) {
96112
const key = keyOf(room, member)
97113
const known = processes.get(key)
98114
if (known !== undefined) {
115+
// A live process answers every turn, unless its seat has already been stopped: a stop that
116+
// was ignored must never hand the next turn to the process the room just closed (#1164).
117+
if (known.stopping === true) processes.delete(key)
99118
// Still running: the same process answers every turn in this room.
100-
if (known.child !== undefined || known.turn !== undefined) return known
119+
else if (known.child !== undefined || known.turn !== undefined) return known
101120
// It died (crash, clean exit, or the user stopped it). The next turn starts a fresh
102121
// one, which is what the room's line promises (#198).
103-
processes.delete(key)
122+
else processes.delete(key)
104123
}
105124
const record = member.external
106125
const entry = { state: 'starting', detail: undefined, turn: undefined, child: undefined, buffer: '' }
@@ -135,19 +154,40 @@ export function createExternalRoomParticipant(rt) {
135154
child.stderr.on('data', chunk => { entry.stderr = `${entry.stderr ?? ''}${chunk}`.slice(-MAX_OUT) })
136155
child.on('error', (error) => {
137156
entry.child = undefined
157+
entry.reported = true
138158
settle(entry, 'crashed', oneLine(error?.message ?? error))
139159
void report(room, member, 'crashed', entry.detail)
140160
})
141-
child.on('exit', (code, signal) => {
142-
entry.child = undefined
143-
// A stop the child obeyed — it read the message and left on its own — is the same exit the
144-
// signal branch below skips: it stopped when asked, so the room must not hear 'crashed' (#198).
161+
const reportExit = (code, signal) => {
162+
// One line either way: whichever of 'exit' and 'close' reports first wins (#1164).
163+
if (entry.reported === true) return
164+
entry.reported = true
165+
// A stop the child obeyed — it read the message and left on its own — is not an ending the
166+
// room should hear about: it stopped when asked, so no 'crashed' line (#198).
145167
if (entry.state === 'stopped' || entry.stopping === true) return
146-
const clean = code === 0 && entry.stopping !== true
168+
const clean = code === 0
147169
const detail = signal !== null ? `signal ${signal}` : `exit ${code}`
148-
settle(entry, clean ? 'exited' : 'crashed', detail)
149-
void report(room, member, entry.state, detail)
170+
const tail = clean ? '' : tailOf(entry)
171+
const said = tail === '' ? detail : `${detail}: ${tail}`
172+
settle(entry, clean ? 'exited' : 'crashed', said)
173+
void report(room, member, entry.state, said)
150174
entry.stopping = false
175+
}
176+
// 'exit' must free the seat at once — otherwise the next turn writes into a dead child and the
177+
// room only sees a missed turn. It also arms one short drain, so the child's last stderr lines
178+
// can still arrive before the crash line is posted (#1164).
179+
let drain
180+
child.on('exit', (code, signal) => {
181+
entry.child = undefined
182+
drain = setTimeout(() => reportExit(code, signal), DRAIN_MS)
183+
drain.unref?.()
184+
})
185+
// 'close' carries the full tail, but it may never come: a grandchild holding the inherited
186+
// stdio postpones it for as long as it lives, so it is the confirmation, not the only path.
187+
child.on('close', (code, signal) => {
188+
clearTimeout(drain)
189+
entry.child = undefined
190+
reportExit(code, signal)
151191
})
152192
return entry
153193
}
@@ -160,10 +200,13 @@ export function createExternalRoomParticipant(rt) {
160200
// The protocol message is a courtesy the child may act on; the signal follows so a child that
161201
// never reads still goes (#198).
162202
try { child.stdin.write(`${JSON.stringify({ type: 'stop' })}\n`) } catch { /* the pipe may already be closed */ }
163-
const kill = () => { try { child.kill('SIGTERM') } catch { /* the process may already be gone */ } }
164-
const grace = setTimeout(kill, 50)
165-
grace.unref?.()
166-
child.once?.('exit', () => clearTimeout(grace))
203+
const sendSignal = (name) => { try { child.kill(name) } catch { /* the process may already be gone */ } }
204+
// SIGTERM first, then SIGKILL: a stop that is ignored is not a stop (#1164).
205+
const term = setTimeout(() => sendSignal('SIGTERM'), TERM_GRACE_MS)
206+
const hard = setTimeout(() => sendSignal('SIGKILL'), TERM_GRACE_MS + KILL_GRACE_MS)
207+
term.unref?.()
208+
hard.unref?.()
209+
child.once?.('exit', () => { clearTimeout(term); clearTimeout(hard) })
167210
}
168211

169212
// A member who loses the seat, or a Bot that is deleted, takes its process with it: a child

‎core/test/room-external.test.js‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,91 @@ test('a turn that times out comes back as no answer, not as a model failure (#19
129129
assert.equal(entry.botId, 'slow')
130130
})
131131

132+
test('a child that ignores the stop is killed after the grace (#1164)', async (t) => {
133+
// SIGTERM is handled and deliberately ignored: only an escalation can take this child.
134+
const STUBBORN = "const fs=require('node:fs');fs.appendFileSync(process.env.SEEN,'start '+process.pid+'\\n');process.on('SIGTERM',()=>{});process.stdin.resume()"
135+
const dir = mkdtempSync(join(tmpdir(), 'flybot-external-'))
136+
const seen = join(dir, 'pids.txt')
137+
writeFileSync(seen, '')
138+
const { driver, run } = fixture(t)
139+
const member = { id: 'stubborn', name: 'Stubborn', external: { driver: 'subprocess', command: [process.execPath, '-e', STUBBORN], env: { SEEN: seen } } }
140+
const turn = run(member, 'work')
141+
await waitFor(() => /start \d+/.test(readFileSync(seen, 'utf8')))
142+
const pid = Number(readFileSync(seen, 'utf8').match(/start (\d+)/)[1])
143+
assert.equal(driver.closeParticipant('ops', 'stubborn'), true)
144+
assert.equal(await turn, null)
145+
await waitFor(() => { try { process.kill(pid, 0); return false } catch { return true } }, 4000)
146+
})
147+
148+
test('a seat that was closed is never handed the next turn (#1164)', async (t) => {
149+
// The child records each turn with its own pid and never answers. A stopped turn leaves the
150+
// entry in place while the child may still be running: that is the reuse path — the next turn
151+
// must reach a fresh process, not the one the user just stopped.
152+
const PID_LOG = "const fs=require('node:fs');fs.appendFileSync(process.env.SEEN,'start '+process.pid+'\\n');process.on('SIGTERM',()=>{});let b='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>{b+=c;let i;while((i=b.indexOf('\\n'))!==-1){const l=b.slice(0,i);b=b.slice(i+1);if(JSON.parse(l).type==='turn')fs.appendFileSync(process.env.SEEN,'turn '+process.pid+'\\n')}})"
153+
const dir = mkdtempSync(join(tmpdir(), 'flybot-external-'))
154+
const seen = join(dir, 'pids.txt')
155+
writeFileSync(seen, '')
156+
const { driver, run } = fixture(t)
157+
const member = { id: 'seat2', name: 'Seat2', external: { driver: 'subprocess', command: [process.execPath, '-e', PID_LOG], env: { SEEN: seen } } }
158+
const turns = () => readFileSync(seen, 'utf8').split('\n').filter(row => row.startsWith('turn ')).map(row => row.slice(5))
159+
const stopped = run(member, 'one')
160+
await waitFor(() => turns().length === 1)
161+
const first = turns()[0]
162+
assert.equal(await driver.cancel({ sessionId: 'external:ops:seat2' }), true)
163+
assert.equal(await stopped, null)
164+
const next = run(member, 'two')
165+
await waitFor(() => turns().length === 2)
166+
const second = turns()[1]
167+
assert.notEqual(second, first, 'the closed process must not answer the next turn')
168+
await driver.cancel({ sessionId: 'external:ops:seat2' })
169+
assert.equal(await next, null)
170+
})
171+
test('the timeout state line says timed out (#1164)', async (t) => {
172+
const { rt, lines, run } = fixture(t)
173+
rt.roomReplyTimeoutMs = 200
174+
const member = { id: 'late', name: 'Late', external: { driver: 'subprocess', command: [process.execPath, '-e', 'process.stdin.resume()'] } }
175+
assert.deepEqual(await run(member, 'work'), { missed: true })
176+
const entry = await waitForLine(lines, 'late')
177+
assert.match(entry.text, /external process timed out/)
178+
assert.doesNotMatch(entry.text, /process timeout/)
179+
})
180+
181+
test('a crash line carries the last words from stderr (#1164)', async (t) => {
182+
const BOOM = "process.stderr.write('boom: missing config\\n',()=>process.exit(3))"
183+
const { lines, run } = fixture(t)
184+
const member = { id: 'boom', name: 'Boom', external: { driver: 'subprocess', command: [process.execPath, '-e', BOOM] } }
185+
assert.equal((await run(member))?.failure?.reason, 'crashed')
186+
const entry = await waitForLine(lines, 'boom')
187+
assert.match(entry.text, /crashed \(exit 3: boom: missing config\)/)
188+
// The tail is capped: 400 characters of stderr keep only the last 200.
189+
const NOISE = "process.stderr.write('x'.repeat(400),()=>process.exit(3))"
190+
const noisy = { id: 'noise', name: 'Noise', external: { driver: 'subprocess', command: [process.execPath, '-e', NOISE] } }
191+
assert.equal((await run(noisy))?.failure?.reason, 'crashed')
192+
const noise = await waitForLine(lines, 'noise')
193+
assert.match(noise.text, /x{200}/)
194+
assert.doesNotMatch(noise.text, /x{201}/)
195+
})
196+
test('a crash is reported when a grandchild holds the stdio open (#1164)', async (t) => {
197+
// On the first launch the child leaves a grandchild on its inherited stdio and exits 1, so
198+
// 'close' is postponed for as long as the grandchild lives (measured here: exit at ~40 ms,
199+
// close at ~1280 ms). The crash line must not wait for 'close', and the seat must be free at
200+
// once. The marker makes the second launch a normal responder.
201+
const HELD = "const fs=require('node:fs');const cp=require('node:child_process');if(!fs.existsSync(process.env.MARK)){fs.writeFileSync(process.env.MARK,'1');fs.writeSync(2,'boom: helper holds the pipe\\n');cp.spawn(process.execPath,['-e','setTimeout(()=>{},1200)'],{stdio:'inherit'});process.exit(1)}let b='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>{b+=c;let i;while((i=b.indexOf('\\n'))!==-1){const l=b.slice(0,i);b=b.slice(i+1);const m=JSON.parse(l);if(m.type==='turn')process.stdout.write(JSON.stringify({text:'again: '+m.text})+'\\n')}})"
202+
const dir = mkdtempSync(join(tmpdir(), 'flybot-external-'))
203+
const mark = join(dir, 'mark')
204+
const { lines, run } = fixture(t)
205+
const member = { id: 'held', name: 'Held', external: { driver: 'subprocess', command: [process.execPath, '-e', HELD], env: { MARK: mark } } }
206+
const started = Date.now()
207+
const first = run(member)
208+
// 900 ms is before the grandchild's 1.2 s: only reporting from 'exit' can meet this.
209+
const entry = await waitForLine(lines, 'held', 900)
210+
const elapsed = Date.now() - started
211+
assert.match(entry.text, /crashed \(exit 1: boom: helper holds the pipe\)/)
212+
assert.ok(elapsed < 900, `the crash line waited for close (${elapsed} ms)`)
213+
assert.equal((await first)?.failure?.reason, 'crashed')
214+
// The seat was cleared on 'exit', so the next turn reaches a fresh process, not a dead pipe.
215+
assert.equal(await run(member, 'ok'), 'again: ok')
216+
})
132217
test('a record may not carry a command line, only arguments (#198)', () => {
133218
assert.throws(() => cleanExternal({ command: 'node agent.js' }), /array of arguments/)
134219
assert.throws(() => cleanExternal({ command: [] }), /1 to 32/)
@@ -140,6 +225,10 @@ test('a record may not carry a command line, only arguments (#198)', () => {
140225
{ driver: 'subprocess', command: ['node', 'agent.js'], cwd: '/tmp', env: { HOME: '/tmp' } })
141226
})
142227

228+
const waitForLine = async (lines, botId, ms = 4000) => {
229+
await waitFor(() => lines.some(line => line.botId === botId), ms)
230+
return lines.find(line => line.botId === botId)
231+
}
143232
const waitFor = async (done, ms = 4000) => {
144233
const started = Date.now()
145234
while (!done()) {

0 commit comments

Comments
 (0)