Skip to content

[factory] Broker cannot observe SIGTERM while an event handler is running, so shutdown overruns its window #1534

Description

@khaliqgant

Summary

E2E Tests fails intermittently on macos-latest with:

Graceful shutdown timed out after 10000ms. Use --force to kill.
[ERROR] Broker still reported as running after down command

The Relaycast presence phase is already bounded (SHUTDOWN_RELAYCAST_PHASE_TIMEOUT = 2500ms, event_loop.rs:35), so that is no longer the cause. What remains is structural: the broker cannot observe SIGTERM while it is inside an event handler, so any slow handler delays shutdown by its full duration.

It is flaky, not broken — which is why it keeps getting misattributed

Same branch, same code, three runs:

head E2E
fdd8dc485 pass
158fdd987 pass
a85744806 fail

main alternates the same way — 31947aa03 pass, 62acd3736 fail, 87ada407c fail. Only the macOS leg fails; ubuntu passes. This has already been misattributed once: it was bisected to #1431, and a controlled A/B (both broker binaries built, SIGTERM→exit timed directly, fresh workspace per run) measured 8.3–9.5s on both, statistically indistinguishable. #1431 is exonerated. Shutdown simply sits close enough to the CLI's 10s down --timeout that runner speed decides the outcome.

Mechanism

crates/broker/src/runtime/event_loop.rs:320-372:

while !self.shutdown {
    let event = tokio::select! {
        _ = tokio::signal::ctrl_c() => RuntimeEvent::CtrlC,
        _ = self.lease_check.tick()  => RuntimeEvent::LeaseTick,
        _ = self.sigterm.recv()      => RuntimeEvent::Sigterm,
        ...
    };
    match event {
        ... => self.handle_api_request(*request).await,        // :349
        ... => self.handle_relaycast_message(message).await,   // :360
        ... => self.handle_fleet_control_event(event).await,   // :366
        ... => self.handle_terminal_control_event(event).await,// :372
    }
}

self.sigterm.recv() is only polled inside the select!. Each handler is then awaited in the loop body, outside it. So while a handler runs, SIGTERM is not being observed at all — the signal waits for the handler to finish before the loop comes back round.

An agent registration handled on this path performs a network round trip to Relaycast. On a slow runner, one in-flight registration can consume most of the 10s window on its own, and the bounded presence phase afterwards cannot recover time already spent.

Why bounding the presence phase did not fix it

SHUTDOWN_RELAYCAST_PHASE_TIMEOUT bounds work done after shutdown_runtime is entered. It cannot help when the delay is in reaching that function, because the loop has not yet observed the signal. The two are in series, and only the second half is bounded.

Suggested direction, to challenge rather than follow

  • Make handler dispatch cancellable so SIGTERM wins immediately — e.g. run the handler as a branch of the same select!, or select! the handler future against the shutdown signal so the loop can abandon it.
  • Or bound the handlers themselves, so no single event can consume the caller's shutdown window.
  • Decide explicitly what a cancelled in-flight registration should do. Abandoning one mid-flight is a real semantic choice, not a detail — the identity may already be claimed server-side.

Definition of done

  • With a deliberately slow Relaycast (block or delay /v1/agents/*), SIGTERM still reaches shutdown promptly and node down completes inside its window. Demonstrate both directions: the test must fail against today's code and pass after, or it proves nothing.
  • The E2E shutdown phase stops depending on runner speed. Given the flakiness, a single green run is not evidence — state how many consecutive macOS runs were observed.
  • Note in the PR what happens to an in-flight registration that is cancelled.

Notes for whoever picks this up

  • Do not re-bisect to #1431. It has been measured and exonerated; a clean bisect boundary here is coincidence, because the mechanism is ambient latency crossing a fixed threshold rather than a code change.
  • Branch fix/e2e-graceful-shutdown-regression (160d5a2c2) carries an earlier, superseded attempt at this: a per-call SHUTDOWN_RELAYCAST_CALL_TIMEOUT of 3s. main's phase-scoped 2500ms bound is strictly better — a per-call timeout applied to N calls costs up to N times as much. That branch should be closed, not merged, but its investigation notes are worth reading.
  • The macOS-only pattern is a symptom of the threshold, not a macOS bug. Do not spend time looking for a platform difference.

Metadata

Metadata

Assignees

No one assigned

    Labels

    factoryFactory auto-dispatchfactory:in-progressFactory agents are working on this issue.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions