Skip to content

fix(runtime): count long native polls instead of failing the task - #4076

Merged
ScriptedAlchemy merged 10 commits into
masterfrom
refactor/subtract-long-poll-counter
Oct 16, 2026
Merged

ScriptedAlchemy merged 10 commits into
masterfrom
refactor/subtract-long-poll-counter

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Why

A native task that took a long poll no longer fails. The debug-build long-poll guard asserted after the poll had already finished, so it threw away a completed result: a broker connection closed after its request was sent (#4056), a code-index worker parked failed (#4023), a schema convergence reported failure. Every flake in that series came from the guard, and its 250 ms thread-CPU bound moved about 70x with host load and opt level (7 ms of CPU at normal load, 490-590 ms at load ~1000, fastbuild opt-0).

An adversarial review of the guard, the reactor patch, and the per-site moves (#4022, #4027, #4052, #4065, #4071, #4072) converged on subtraction. With the reactor handoff patch, a long poll on a runtime with two or more workers no longer holds the reactor, so the guard protected nothing there. The remaining gap is a one-worker runtime. This PR turns the guard into evidence and closes the one-worker gap structurally.

Scope

  • native_runtime::LongPollGuard runs in every build. It measures wall time and, on unix, thread CPU. A poll past LONG_POLL (100 ms wall) logs a tracing::warn! with the spawn site and is counted in long_native_polls(), keyed by spawn site. The poll result is never changed. The daemon's daemon_runtime_hazards line lists the sites.
  • native_runtime::spawn_lateness_canary() measures how late native timer wakes run (scheduling_lateness()). The daemon runs it for its lifetime and reports late wakes at exit.
  • native_runtime::spawn_blocking runs work inline when called on a blocking-pool thread that already runs queued work. Before, nested blocking work could deadlock a full pool.
  • A second asupersync patch, asupersync-blocking-pool-stack.patch, gives blocking-pool threads the runtime's thread_stack_size. They were on the std default of 2 MiB, not the CLI's 16 MiB.
  • tracedecay-cli: daemon run, serve, and other non-one-shot commands start at least two native workers. The reactor patch promotes a follower only when there are at least two parkers (count < 2 returns early). tool and status stay at one. The native pool takes MAX_BLOCKING_THREADS directly; max(tokio limit, 512) always resolved to 512.
  • #[track_caller] on spawn_detached, spawn_maintenance_task, spawn_doctor_task, and LspRuntimeSpawner::spawn, so counts name the real site.
  • Removes tests that only exercised the guard: fix(daemon): dispatch broker requests off native workers #4065's long_dispatch_tests, fix(code-index): prewarm memory activations on the blocking pool #4022's a_memory_activation_longer_than_one_native_poll_still_seats, and fix(runtime): trim off the native poll and park panicked workers #4027's schema_convergence_completes_when_its_allocator_trim_outlasts_a_native_poll. Also drops a stale clippy::disallowed_methods allow.

Blast Radius

Every NativeTask poll now pays two monotonic clock reads and one thread-CPU clock read. Pool threads reserve 16 MiB of virtual stack each instead of 2 MiB; resident memory is unchanged. No behavior depends on the counters; they are evidence for future per-site moves.

Verification

  • //crates/tracedecay-rusqlite-runtime:unit_test, //crates/tracedecay-cli:tracedecay_unit_test, //crates/tracedecay-code-index-runtime:unit_test, //crates/tracedecay-lsp:unit_test, //crates/tracedecay:unit_test pass.
  • Each new test was checked against its defect: the counter test fails with None when the guard stops counting; the inline test fails with None when work is queued; the canary test fails with late_ticks: 0 when nothing is recorded; the stack test fails with pool thread stack is 2097152 bytes without the patch.
  • --config=clippy and rustfmt are clean for the touched crates.
  • //crates/tracedecay:daemon_suite (77 tests) and //crates/tracedecay:mcp_suite (666 tests) pass. A local, uncommitted hook logged each counted poll during those runs. Two sites still pass 100 ms:
    • daemon/project_composition/full_owner_mount.rs:38, the HTTP application mount: 45 polls, 100-244 ms wall, CPU equal to wall, so the work is compute.
    • session_temporal_refresh_scheduler/registry.rs:372, the session refresh worker: 3 polls, up to 242 ms wall at 3-41 ms CPU, so the poll is parked, not computing.

The native blocking pool built its threads with std::thread::Builder defaults, 2 MiB, while native workers run on the CLI's 16 MiB ASYNC_STACK_BYTES. Handler futures moved off a worker through off_native_thread ran on that smaller stack. A second asupersync patch passes the runtime's thread_stack_size to BlockingPoolOptions and applies it to every pool thread.
The debug-build long-poll guard asserted after a poll had already finished,
so it discarded the poll's result and failed tasks whose side effects had
happened: a broker connection closed after its request was sent, a code-index
worker parked failed, a schema convergence reported failure. Its 250 ms
thread-CPU bound also moved 70x with host load and opt level.

LongPollGuard now runs in every build, measures wall time and (on unix)
thread CPU, and on a poll past LONG_POLL (100 ms wall) logs a warning and
counts it against the spawn site in long_native_polls(). It never changes
the poll's result. The daemon's runtime hazards line lists those sites.

Beside it, so long polls can be judged by their effect:
- spawn_lateness_canary() measures how late native timer wakes run; the
  daemon runs it for its lifetime and reports late wakes at exit.
- spawn_blocking runs work inline when called on a blocking-pool thread
  that already runs queued work, so a full pool cannot deadlock on nested
  blocking work.
- MAX_BLOCKING_THREADS describes the native pool it bounds.

The long-dispatch test #4065 added only exercised the guard and goes with it.
The reactor handoff patch promotes a parked follower only when a runtime has two or more parkers, so on a one-CPU host daemon run and serve held every socket wake and timer behind any long poll. Commands other than the one-shot tool and status clients now start at least two native workers. The native blocking pool takes MAX_BLOCKING_THREADS directly instead of max(tokio limit, 512), which always resolved to 512.
spawn_detached, spawn_maintenance_task, spawn_doctor_task, and LspRuntimeSpawner::spawn wrap NativeTask::spawn without #[track_caller], so every task they start counted against the wrapper's line. They now pass their caller's location through.
@changeset-bot

changeset-bot Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 783ddaf

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

@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: 2 flags

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

Devin Review

a_memory_activation_longer_than_one_native_poll_still_seats (#4022) and schema_convergence_completes_when_its_allocator_trim_outlasts_a_native_poll (#4027) inject CPU so a poll trips the guard. With long polls counted instead of failed, both pass whether or not the prewarm and the trim stay on a worker.
…ong-poll-counter

# Conflicts:
#	MODULE.bazel
…ong-poll-counter

# Conflicts:
#	crates/tracedecay-rusqlite-runtime/src/native_runtime.rs
…ong-poll-counter

# Conflicts:
#	crates/tracedecay/src/daemon/bootstrap.rs
NativeTask::spawn_off_native_thread polls its work on a blocking-pool thread without marking it, so spawn_blocking called from that work queued behind the thread it held. It now marks the thread like queued blocking work does.
@ScriptedAlchemy
ScriptedAlchemy merged commit 683bf20 into master Oct 16, 2026
10 of 20 checks passed
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