Repository navigation
fix(code-execution): stop blocking code at the time limit - #240
Conversation
The deadline's cancel op is an asyncio cancellation, so it reached a script only at an await. Blocking code (time.sleep, a busy loop, a blocking read) ran past the limit: it either returned and read as a success, or was killed after the 10s grace with its output and the interpreter's variables lost. At the deadline the host now also sends the interpreter SIGINT (not on Windows, where Node's kill() ends the process). The runtime installs its own handler, which raises KeyboardInterrupt only while the running script's frames are on the stack and at most once per script; anywhere else (between scripts, a suspended script, the protocol loop, a protocol write) it does nothing. _run_script catches it, so the result keeps the output so far, shows the line the script was stopped at, and gets the time-limit note, and the interpreter and its globals survive. The reader thread blocks SIGINT so the signal reaches the main thread. The kill grace stays as the backstop, and its message now names the time limit and the lost variables. Refs #235 (F3), #203. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The SIGINT handler did nothing while the script was suspended on an await, leaving that case to the cancel op. But the script could resume (its timer fired, a tool result arrived) and enter blocking code before main() read the op: the deadline then went unenforced and the script returned 0. A SIGINT that finds the script waiting, or mid-way through a protocol write, now cancels the script task itself, as asyncio.run's own SIGINT handler does, and wakes select() so the cancellation runs at once; the task gets CancelledError at its resume point, before any of its code runs. In the script's own code it still raises KeyboardInterrupt. Either way the handler acts once per script, and the cancel op no longer cancels a second time. PyRunner takes cancelGraceMs (default 10s), so the kill backstop can be tested: a script that swallows the interrupt and keeps blocking is killed, and the next exec starts without the earlier variables. Refs #235 (F3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@greptileai please re-review at the current head (a4d9e59). |
… handler Cancelling the script task inside the signal handler could land between main()'s check of a future the script awaits directly (a wake ack, and a tool result on Python >= 3.12) and its set_result: the future was cancelled under it, set_result raised InvalidStateError out of main(), and the interpreter died. The handler now only schedules the cancellation with call_soon_threadsafe (which also wakes select()), bound to the script's task, so it runs at a loop-iteration boundary. The script stays armed until the cancellation runs; should it resume into blocking code first, the next SIGINT interrupts it there, so the host repeats the deadline SIGINT every 200ms until the script reports or is killed. The runtime ignores the repeats once the script was interrupted or cancelled, so a script that catches the cancellation can still finish. main()'s set_result sites also tolerate an already-done future. Refs #235 (F3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@greptileai please re-review at the current head (8a67faa). |
Three review rounds each found an interleaving between the SIGINT handler, the cancellation it queues, and the cancel op, all tracked by separate flags. The last: a reply let the script run ahead of the queued cancellation into blocking code, the next SIGINT raised KeyboardInterrupt there, and the queued cancellation then cut the script's cleanup short. Each script now has one _ScriptStop, PENDING -> REQUESTED -> DELIVERED, owned by the runtime. Whichever path delivers first (KeyboardInterrupt in the script's own code, or the task cancellation, which runs only in loop context) moves it to DELIVERED; the queued cancellation, repeated SIGINTs and the cancel op all do nothing after that, and nothing for a different or finished script. A stop requested before the script started is delivered as it starts, so it still reports (a cancel op that early used to leave it reporting nothing). main()'s dispatch moves into _dispatch() so a driver can exercise it. The table of where a signal or cancel op can land is in the runtime; a new test drives each row through the runtime's own functions. Refs #235 (F3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@greptileai please re-review at the current head (bef4db7). |
…tate The script task came from asyncio.ensure_future(), which uses the loop's task factory. Interpreter state persists, so a script can install one; an eager factory (asyncio.eager_task_factory, 3.12+) then ran the next script inside _dispatch(), before _stop and st.task were assigned, and _on_sigint() ignored the deadline during its first blocking call. _dispatch() now assigns the stop state first and constructs asyncio.Task directly, which no task factory affects. Row 13 of the stop table; a PyRunner test and a driver row cover it on 3.12+. Refs #235 (F3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@greptileai please re-review at the current head (1f3e783). |
slimepriestess
left a comment
There was a problem hiding this comment.
Validated two-panel on the scratch rig (both Weft panels; receipts preserved per run). One process note for transparency: our first rig pass produced a false "bun blocker" — the rig had swapped only dist/ while bun's export condition loads src/, so our bun runs were testing main. The second panel caught it from a handler check that made no sense; with src/ swapped too, the divergence vanished entirely. The before/after reds from main are kept in our receipts as the defect's before-picture.
Verdict: APPROVE
Verified first-hand
- The PR's tests pass on the repo's real harness (build + pretest +
node --testover dist): the blocking-code suite and the stop-table
test, 9/9 matched. Worktree at PR head 1f3e783. - The design: the 13-row landing-site table in runtime-py.ts is
executed row by row by the stop-rows driver — the analysis IS the test.
Single_ScriptStopstate (stopped exactly once, by whichever path is
first); the handler raises only with the script's own frame on the
stack,sendshort-circuiting so a mid-protocol signal only queues;
process.killso the child isn't marked killed and protocol state
survives; directasyncio.Taskconstruction dodging a script-installed
eager task factory; row 6 asserting a queued cancellation can't cut an
interrupted script's cleanup short. The module-level handler install
also (deliberately) keeps asyncio.run from installing its own
main-cancelling handler — confirmed live:getsignal(SIGINT)is the
runtime's handler immediately before the loop starts, under node AND
bun (opus's install-real probe on the PR's actual runtime source,
spawned exactly as PyRunner spawns it). - Direct runner probes, both runtimes (node 20.20.2, bun 1.3.14,
python 3.14.6, macOS): blockingtime.sleep(10)under a 2000ms limit
stops at ~2.05s on both; stdout before the limit kept; stderr carries
the script-located traceback (no runtime frames) + the #203-style
message; same interpreter afterwards; variables survive. Main's build
under the same probes runs the script to completion on both runtimes —
the before/after split is the fix, cleanly. - Full-host validation on the deploy runtime (our Campaign-A4
choreography: scripted mock, real host under bun, zero spend): 11/11
×3 consecutive. A4.2a — the spec case this PR exists for — green:
blocking script stopped at its limit with the documented message.
A4.6 re-pinned from the old severity (grace-kill at ~17.4s,
destructive) to the fixed contract: atime.sleep(30)with no
per-call limit now stops AT the 6s deployment default (~7.3s
wall-clock including report), output kept, no kill, interpreter
intact. Refusals, cooperative exactness, above-ceiling capping,
no-leak-between-calls, and the composed inner-call case all hold
unchanged. The old A4.2a/A4.6 reds ×3 from main are preserved in the
receipts as the defect's before-picture.
Non-blocking note for the queue
With the exports map's "bun": "./src/index.ts" condition, bun consumers
execute src/ while CI tests dist/ under node. Same code today — but a bun
leg for the new blocking-code suite (or a src/dist agreement check) would
pin the deploy runtime permanently and catch any future drift of this
class. (This note is about the package's test matrix, not about #240,
which behaves identically on both runtimes.)
Small appreciations
- The kill-path message now naming the limit and the lost variables turns
the old opaque line into something an agent can act on. cancelGraceMsas an option is the right seam for tests.- Row 8's cancel-op-before-start case ("used to leave it reporting
nothing") quietly fixes a bug nobody had filed.
…onto current main GitHub reported the anima-research#247 → anima-research#252 → anima-research#256 → anima-research#257 stack DIRTY against main 02adf27 (June-1784 and Basil-1786, room-203 #51425): the conflict sits at the bottom, in anima-research#247. Integrated once here, by merge so every reviewed commit keeps its hash, and propagated upward the same way (Tessa-974 #51438; Basil-1007 reviews the resolutions, #51449). main since 64c480b brings anima-research#236 (reject visibly-empty push and channel content), anima-research#238 (zero-match pattern and feature-set diagnostics), anima-research#240 and anima-research#242 (code-execution deadlines and inner results), anima-research#241 (silent heartbeat turn rows) and anima-research#237 (lifecycle tool calls from code_execution scripts). Two textual conflicts, both additive: - src/mcpl/push-handler.ts imports: main's EmptyContentError and visible-content helpers beside anima-research#247's InboundSource type. - src/framework.ts: main's new silentTurnRowMetadata() sits before recordProseSuppression(); that method keeps anima-research#247's doc comment ("suppressed without drafts (proseRouting=disabled)"), which is what its body does since anima-research#247 holds explicit-send-suppressed prose as drafts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221) into fix/workspace-truthful-listings Brings the branch up to main 956c6d2. GitHub reported the PR as conflicting after anima-research#169's merge (6c07b6a), so CI didn't run on 2ae3554; git's merge is clean, with no file conflicts.
Brings in main through c2f9bae: anima-research#236, anima-research#241, anima-research#238, anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221, anima-research#248, anima-research#308, anima-research#263 and anima-research#312. One conflict, in src/framework.ts's four MCPL handlers (push/event, inference/request, channels/incoming and host/command). anima-research#263 wraps each handler in answerOnce and refuseServerItem, and refuses the item after the barrier wait once the queue has closed. This branch replaced each handler's single-barrier wait with awaitDiscordAwarenessBarriers(), called only while a generation is current. Resolved by keeping anima-research#263's structure and putting this branch's wait where it waited on the barrier: if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers(); So the queue-closed check runs once every generation armed while the handler waits has been waited out, and with no gate up an event still reaches its handler in the turn it arrives. Everything else merged without conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
A
code_executiontime limit did not stop blocking code (#235, finding F3). At the deadline the host sends{op:"cancel"}, and the runtime cancels the script's asyncio task, but the cancellation only lands when the event loop gets control back. A script stuck intime.sleep(), a busy loop or a blocking socket read never yields, so it kept running:This applied to every call, including those on the default limit, not only calls that pass
time_limit_ms.What this does
Host (
py-runner.ts): at the deadline it sends the cancel op and then SIGINT to the interpreter. It repeats the SIGINT every 200ms until the script reports or is killed. It usesprocess.kill(pid), notchild.kill(), becausechild.kill()marks the childkilled: the runner then stops writing to it and respawns it on the next exec. Windows is skipped, because there Node'skill()terminates the process; it keeps the old behavior.Runtime (
runtime-py.ts): beforeasyncio.runit installs its own SIGINT handler. Because the handler isn't the default one, asyncio doesn't install its own, which would cancelmain()and end the interpreter._ScriptStop, owned by the runtime, that movesPENDING→REQUESTED→DELIVERED. Whichever path delivers first moves it toDELIVERED: aKeyboardInterruptraised in the script's own code, or the cancellation of its task. After that, the queued cancellation, repeated SIGINTs and the cancel op all do nothing, and they never touch a different or finished script. The script's own cleanup (except/finally, and awaits inside them) runs to completion or to the kill; we never stop a script twice._run_script's frame, reached before anysendframe), the handler raisesKeyboardInterruptthere. Anywhere else, it only queues the cancellation withcall_soon_threadsafe, which also wakesselect(). It never cancels from inside the handler, where a cancellation could land betweenmain()'sdone()check andset_result. As defense in depth, thoseset_resultsites tolerate a future that is already done.main()'s dispatch moved into_dispatch(), so a test driver can run it._dispatch()builds the script task withasyncio.Task(...), so a task factory a script installed can't start the next script early.Where a signal or the cancel op can land, and what happens. This table is also in the code, and the new test covers each row:
KeyboardInterrupttheremain()mid-dispatch, between thedone()check of a future the script awaits andset_result(reply behind the cancellation)set_resultcompletes; the script getsCancelledErrorinstead of the replyKeyboardInterrupt; the queued cancellation then does nothing, so cleanup that awaits is not cut shortCancelledErrorwhen the script startsCancelledErrorrunsThe earlier versions used separate flags, and each review round found an interleaving that slipped between them: the first round a waiting script that resumed into blocking code (rows 5 and 6), the second row 3, the third row 6. On the head before the restructure, rows 6, 8 and 10 fail. Row 13 (fourth review round) failed on the head before it was fixed: the next script ran its blocking call to completion.
It checks the stack rather than
asyncio.current_task(). The task also counts as current while asyncio runs its own code between steps (call_soonafter a bare yield), and aKeyboardInterruptraised there escapes the event loop. With a current-task check, a stress run tore atool_callline and lost anexec_result.Interrupted result: the script's stdout so far is kept. stderr shows where the script was stopped (
File "<script>", line N, with the runtime's own frames left out) followed byKeyboardInterrupt: script interrupted by host, and the host appends the feat(code-execution): per-call time limit (time_limit_ms) #203 note. The interpreter and its globals survive.Reader thread: it starts with SIGINT blocked, so the signal reaches the main thread, where the blocking call is.
Kill backstop: the 10s grace and SIGKILL stay. They still apply to code that never sees the interrupt: a long call inside C code such as
sum(range(10**11)), code that swallowsKeyboardInterrupt, and blocking code in a task the script started (the interrupt is raised only in the script's own task). The kill message now names the time limit and says that earlier variables are gone.PyRunnertakescancelGraceMs(default 10s), so the backstop can be tested quickly.asyncio.wait_forcan swallow a cancellation when the awaited result arrives at the same moment (a CPython bug, fixed in 3.12). A script waiting on a tool call at the deadline can then lose its cancellation, from the SIGINT or the cancel op, and run on until the kill. The stress run below shows the same hang on unmodifiedmainunder 3.9 without any SIGINT, so it isn't caused by this change.Changelog fragment:
changelog.d/fix-code-exec-blocking-deadline.fixed.md.Alternatives that were probed and rejected:
_thread.interrupt_main(): it raises only after the blocking call returns.asyncio.run's handler cancelsmain()and the interpreter exits.Tests
New:
test/code-execution.test.tshas a new describe block, "code_execution time limit vs blocking code", with 9 tests (skipped on win32):time.sleep, a busy loop and a blockingrecveach stop at a 1s limit. Each result is notaborted, keeps its stdout, includes the feat(code-execution): per-call time limit (time_limit_ms) #203 note and the script line, and arrives well inside the kill grace. All three run in the same interpreter (same PID).await asyncio.sleep(1), thentime.sleep(5), under a 1s limit, is stopped every time.test/helpers/pytc-stop-rows.tsis a Python driver that loads the runtime as a module and drives its own_dispatchand SIGINT handler. It lands the signal or the cancel op at each row's exact point: inside the write of atool_callorexec_resultline, between adone()check andset_result, before the first step, and with a real signal during blocking code. Every row of the table is asserted. The driver's results were stable over 10 runs on 3.9 and 3.14.asyncio.eager_task_factory. The next exec'stime.sleep(30)is still interrupted at its 1s limit, not killed.KeyboardInterruptand keeps blocking is killed after a 500ms grace. The result isabortedand carries the time-limit and lost-variables message. The next exec gets aNameErrorfor a variable set earlier.On unmodified
main, every test except the await-cancel one fails.Python versions: the
code_executionfile passes on Python 3.9, 3.11, 3.12, 3.13 and 3.14.Stress: a protocol-level harness ran about 2,000 execs across 3.9 to 3.14, on both the first and the final version. Each exec was stopped at a random moment by cancel plus SIGINT, with extra stray SIGINTs. In the final run, 40% of execs got the SIGINT alone, with no cancel op, and the harness repeated the SIGINT the way the host does. Script shapes were bare-yield loops, tool-call loops with large payloads,
gather, sleep, busy and print loops. Results: no interpreter deaths, no torn protocol lines, and globals intact throughout.Full suite:
npm run typecheckandnpm run buildare clean. On the final commit,npm testreported 1229 tests: 1221 passed, 3 failed, 5 skipped. All three failures aremcpl-awareness-barrierstartup-gating tests that hit an MCPL timeout under full-suite load. That file doesn't usecode_execution, and it passes 25/25 when run alone. It's the known timing flake that also shows up onmain.Refs #235 (F3), #203.
🤖 Generated with Claude Code