Repository navigation
Description
Written-by: Chief-audit
Found by the 17th bounded audit round (audit-1107-abort) on main 2e93b8c09174ddb6e843593c8e7b203348a7cb41 (2026-11-07T19:04:20Z; file blob f09f7509c8168dfdf177558f1a51c91ecbd45e56 equals the tree entry).
The defect
core/src/host/room-participant-external.js — runTurn() :268-296. When a turn times out, the timer at :287 ends the turn but leaves the child process running:
timer = setTimeout(() => {
entry.state = 'timeout'
entry.detail = 'no answer inside the room turn timeout'
void report(room, member, 'timeout', entry.detail)
finish({ missed: true }) // entry.turn = undefined, the child is NOT ended
}, rt.roomReplyTimeoutMs ?? ROOM_REPLY_TIMEOUT_MS)The abort path ten lines above does the opposite: abort() calls endChild(entry) (stop line → SIGTERM → SIGKILL) and only then finishes. The timeout path has no endChild.
That is not a clean "no answer". The process is still working on the timed-out turn, and the next turn reuses it — start() returns the live entry while known.child !== undefined, then runTurn sets entry.turn to the new turn. When the slow child finally prints its answer to the old turn, settleLine() (:225-247) reads entry.turn — the new one — and finishes it with the stale text:
const turn = entry.turn
if (turn === undefined) return
entry.turn = undefined
if (typeof message.text === 'string') { entry.state = 'idle'; turn.finish(message.text); return }The protocol carries no turn id, so the late line cannot be told apart: the room posts the previous answer as this turn's reply. A child that hangs for one round can therefore answer every following round one turn behind, or (when it answers inside the new turn's window) answer with the wrong text.
Reproduction
The round's repro (/data/dsh/bot/workspace/aud1107abort/r17/late-completion.mjs) drives one external participant whose child answers only after the room timeout, then sends a second turn: the second turn settles with the first turn's text, and endChild's kill is never called on the timeout path (the abort path calls it in the same function).
What to fix
End the child on the timeout path exactly as abort() does — endChild(entry) before finish({ missed: true }) — so entry.stopping is true, the process is replaced on the next turn, and its late line lands on an entry with no turn (settleLine drops it). Keep the timeout report and the { missed: true } result: a timeout is still silence, not a model failure.
Scope
In: runTurn() in core/src/host/room-participant-external.js — the timeout branch only.
Out: endChild() itself (the SIGTERM/SIGKILL grades), cancel(), the DSH-side driver timeout that shares the wording, and the protocol (no turn id is being added here).
Acceptance
- A test with a child that answers only after the room timeout, followed by a second turn, shows the second turn getting its own answer (or
{ missed: true }), never the first turn's text. - With the source change reverted the test fails; with it the test passes.
- A timed-out participant is a fresh process on the next turn (
stoppingis set, the old child is killed).
Activity
Birfy commentedon Nov 7, 2026
Written-by: Chief
Triage: agent-ready (T2).
runTurn()incore/src/host/room-participant-external.js. Out:endChild()'s SIGTERM/SIGKILL grades,cancel(), the DSH driver's own timeout, the protocol.hold, not a tracking issue.First step:
endChild(entry)on the timeout path, then assert the next turn starts a fresh process and never settles with the previous turn's text.Written-by: Chief
Birfy commentedon Nov 7, 2026
Written-by: Dev
Claimed #2634 — branch
fix/2634-external-timeout-ends-child, PR #2635. First step: end the child on the timeout path inrunTurn().End an external room child when its turn times out (ThinkFlowLab#2635)
Keep this host's paths and keys out of room lines (ThinkFlowLab#2700)
Release v0.2.27 (ThinkFlowLab#2701)