[RSI, performance] perf(kernel): defer the event-loop import stack past the ready event - #2379
Conversation
The Python kernel imported asyncio (plus the bash tool's secrets, shutil, datetime, selectors, struct, fcntl/termios, atexit, and tempfile) before sending the ready event. python -X importtime shows the asyncio subtree is the heaviest part of the boot chain (ssl, concurrent.futures, and logging ride along). Defer them: rlm.bash binds asyncio on first BashHandle construction (its only entry point), repl imports asyncio in main() after the ready event and in the handlers that reference it, and the remaining stdlib modules import at their first real use. No protocol, API, or behavior changes; the kernel serves requests and interrupts exactly as before. Local A/B (macOS, 16 interleaved trials, same harness phases as scripts/benchmarks): - kernel_start: 31.4 -> 24.4 ms (-22.3%) - kernel_rss: 28.6 -> 26.8 MB (-6.2%) - kernel_exec, bash, git_status, output, mixed, interrupt, snapshot, loaded_rss: unchanged within noise - restore: 90.1 -> 97.3 ms (+8%): the one-time loop import now lands in the first request of a fresh kernel instead of its boot; after one warm-up cell restore returns to parity (90.0 vs 89.4 ms baseline) Regression tests: import rlm must keep asyncio/secrets off the boot path; a serving kernel loads asyncio by the first cell; BashHandle construction binds asyncio with no event loop or prior bash() call.
Prime Agent performance — partialPR Benchmark execution did not complete successfully. Missing measurements are not performance wins. Failure diagnostics:
See the saved per-trial logs and terminal transcripts for details. Overall: 0 regressed · 25 improved · 15 no clear change · 1 unavailable.
Python runtime
Session transport
UI interactions
Sandbox cost: ~$0.1400 — no inference calls. Methodology and samplesMain resolved at 2026-09-17T21:06:23.069826+00:00. Harness
Failures:
|
…boot _sigint_handler has no task to target before serving starts, so the PR-head ordering (install before the ready send) silently swallowed a Ctrl-C during the deferred asyncio import and event-loop startup. Keep the default handler until the loop and _serve_task exist, then install the custom handler. Adds a regression test that parks the kernel inside the post-ready deferred import (fake asyncio on PYTHONPATH) and asserts a SIGINT there terminates the kernel; it fails on the pre-fix ordering and passes with the fix.
Net test additions drop 116 -> 22 against the 23 meaningful source lines the deferred event-loop import adds, so the test-policy gate passes at branch level. Cuts: merge the import-deferral and serving vectors; park the fake asyncio on a fifo read instead of polling for a marker with time.sleep (this also removes the wall-clock-sleep violation); trim the fresh-interpreter handle test; fold the no-string-key list_names vector into test_list_names; fold the int-reject vector into the stdout.buffer test; drop the redundant clean-shutdown, unknown-id host_reply and zero-size-cap tests. Per-cut ledger is in the PR body.
|
Second-pass review findings — holding:
|
main carries the boot-lean import change and the async-bash notice-race fix; this branch carries the recursive chmod/chown escape guard. Only the module-level import block conflicted, resolved the same way as the sibling guard branches: keep main's deferred layout, keep the guard's cheap module-scope needs (`re` for its compiled patterns, `Collection` and dataclasses `replace` from modules main already imports), and drop the guard's module-level `secrets`/`selectors`/`shutil`/`datetime`/`timezone` so #2379's deferral pin (`import rlm` loads no asyncio/secrets) still holds. Guard functions that need the deferred modules import them locally, matching the established pattern. Verified: full runtime suite 425 tests OK; deferral test ok.
Mechanism
python -X importtimeon a coldpython -m rlm.replboot shows the asyncio subtree (ssl, concurrent.futures, logging ride along) is the heaviest part of the kernel import chain, andrlm.bashdrags in several more stdlib modules (secrets/hmac/hashlib, shutil, datetime, selectors, struct/fcntl/termios, atexit, tempfile). All of it loaded before thereadyevent, so every kernel boot paid the full cost up front.This PR moves that work off the boot path, with no protocol, API, or behavior change:
rlm/bash.py: binds theasynciomodule global on firstBashHandleconstruction (its only entry point —bash()is the sole constructor), and importssecrets/shutil/datetime/selectors/struct+fcntl+termios/atexitat their first real use.subprocess,socket, and_winjobstay module-level (tests patch them viarlm.bashattributes).rlm/repl.py:main()sendsreadyfirst and imports asyncio afterwards, before creating the loop, reader thread, and serve task; handlers that referenceasyncio(_sigint_handler,_run_guarded,_handle_state,active_cell_task,_CellExecution) import it locally.tempfilemoves into_snapshot_state.rlm/__init__.pyneeded no change on this base.Local before/after (macOS arm64, 16 interleaved trials, harness phases replicated from
scripts/benchmarks/worker.py)IQRs on kernel_start were 0.39/0.50 ms, so the startup win is far outside noise.
Honest tradeoff: the first request of a fresh kernel absorbs the one-time loop import
Total kernel work is unchanged; the asyncio import moved from boot into the first request that needs it. For cell-serving kernels the warm-up cells already cover it. For the restore benchmark (fresh kernel, restore is the first request) the restore request pays it: measured 97.8 ms vs 90.0 ms after a single warm-up cell (baseline shows no such delta, 89.6 vs 89.4 ms). Expected table movement on Linux (where imports are slower than this macOS box): restore +7-9%, which stays under the 20% comparison threshold, while kernel_start should drop well past it.
Expected benchmark movement
Validation
uv run python -m unittest discover -s testinprime-agent-runtime: 324 tests OK (3 new:import rlmkeeps asyncio/secrets off the boot path; a serving kernel loads asyncio by the first cell;BashHandleconstruction binds asyncio with no event loop and no priorbash()call)npm run test:kernelinpackages/coding-agent: 15/15 OK (real host <-> kernel round trips)npm run check: OKRUNTIME_READY_CHECKstring still passes against this treerlm.bash.secrets/rlm.bash.shutilmodule attributes; they now patch the stdlib modules directly (same patch semantics, since the deferred imports resolve to the same module objects)Changelog fragment:
packages/coding-agent/.changes/kernel-defer-event-loop-imports.mdTest-line budget
Fleet test-budget audit (2026-09-18): this PR exceeded the repo test-line budget gate (
node scripts/check-test-policy.mjs, run by CI's Build and check job: net added test lines over changed test files may not exceed meaningful added source lines, and changed test files may not add per-category violations). The user directive driving the audit: "probably needs to remove redundant and unnecessary tests throughout. Remove unnecessary tests and we should have enough." The owner policy decision authorized executing the named-vector cut menu in cost order: "nah dont grandfather them in. we should fix them now".wall-clock-sleepviolation inprime-agent-runtime/test/test_repl.py(the SIGINT boot-window test polled a marker file withtime.sleep(0.01)). CI's Build and check job is failing on exactly this budget line (run 35262369844); this head turns it green.testAdded 55 / testDeleted 33). Measured at the pushed head against the merge base CI's checker uses (a7d791bc1be09793ed5f3ec05bf4cccbc60679ea), and re-confirmed with the merge-commit method againstmain(e2fb7bfa1): same numbers, same verdict.What the compression did, in cost order: the two deferral vectors merged into one test; the SIGINT boot-window test was rewritten around a fifo rendezvous instead of a sleep-based marker poll (which also removed the violation); the fresh-interpreter
BashHandletest was trimmed; and four small pre-existingtest_repl.pyvectors were folded into survivors or dropped where they were redundant. Every surviving test is honestly bounded: the SIGINT regression was re-verified by restoring the pre-fix handler ordering (install before the ready send), which makes the rewritten test fail exactly as it did before.Lost-coverage ledger (owner-authorized cuts, per vector)
Each row records what the deleted or folded lines pinned, why it was the cheapest option per line, and any surviving partial pin. Nothing disappears silently.
test_serving_kernel_loads_asyncio_after_readymerged intotest_import_rlm_defers_the_event_loop_stack(test_repl.py): pinned that a serving kernel has the event-loop stack resident by its first cell. Cheapest (two tests shared one subprocess/env fixture; the merge removed a def, a blank line and a duplicate comment). Surviving pins: both, unchanged asserts in the merged test.test_sigint_during_boot_window_terminates_kernelrewritten astest_sigint_during_the_deferred_boot_stays_fatal(40 -> 16 lines): pinned that a SIGINT delivered while the kernel is inside the post-readydeferred import is fatal (the default handler is still in charge). Cheapest per line and the only cut that also closed a gate violation: the fakeasyncionow blocks on a fifo read, and the test's blocking open of the fifo is the rendezvous, so the window is deterministic instead of polled. Dropped along with it: the reader thread/queue, thePopen+communicateplumbing, the_stop_boundedcleanup helper (ReplProcess.closealready kills and waits) and the"KeyboardInterrupt"text assertion. Surviving pins: nonzero exit code after SIGINT in that window (which the regression probe confirms fails on the pre-fix ordering), andreadybefore the deferred import. Coverage cost: the interrupt is asserted by exit status rather than by the traceback text.test_handle_construction_binds_asyncio_without_event_looptrimmed (test_bash.py, 21 -> 14 lines): pinned that a fresh interpreter with no event loop can construct and reap aBashHandlewithoutNameError. Cheapest (comments, a temporary variable and an explicitsys.exit(0)). Surviving pins: both assertions, unchanged.test_list_names_skips_non_string_keysfolded intotest_list_names(test_repl.py, 9 -> 1 line): pinned that non-stringglobals()keys are skipped bylist_names. Cheapest (same request/fixture; the survivor now setsglobals()[1] = 2and asserts1 not in names). Surviving pin: the merged assert. Coverage cost: the folded test's trailing "runtime still serves'alive'" re-check, which every later execute in the suite covers.test_stdout_buffer_write_rejects_intfolded into the buffer test (test_repl.py, 16 -> 13 lines): pinned thatsys.stdout.buffer.write(int)raisesTypeErrorin the cell. Cheapest (same tagged-writer fixture, two executes in one test). Surviving pin: theTypeError+ error-status asserts. Coverage cost: a redundantdone status okassert on the buffer path, which thestdoutevent already implies.test_shutdown_clean_exitdeleted (test_repl.py, 3 lines): pinned a cleanshutdownexit code. Cheapest (smallest test in the file). Surviving pins:test_shutdown_after_mcp_import_exits_cleanly,test_shutdown_with_pending_host_request_exitsandtest_stdin_eof_with_pending_host_request_exitsall assert exit code 0 on the same teardown path.test_host_reply_for_unknown_id_droppeddeleted (test_repl.py, 4 lines): pinned that ahost_replyfor an unknown id is dropped and the runtime keeps serving. Cheapest (4 lines). Surviving partial pins:test_malformed_request_line(unexpected protocol input produces an error event and the runtime still serves) andtest_host_request_cancelled_cell_drops_pending_future(host-reply bookkeeping for a cancelled cell).test_zero_size_cap_writes_no_empty_payload_overheaddeleted (test_repl.py, 3 lines): pinned that a zero aggregate cap errors before writing and leaves no files behind. Cheapest (3 lines). Surviving partial pins:test_complete_payload_respects_aggregate_size_cap(same cap error string) and_assert_only_pair_files()in the snapshot-pair tests (no stray temp files).Note
Medium Risk
Boot-order and SIGINT-handler timing change in the kernel entry path; behavior is intentionally preserved after serving starts but the deferred-boot window is a subtle lifecycle edge.
Overview
This PR speeds up kernel startup by keeping
asyncioand related heavy stdlib imports off the path until after the host sees thereadyevent, without changing the JSON protocol or shell/REPL behavior once serving.In
rlm.repl,main()now emitsreadyfirst, then importsasyncio, creates the loop, starts the reader thread, and launches the serve task. The custom SIGINT handler is registered only after the loop and serve task exist, so Ctrl-C during the short post-ready boot window still uses the default handler and can terminate the process instead of being swallowed.In
rlm.bash,asynciois no longer imported at module load; it is imported on firstBashHandleconstruction and bound as a module global. Other previously eager imports (secrets,shutil,selectors,fcntl/struct/termios,datetime,atexit) move to the functions that first need them soimport rlmstays lean before ready.Tests add coverage for deferred imports, lazy
BashHandle/asynciobinding, and SIGINT during deferred boot; existing tests patch stdlib modules directly whererlm.bashno longer re-exports them.Reviewed by Cursor Bugbot for commit dbffdfe. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Defer kernel event-loop import stack past the ready event in
rlm.replandrlm.bashasyncio,tempfile,selectors,secrets,shutil,fcntl,termios,struct,datetime, andatexitout of module initialization in bash.py and repl.py; each is now imported at first userlm.repl.mainbefore importingasyncioand creating the event loop, request reader thread, and serve tasksecrets.token_hexandshutil.whichat their stdlib module locations and adds subprocess tests verifyingrlmimport excludes the event-loop stack_sigint_handlerin repl.py is handled by the process default (fatal) rather than the custom handler, which is now installed only after the serve task existsMacroscope summarized dbffdfe.