An Aborted Tool Call Can Still Have a Running Child
A local REA subprocess replay separates cancellation, child exit, workspace cleanup, and the evidence an agent can safely retain.
An abort error is not evidence that a subprocess has stopped writing files. I tested a child that handles SIGTERM, waits 200 ms, writes a cleanup marker, and exits. The ordinary abort path returned before the marker existed. REA's stoppable path returned after it existed. Both calls failed with ABORT_ERR.
That difference matters for agent tools that unpack archives, inspect binaries, or write temporary analysis output. Removing a workspace as soon as the promise rejects can race with the process still using it. The error tells the caller that its request was interrupted. Resource cleanup needs a separate contract.
I examined REA 6.4.0, revision 428eee00fdd0406cdf2b734c9ea14a2bb02da279, and executed its unchanged subprocess helper on Linux with Node 24.19.0. This was a source-module test with real local child processes, not a run of the complete MCP server or a decompiler. The helper imports only Node's child-process API, so this narrow test required no dependency installation.
Two Cancellation Contracts in One Helper
execFileBytes() has two modes. Without stopSignal, it passes the caller's signal and timeout into Node's execFile(). With stopSignal, it manages those controls itself. The wrapper sends the requested signal to the child, starts a five-second grace timer, and escalates to SIGKILL if the execution callback has not cleared that timer.
The stoppable mode records the first interruption reason. An abort remains an abort even if the child handles the signal and exits with code zero. A timeout remains a timeout. The execution callback clears the deadline and grace timers, removes the abort listener, and rejects with the recorded reason instead of accepting the clean exit as success.
This is useful behavior for an agent. The provider may have completed its shutdown correctly while failing to complete the requested analysis. Conflating those two outcomes would turn an interrupted operation into a successful tool result.
The source also checks timeout values before spawning. It accepts nonnegative safe integers; zero disables the explicit timeout. Negative, fractional, nonfinite, and unsafe integer values are refused with ERR_OUT_OF_RANGE. That check matters because the stoppable mode does not delegate its timeout validation to execFile().
Four Real Child Processes
The replay probe verifies the helper's source bytes against blob 08046d83d3f46cd3907463c9d5cd824eafad8992 before importing it. It accepts either a pinned checkout or that exact TypeScript file. Node loads the module with its built-in TypeScript support; the probe does not copy or rewrite the implementation.
For each case, the child installs its signal handler before writing a readiness file. The parent waits for that file before sending an abort. A separate completion file records the child's delayed cleanup. For timeout cases, the wrapper receives a 2,000 ms timeout. These are deliberately small fixtures, not measurements of analysis latency.
I ran the probe against the pinned source file:
node public/blog/abort-before-process-exit/process-probe.mjs /tmp/rea-field-note/ExecFileOutput.ts
The process exited with code zero. Its lifecycle results were:
{"mode":"plain-abort","code":"ABORT_ERR","signal":null,"markerAtRejection":false}
{"mode":"stoppable-abort","code":"ABORT_ERR","signal":null,"markerAtRejection":true}
{"mode":"stoppable-timeout","code":"ETIMEDOUT","signal":null,"markerAtRejection":true}
{"mode":"ignore-stop","code":"ETIMEDOUT","signal":"SIGKILL","markerAtRejection":false}
| Case | Child behavior | Result when the wrapper rejected |
|---|---|---|
| Ordinary abort | Delayed cleanup after SIGTERM | Cleanup marker absent; it appeared later |
| Stoppable abort | Same delayed cleanup | Marker already present; request still cancelled |
| Stoppable timeout | Handles stop signal and exits zero | Marker present; request still timed out |
| Ignored stop signal | Keeps running after SIGTERM | Escalated to SIGKILL; no cleanup marker |
The ordinary-abort fixture waits for the marker before removing its directory. It does not reproduce the failure by deleting a live child's files. The marker establishes ordering; the child's configured 200 ms delay is not a measured wrapper overhead.
Ten additional assertions exercised five invalid timeout values in both modes, using a nonexistent command. Each returned ERR_OUT_OF_RANGE, rather than reaching a spawn failure. The final output was:
{"invalidTimeoutChecks":10,"result":"passed"}
PASS: four real child-process cases and ten timeout validation checks
To replay from a checkout:
git clone https://github.com/morluto/rea rea
git -C rea checkout 428eee00fdd0406cdf2b734c9ea14a2bb02da279
node process-probe.mjs ./rea
This fixture requires POSIX signals. It refuses to run on Windows. It is an independent probe inspired by the project's existing subprocess tests, not a claim that I ran its complete test suite.
Process Completion and Output Are Different Evidence
There was an important environment limitation. Even a control child that wrote control-output and exited normally returned an empty captured stream in this cloud runtime:
{"controlStdoutBytes":0,"expectedControlBytes":14}
A direct execFile() control and a separate spawn() control also produced no captured stdout. I therefore used filesystem markers for the lifecycle assertions and made no claim about retained shutdown logs. The wrapper's source attaches captured stdout and stderr to its error, but this environment did not validate that capture behavior. The missing stream is not evidence of a REA output defect.
The source's default maxBuffer is also Infinity. Callers can supply a bound, but stoppable mode alone does not bound memory consumed by output. A process-lifetime contract and an output-budget contract need separate configuration.
Ownership Extends Beyond This Wrapper
REA has more than a tool catalog. Its source separates application services, domain errors and contracts, provider adapters, process supervision, and CLI/MCP entry points. The MCP transport handles optional-provider load failures and startup cleanup. Its Apktool provider uses the richer owned-command supervisor, rather than this helper, for resource decoding.
That provider retains a cleanup owner when stopping a command fails. Its workspace-removal path checks whether a pending command owner still references the workspace. If so, it retains the directory and reports the cleanup failure. On close, it drains active operations, retries stopping those owners, and removes workspaces only after that step succeeds. These are code-inspection findings; I did not run Apktool or validate that shutdown path end to end.
The small helper tested here sends signals to one child handle. It does not by itself prove that every descendant has stopped, that inherited pipes have closed, or that a whole analysis workspace can safely be deleted. The project's separate ownership and supervision code addresses a larger boundary, which needs its own integration evidence.
The verified result is narrower and useful: with this source module and a cooperative direct child, stoppable mode delays rejection until the cleanup marker has been written. With an uncooperative child, it escalates and preserves the timeout as the failure. An agent harness should retain those separate facts: the request failed, the direct child stopped, and workspace ownership still needs to be accounted for.