Skip to content

Part of #3736: T2-F2 daemon run on the native runtime - #4003

Merged
ScriptedAlchemy merged 15 commits into
masterfrom
refactor/3736-t2-f2-daemon-run-flip
Oct 15, 2026
Merged

ScriptedAlchemy merged 15 commits into
masterfrom
refactor/3736-t2-f2-daemon-run-flip

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Refs #3736

T2-F2 of the native runtime cutover: tracedecay daemon run now drives its root future and every client connection on the Asupersync runtime. The daemon still owns one multi-thread Tokio runtime. The Tokio sites that remain reach it only through an explicit native_runtime::tokio_handle(site) handle, never through an ambient Handle::current() from a native thread.

What changed

  • tracedecay-rusqlite-runtime native_runtime:
    • install_tokio_runtime(handle) puts the Tokio runtime in a clearable slot, and uninstall_tokio_runtime() empties it. tokio_handle(site) returns the runtime entered on the calling thread, else the installed one, and counts a miss as before.
    • on_tokio(site, work) returns None on a counted miss, else the work wrapped in InTokioContext. The wrapper polls and drops the work with the Tokio runtime entered, in the caller's own task, so Tokio timers and spawns inside it work from a native thread and dropping the caller drops the work at once. in_tokio_context(handle, work) does the same with an explicit handle.
    • NativeTask implements Future. NativeTaskSet<T> joins native tasks in completion order and aborts the rest on drop, as JoinSet does.
  • tracedecay-cli main.rs: every command runs native.block_on(run(cli)). For daemon run (CommandRuntime::NativeWithTokio), main builds the Tokio runtime with the same settings as before and installs it. After run returns, which is after the shutdown coordinator has logged every owner's receipt, it uninstalls the handle and calls shutdown_background(), so a late tokio_handle call is a counted miss rather than a handle to a dead runtime.
  • daemon/bootstrap.rs:
    • ClientTasks, an alias of NativeTaskSet, replaces the three client JoinSets. A refused spawn is logged as daemon_client outcome=runtime_unavailable.
    • The Windows loopback accept loop now logs a failed client as daemon_client outcome=error error=<message>, as the Unix loop does. It used to log daemon_client outcome=<message>.
    • Both process_runtime() calls are deleted.
    • At exit the daemon logs an event=daemon_runtime_hazards receipt with tokio_runtime_missing_total() and native_thread_writer_waits().
  • daemon/project_server_lifecycle_hops.rs (new): three project_server_lifecycle entry points that native code awaits run under on_tokio. They are cancel_retained_session_history (bootstrap root), schedule_user_profile_host_admission_replay_for_identity (connection handshake) and await_user_profile_host_admission_replay_for_identity (retained profile reads). That file still polls Tokio timers, and this PR does not read or edit it. The hops run in the caller's task, so the connection admission task-local stays in scope and a dropped client releases everything the hop holds before the clients owner reports. A miss on the last hop is a non-retryable tokio_runtime_unavailable. The file's other entry points are awaited only inside Tokio tasks: project open, retirements, and the shutdown runner.
  • hook_v2_replay_consumer::register_hook_v2_replay_consumer resolves the Tokio handle before it touches the replay registry, and returns ReplayConsumerStart (Started, AlreadyRunning, NoTokioRuntime, RegistryPoisoned) instead of a bool. A miss used to abort the data root's running consumer first.
  • dashboard-api serve_loopback takes the Tokio handle as a parameter. Its inner lookup returned silently on a miss.
  • scripts/check-ambient-tokio.py (new) fails the Repository gates job when production code calls tokio::spawn, tokio::task::spawn, tokio::task::spawn_blocking, tokio::runtime::Handle::current, JoinSet::spawn or spawn_blocking, or a tokio::time free function (sleep, sleep_until, timeout, timeout_at, interval, interval_at). It reads crates/*/src, resolves each file's use tokio::... imports (aliases, groups, globs), and skips test files, #[cfg(test)] and test-feature items and the files they load. An exception needs an // ambient-tokio: <reason> comment at the call, or above a mod x; line to cover that file. The one exception is the project_server_lifecycle declaration, for its 3 Tokio timeouts that run on Tokio tasks or inside the on_tokio hops. //scripts:ambient_tokio_check_test runs its self-test. On origin/master the check reports 22 ambient calls, the same sites this PR moves. It replaces the earlier clippy.toml entries, whose 258 test-module allows also switched off the test: scoped tracing subscribers miss callsites cached by other tests #3829 tracing set_default guard there.

Remaining Tokio sites

site reason it stays on Tokio behavior on a miss
project_retirement.rs spawn_and_track, spawn_fallible_retirement; remote_recovery_lifecycle.rs (track_tokio_retirement) calls project_server_lifecycle failed retirement receipt
project_open_admission.rs project-open task calls project_server_lifecycle ProjectOpenTaskClaim::Failed
invocation_executor.rs in-process invocation (outer and inner spawn) work attempts reach spawn_for_worktree DaemonInvocationError::Unavailable
http_application.rs loopback listener, dashboard-api serve_loopback connections /remote-deletions calls project_server_lifecycle listener refused before bind
mcp/tools/handlers/dashboard.rs hosted dashboard serves the same routes Config error before bind
hook_v2_replay_consumer.rs consumer, spool_opener.rs opener not traced Tokio-free (the opener opens projects) NoTokioRuntime, registry unchanged; opener not started
mcp/server/hook_dispatch.rs ×2, dashboard-api state.rs scheduler reconcile not traced Tokio-free; best effort reconcile skipped
tracedecay-mcp lifecycle.rs task owner, settlement.rs dispatch set, project_host_admission_replay.rs dispatch work not traced Tokio-free task refused; tool_dispatch_runtime_unavailable; no replay worker
daemon-service tokio_handle sites (7, unchanged) as before unchanged typed refusals
the three project_server_lifecycle_hops Tokio timers in that file retention kept; None; non-retryable tokio_runtime_unavailable

scripts/check-ambient-tokio.py finds no ambient Tokio call in production code.

Tests

  • mcp_suite native_root_daemon_test::daemon_on_the_native_root_serves_tool_mcp_and_dashboard_then_stops_clean (new) runs the real binary. It covers an MCP session (initialize and a readiness read), a CLI tool call, the hosted dashboard started through MCP, and an authenticated /api/capabilities request. It then sends SIGTERM and asserts a clean exit, native_thread_writer_waits=0 on the hazards receipt, a completed store close, and no unfinished owner, client drain timeout, incomplete project-server shutdown, missing reactor or panic in the log.
  • daemon/tests/lifecycle.rs a_client_dropped_mid_tokio_hop_releases_the_hop_before_the_drain_receipt (new): a client is dropped while its hop parks on a Tokio timer and the Tokio worker is busy. The hop's resource and the client's admission slot must be released when the clients owner reports.
  • native_runtime tests (new): a_native_task_uses_tokio_timers_and_spawns_in_tokio_context, dropping_a_tokio_hop_drops_its_work_at_once, dropping_a_native_task_set_aborts_its_running_tasks.
  • tokio_runtime_slot (new test binary, because the slot is process-global): a native_runtime::block_on root with no Tokio entered misses, reaches the installed runtime, and misses again after the uninstall.
  • hook_v2_replay_consumer a_registration_without_tokio_leaves_the_running_consumer_in_place and dashboard-api loopback_server_serves_from_a_native_task_with_an_explicit_handle (new).
  • The daemon/tests/lifecycle.rs client shutdown and panicking-connection tests drive ClientTasks.

Mutation checks (each reverted afterwards):

settlement.rs spawn_on -> ambient JoinSet::spawn:
  project readiness read failed: {"error":{"code":-32603,"message":"request handler panicked: there is no reactor running, must be called from the context of a Tokio 1.x runtime"},...}
main.rs without install_tokio_runtime:
  panicked at crates/tracedecay/tests/mcp_suite/../common/mod.rs:1294:17 (daemon exited before accepting connections)
hop spawned on Tokio and aborted on drop (the previous design), Tokio worker busy:
  a_client_dropped_mid_tokio_hop_releases_the_hop_before_the_drain_receipt: the hop's work outlived the clients owner's receipt
NativeTaskSet without its Drop abort:
  dropping_a_native_task_set_aborts_its_running_tasks: left: Err(Timeout) right: Err(Disconnected)
registry mutated before the Tokio lookup (the previous order):
  a_registration_without_tokio_leaves_the_running_consumer_in_place: the owner must keep the data root's running replay consumer
serve_loopback with its inner tokio_handle lookup:
  loopback_server_serves_from_a_native_task_with_an_explicit_handle: connect loopback server: ConnectionRefused
uninstall_tokio_runtime that keeps the handle:
  a_native_root_reaches_the_installed_tokio_runtime_until_it_is_uninstalled: a caller after the uninstall must miss, not reach a dead runtime
one ambient `drop(tokio::spawn(async {}));` added to daemon/bootstrap.rs `log_runtime_hazards`:
  crates/tracedecay/src/daemon/bootstrap.rs:488: ambient `tokio::spawn`
  1 ambient Tokio call(s) in production code. ... (exit 1)

Gates

At b14a8a2acb (origin/master 503059bedd merged, with #4011, #4013, #4014, #4017 and #4018):

  • bazel build --config=clippy --keep_going //crates/...: Build completed successfully. //:rustfmt_check and //scripts:bazel/gen_builds.py -- --check: completed successfully.
  • python3 scripts/check-ambient-tokio.py: checked 2489 production source files; no ambient Tokio call. //scripts:ambient_tokio_check_test PASSED.
  • //crates/tracedecay:unit_test, :daemon_suite, :mcp_suite, :session_suite, :transport_acceptance_suite, :runtime_acceptance_suite, :dashboard_api_test and :hooks_lsp_suite PASSED.
  • //crates/tracedecay-cli:core_cli_suite and :host_journeys_suite PASSED.
  • //crates/tracedecay-rusqlite-runtime:unit_test, :tokio_runtime_slot, //crates/tracedecay-mcp:unit_test and //crates/tracedecay-dashboard-api:unit_test PASSED.

Latency diagnosis

The previous ABBA (8 runs each at host load 20 to 292) put the flip's short-read p50s 13 to 15 % behind base. The measurements below do not depend on host load, and none of them shows a per-request cost on the flip. The gap came from host load, not from the flip.

Probe: one private daemon over the small corpus. 6 client workers run a fixed mix (3 × callers, grep and search, then 1 context), one CLI process per call. Release builds: base 42489e6324 against flip ed907e0cc6.

  • CPU per request. perf stat -p <daemon> over 1200 calls, 4 runs each, net of the daemon's idle rate measured just before, with the operator's real HOME as the perf gate uses it. Base used 103 to 109 M user and 22 to 24 M kernel instructions per call. The flip used 103 to 111 M user and 21 to 24 M kernel. That is the same work, so the on_tokio hops, the per-connection NativeTask and the NativeTaskSet add no measurable CPU.
  • Thread handoffs per request. bpftrace on sched:sched_waking counted wakeups made by daemon threads, over 600 calls with an isolated HOME. Base made 326 to 332 per call, about 84 of them with a Tokio worker on one side. The flip made 260 to 318 per call and none on Tokio: this request mix never leaves the native runtime. Handoffs are where host load turns into latency, and the flip has no more of them.
  • Paired latency. Base and flip daemons ran side by side and every worker sent each request to both, in alternating order, so both saw the same host load (270 pairs per tool, load 68 to 196). A base-against-base control gave median ratios of 0.95 to 1.00. Flip over base was 0.89 callers, 0.93 grep, 0.95 search and 0.98 context. With the order of the two daemons swapped it was 1.00, 0.99, 1.01 and 1.02.

The perf gate does not isolate HOME, so its private daemon ingests the operator's real ~/.claude and ~/.hermes sessions while it measures. That kept the daemon 0.7 to 1.4 cores busy between requests, in some runs for more than 6 minutes after init. This PR does not change that. It is a source of noise between runs.

Peak RSS and latency (ABBA)

Release builds (--config=release): base 503059bedd (the master merged here) against this branch at b14a8a2acb. Harness and knobs as before: scripts/perf-gate.sh, small corpus, 6 workers for 60 s, private daemon. Four rounds of A B B A, 8 runs per binary, all PASS. Each run started only once the 1-minute load average was below 50. The load average at each run boundary was 17.5 to 47.1. Three runs started above 30: base-1 at 43.6, flip-8 at 47.1 and base-8 at 45.1.

median (n=8) base flip delta
cold status s 1.768 1.540 −12.9 %
index s 25.01 28.34 +13.3 %
callers p50 / p95 ms 33 / 40 34 / 41 +3.0 % / +2.5 %
context p50 / p95 ms 184 / 801 190 / 720 +3.3 % / −10.1 %
grep p50 / p95 ms 44 / 56 44 / 59 0.0 % / +5.4 %
search p50 / p95 ms 72 / 1095 73 / 1044 +1.4 % / −4.7 %
calls/s 36.35 35.99 −1.0 %
peak RSS MB 709 706 −0.4 %

Request latency, startup latency and peak RSS are inside the 10 % bound. The read p50s differ by 0 to 2 ms, about 1 ms on the 33 ms callers read. That is the size of one step in a run's millisecond-rounded percentile, and the paired probe shows no gap at the same load. Index time is not a section 7 measurement, and its runs spread widely: base 21.1 to 46.0 s, flip 23.5 to 35.2 s. The index phase polls readiness once a second. The previous ABBA had index time at −0.5 %.

@changeset-bot

changeset-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b14a8a2

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@ScriptedAlchemy
ScriptedAlchemy marked this pull request as draft October 15, 2026 06:55

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Devin Review: 1 flag

Not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

The three project_server_lifecycle hops ran as spawned Tokio tasks that a
dropped caller only aborted, so their work (a cloned StoreAdministration,
the carried admission lease) was dropped later on a Tokio worker, possibly
after the clients owner reported drained. on_tokio now polls and drops the
work in the caller's task with the Tokio runtime entered.
NativeTask now implements Future, and NativeTaskSet joins native tasks in
completion order and aborts the rest on drop, as JoinSet does. The
daemon's ClientTasks becomes an alias of it, and NativeTask::wait goes
back to borrowing its handle.
A registration that missed the Tokio runtime had already aborted the
running consumer of the data root, then released its own entry, leaving
the root with no consumer. The handle is now resolved first, and the
return names why no consumer started.
Both production callers already hold the handle. The inner lookup
returned silently on a miss, closing the listener unserved.
The installed handle now sits in a clearable slot. main uninstalls it
before shutdown_background, so a late tokio_handle call is a counted
miss instead of a handle to a dead runtime. A separate test binary
covers the fallback from a native root and the uninstall.
The native root test now asserts native_thread_writer_waits is 0 and
drops the tokio_runtime_missing assertion, which could not fail with
the handle installed. The receipt is logged as daemon_runtime_hazards
so it no longer reads like a shutdown fault.
NativeTask is a Future now, so clippy's async_yields_async flagged the
test that returned one from block_on.
A native worker has no ambient Tokio runtime, so tokio::spawn,
spawn_blocking, Handle::current, JoinSet::spawn and the tokio::time free
functions are now lint errors. Test modules, which run on an ambient
Tokio runtime, carry a module allow; project_server_lifecycle carries
one for its Tokio-polled timers.
The 242 module allows also switched off the #3829 tracing set_default
guard in those modules. A repository check replaces the lint.
scripts/check-ambient-tokio.py resolves each production file's tokio
imports and fails on tokio::spawn, spawn_blocking, Handle::current,
JoinSet::spawn and the tokio::time free functions outside test code.
An exception names its reason in an ambient-tokio comment; the
project_server_lifecycle module declaration carries the one exception.
The Repository gates job runs the check and its self-test, and Bazel runs
the self-test as //scripts:ambient_tokio_check_test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant