fix(flows): kill shell children on interrupt and node timeout - #530
fix(flows): kill shell children on interrupt and node timeout#530SebTardif wants to merge 2 commits into
Conversation
runShellAction only sent SIGTERM/SIGKILL on its own timeoutMs. FlowRunner.run persisted an interrupted bundle and rejected without aborting the in-flight spawn. An outer node timeout (or execution.timeoutMs: 0) left the child running, so the CLI process.exit orphaned it. Pass an AbortSignal into runShellAction. Abort and the inner timer share the same SIGTERM then SIGKILL grace. Abort on SIGINT/SIGTERM/SIGHUP and on TimeoutError. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
|
🦞👀 Pull request received. I will update this pull request when review starts. |
|
Codex review: needs changes before merge. Reviewed August 29, 2026, 9:27 PM ET / August 30, 2026, 01:27 UTC. ClawSweeper reviewWhat this changesAdds abort propagation from flow interrupts and node timeouts to shell actions, with direct-child termination tests. Merge readiness⛔ Blocked by patch quality or review findings - 8 items remain Keep open: direct-child cancellation is proven, but the two previously reported P1 teardown gaps remain, so SIGTERM-resistant commands and descendants of shell wrappers can still survive an interrupted CLI. Priority: P1 Review scores
Verification
How this fits togetherFlows execute user-authored graphs containing runtime-owned shell actions and persist run state as they progress. Interrupts and node timers must stop the command being run before the CLI reports the failed run and exits. flowchart TD
A[Flow definition] --> B[Flow runner]
B --> C[Shell action executor]
D[Interrupt or node timeout] --> E[Cancellation signal]
E --> C
C --> F[Spawned command tree]
F --> G[Run result and stored bundle]
Before merge
Findings
Agent review detailsSecurityNone. Review metrics
Root-cause clusterRelationship: Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Use a tracked, awaited platform-aware process-group/tree teardown for shell actions and prove it through CLI interruption before merging. Do we have a high-confidence way to reproduce the issue? Yes — the supplied compiled-public-API before/after terminal traces reproduce the direct-child timeout and interrupt leak against the PR base, and the source confirms the relevant cancellation path. Is this the best way to solve the issue? No — abort propagation fixes the direct-child case, but wrapper-only signalling and an unref'ed escalation timer do not guarantee cleanup when the CLI exits. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against ef6b81e71756. LabelsLabel justifications:
EvidenceAcceptance criteria:
What I checked:
Likely related people:
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (1 earlier review cycle)
|
Persist the interrupted run before aborting the in-flight shell so currentNode stays on the active node. Skip later step snapshots once the run is already failed or timed out. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
|
Maintainer triage: CLOSE recommended for the current implementation. Independent built-CLI proof confirms that interruption still leaks both resistant direct children and shell descendants. The proof launched a real flow through this PR's Environment: macOS, Node 26.8.1, isolated synthetic HOME/cwd/TMPDIR, no provider or network. The fixture's final cleanup killed and verified absence of every known test process. The root cause is the remaining ownership gap: the interrupt path aborts but does not await command teardown before A complete replacement needs one runtime-owned, awaited command-tree cleanup boundary shared by timeout and interruption. It must wait only for owned process teardown, not for arbitrary user |
|
Thanks @SebTardif for working on this. The interruption/timeout cleanup problem is real, but I’m closing this implementation because the built-CLI proof still leaves both a SIGTERM-resistant direct child and a shell-wrapper descendant alive 1.6 seconds after the CLI exits with code 130. Cancellation needs a shared, awaited process-tree cleanup boundary: finish teardown and escalation for owned processes before CLI exit, without waiting indefinitely for user callbacks. Killing only the wrapper PID and scheduling an unref’ed escalation timer does not provide that guarantee. A corrected design with interruption and timeout proof for resistant children and descendants is very welcome. |
What Problem This Solves
Fixes an issue where users running
acpx flow/FlowRunner.runwith ashell()node would leave the spawned command running after Ctrl+C or after an outer node timeout.runShellActiononly sent SIGTERM/SIGKILL on its owntimeoutMs.FlowRunner.runpersisted the interrupted bundle and rejected; the CLI thenprocess.exited and orphaned the child. The same orphan happens when the node timer fires (timeoutMs: 200) while the action setsexecution.timeoutMs: 0, so the inner kill timer never starts.Why This Change Was Made
runShellActionnow accepts anAbortSignal. Abort and the existing inner timeout share the same SIGTERM then SIGKILL grace (1s).FlowRunner.runaborts that signal on SIGINT/SIGTERM/SIGHUP.executeActionNodealso aborts it fromrunWithHeartbeat'sonTimeout, matching how ACP nodes cancel a session on node timeout.This is not stdin EPIPE handling (open #529). It is not output bounding. It does not change successful shell completion.
The missing abort dates to #179 (
697ee1f, 2026-03-26, "feat: add experimental acpx flows runtime and examples"), 156 days onmain. #188 persisted interrupted runs and still did not reap the spawn.User Impact
A flow
shell()child is reaped when the operator interrupts the run or when the node timeout fires, including when the action setstimeoutMs: 0. The host still reportsTimeoutError/InterruptedError. Operators no longer leak long-runningsleep/ compiler /yesprocesses after the CLI has exited.Evidence
terminal output from live
nodeagainst compiled publicdefineFlow/shell()/FlowRunner(dist/flows.js).Same scripts: one-node flow whose
execreturnscommand: process.execPath,argsthat writeprocess.pidthensetInterval, andtimeoutMs: 0. Outer node timeout is 250ms. Interrupt script starts the same flow in a child host, then sends SIGINT.Before, on unpatched
src/flows/executors/shell.tsandsrc/flows/runtime.tscompiled todist, the host rejects but the child stays alive:After, on the patched compiled public API, the same flows reject and the child is gone:
Real behavior proof
Behavior or issue addressed: Flow
shell()children stayed running after Ctrl+C and after an outer node timeout whenexecution.timeoutMswas 0, soprocess.exitorphaned the spawn.Real environment tested: macOS, Node v26.7.0, acpx compiled from this branch at
/tmp/oc-pr-acpx-F004(dist/flows.js).Exact steps or command run after this patch:
The timeout script imports
defineFlow,shell, andFlowRunnerfrom compileddist/flows.js, then runs a one-node flow with nodetimeoutMs: 250and actiontimeoutMs: 0. The interrupt host runs the same long child; the parent sends SIGINT after the pid file appears.Evidence after fix: terminal output from the patched compiled public API (shown above). Timeout path prints
childAliveAfterTimeout: false. Interrupt path printschildAliveAfterHostExit: falseand host exit 130.Observed result after fix: The same pid-writing
setIntervalchild that stayed alive after TimeoutError and after SIGINT on unpatcheddistis now gone once the host rejects. The host still surfacesTimeoutError/InterruptedError.What was not tested: Windows cmd.exe spawn, and a child that ignores SIGTERM for the full 1s grace before SIGKILL when the CLI
process.exits immediately.Related: #179 (origin), #188 (interrupt persist only), #529 (stdin EPIPE, different file), #501, #498. Same leftover-child class as merged process-list timeouts in #501.