daemon: hand the parse lock off so a flood cannot starve pull answers #376

Merged
natsukium merged 2 commits from fix/375-parse-lock-starvation into main 2026-09-24 03:49:01 +09:00
Owner

Closes #375.

Problem

Under a sustained flood, the Linux window stopped repainting until the output stopped. The PTY parse thread and the
session task share ParseCore. std::sync::Mutex on Linux never hands the lock to a waiter: the parse thread took it
back each time it released it, so the session task could not reliably acquire it to answer a frame pull. Under pull
pacing, an unanswered pull means the window never gets a cycle, never paints, and never pulls again.

Change

  • ParseCore is now behind parking_lot::Mutex, which hands the lock to a waiter on average about every 0.5 ms.
    The guard is still !Send, since send_guard is not enabled.
  • implementation.md and pipeline.md ("Demand-driven emission") now say that this lock must hand off to waiters.
    They record the rejected alternatives and a "Revisit if" trigger.
  • CHANGELOG has a Fixed entry.
  • measure_pull_cycles_under_flood is a new #[ignore]d measurement test. It needs a release build. It asserts no
    cycle count, because an unfair lock does not starve every run.

Evidence

DOOM-fire (10 s, Linux, niri, NVIDIA), same client binary:

Daemon Pulls during the flood Distinct 1 s screenshots (of 10) DOOM-fire fps
std::sync::Mutex 3 3 1,120
parking_lot::Mutex 161 9 1,095

Headless measurement test, release build, 8 runs per lock, cycles in 10 s:

  • std::sync::Mutex: 1 to 11, plus one outlier at 477.
  • parking_lot::Mutex: 268 to 519.

cat of 150 MB (ascii, unicode and csi, 2×3 runs per daemon) showed no difference beyond run-to-run noise.

just check passed. pi (sol) reviewed the design and the code, and both rounds returned PASS. The one non-blocking
finding, a non-atomic counter in the test, is fixed in the second commit.

Closes #375. ## Problem Under a sustained flood, the Linux window stopped repainting until the output stopped. The PTY parse thread and the session task share `ParseCore`. `std::sync::Mutex` on Linux never hands the lock to a waiter: the parse thread took it back each time it released it, so the session task could not reliably acquire it to answer a frame pull. Under pull pacing, an unanswered pull means the window never gets a cycle, never paints, and never pulls again. ## Change - `ParseCore` is now behind `parking_lot::Mutex`, which hands the lock to a waiter on average about every 0.5 ms. The guard is still `!Send`, since `send_guard` is not enabled. - `implementation.md` and `pipeline.md` ("Demand-driven emission") now say that this lock must hand off to waiters. They record the rejected alternatives and a "Revisit if" trigger. - CHANGELOG has a Fixed entry. - `measure_pull_cycles_under_flood` is a new `#[ignore]`d measurement test. It needs a release build. It asserts no cycle count, because an unfair lock does not starve every run. ## Evidence DOOM-fire (10 s, Linux, niri, NVIDIA), same client binary: | Daemon | Pulls during the flood | Distinct 1 s screenshots (of 10) | DOOM-fire fps | |---|---|---|---| | `std::sync::Mutex` | 3 | 3 | 1,120 | | `parking_lot::Mutex` | 161 | 9 | 1,095 | Headless measurement test, release build, 8 runs per lock, cycles in 10 s: - **`std::sync::Mutex`:** 1 to 11, plus one outlier at 477. - **`parking_lot::Mutex`:** 268 to 519. `cat` of 150 MB (ascii, unicode and csi, 2×3 runs per daemon) showed no difference beyond run-to-run noise. `just check` passed. pi (sol) reviewed the design and the code, and both rounds returned PASS. The one non-blocking finding, a non-atomic counter in the test, is fixed in the second commit.
A pull-paced window stopped repainting on Linux while a program flooded
the terminal with screen redraws (DOOM-fire without synchronized
output). The PTY parse thread relocks ParseCore once per swap buffer,
and std::sync::Mutex on Linux is a futex lock with no hand-off: the
thread that just unlocked takes it again before the woken waiter runs.
The session task therefore could not reliably acquire ParseCore to
answer a NextGridFrame pull, and since the client holds its next pull
until CycleEnd, one unanswered pull stopped emission for the rest of the
flood. The client itself was idle the whole time, waiting on a cycle.

ParseCore now sits behind parking_lot::Mutex, whose eventual fairness
forces a hand-off to a waiter on average about every 0.5 ms. Its guard
stays !Send (send_guard is not enabled anywhere in the workspace), so
holding it across an await is still a compile error, and it has no
poisoning, which the old lock swallowed anyway.

Measured with the GUI client on Linux/niri, same client binary, only the
daemon's lock changed:

  - pulls answered in a 10 s DOOM-fire flood: 3 (std) vs 161 (parking_lot)
  - DOOM-fire fps: 1,120 vs 1,095
  - 150 MB cat (ascii/unicode/csi, 2x3 runs): unchanged within run noise

The new ignored measurement measure_pull_cycles_under_flood reproduces
this headlessly (release profile, truecolor full-screen flood, pull
re-armed after every CycleEnd, 10 s window): std gave 11, 3, 1, 2, 2, 1,
1 cycles in seven runs and 477 in one; parking_lot gave 268 to 519
across eight runs. It stays ignored because an unfair lock does not
starve on every host and a fair one guarantees no cycle-count deadline,
so any fixed threshold would flake in CI.

Rejected: unlock_fair after every sink chunk (a context switch per swap
buffer on the throughput path), a yield or sleep in the sink (a constant
tuned to one scheduler), a client-side forced-frame deadline (the daemon
has nothing composed while it cannot take the lock), and composing off
the shared lock (right shape perhaps, far too large a change for the
liveness bug). pipeline.md "Demand-driven emission" records these.

Closes #375

Assisted-by: Claude Code
daemon: publish the flood test's pass counter atomically
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m3s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m1s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 2m10s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 1m21s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 3m11s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m47s
pr / wire schema is compatible with the base (pull_request) Successful in 14s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 4m19s
windows / frontend smoke (Windows) (pull_request) Successful in 1m47s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
da99c04ff3
The child rewrote the counter file in place, so a read landing between
the truncate and the write saw 0: that could satisfy the still-flooding
check by accident or underflow the reported pass count. The shell now
writes a temporary file and renames it over the counter.

Assisted-by: Claude Code
natsukium force-pushed fix/375-parse-lock-starvation from da99c04ff3
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m3s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m1s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 2m10s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 1m21s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 3m11s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m47s
pr / wire schema is compatible with the base (pull_request) Successful in 14s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 4m19s
windows / frontend smoke (Windows) (pull_request) Successful in 1m47s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
to 01228bae60
All checks were successful
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m10s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m24s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 24s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m50s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 4m3s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / frontend smoke (Windows) (pull_request) Successful in 1m46s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
darwin / detect relevant changes (push) Successful in 4s
darwin / darwin docs-only no-op (push) Has been skipped
darwin / build felis (aarch64-darwin) (push) Successful in 13s
pr / cargo build / clippy / test / deny (push) Successful in 1m47s
pr / wire schema is compatible with the base (push) Successful in 12s
fuzz / cargo fuzz nightly long-run (push) Has been skipped
darwin / publish felis (aarch64-darwin) (push) Successful in 31s
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m9s
pr / nix flake check (push) Successful in 6s
pr / publish felis (x86_64-linux) (push) Successful in 12s
windows / cargo clippy (Windows cross) (push) Successful in 12s
windows / cargo nextest (Windows) (push) Successful in 3m30s
windows / frontend smoke (Windows) (push) Successful in 1m31s
pr / frontend smoke (x86_64-linux) (push) Successful in 7s
windows / detect relevant changes (push) Successful in 4s
windows / windows docs-only no-op (push) Has been skipped
windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m36s
2026-09-24 03:29:53 +09:00
Compare
natsukium deleted branch fix/375-parse-lock-starvation 2026-09-24 03:49:01 +09:00
Commenting is not possible because the repository is archived.
No description provided.