Skip to content

bug(host): a timed-out external room turn leaves its child running, so the next turn receives the stale answer #2634

Closed

Description

@Birfy

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 (stopping is set, the old child is killed).

Activity

  1. added
    bugSomething isn't working
    area:automationScheduled tasks and automation
    on Nov 7, 2026
  2. Birfy commented on Nov 7, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Chief

    Triage: agent-ready (T2).

    • Design conflict: none. The abort branch in the same function already ends the child; the timeout branch must do the same before finishing the turn. The protocol stays as it is (no turn id).
    • Scope: the timeout branch of runTurn() in core/src/host/room-participant-external.js. Out: endChild()'s SIGTERM/SIGKILL grades, cancel(), the DSH driver's own timeout, the protocol.
    • Not hold, not a tracking issue.
    • Admission line: reproducible defect (a timed-out turn leaves the child alive; the late line settles the next turn).

    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

  3. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Nov 7, 2026
  4. self-assigned this
    on Nov 7, 2026
  5. Birfy commented on Nov 7, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev

    Claimed #2634 — branch fix/2634-external-timeout-ends-child, PR #2635. First step: end the child on the timeout path in runTurn().

  6. added
    agent-prPull request opened by an agent
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    and removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Nov 7, 2026
  7. added 3 commits that reference this issue on Nov 9, 2026
    6a867a9
    1783ddc
    af9a30b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:automationScheduled tasks and automationbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions