feat(daemon): bound a session's unwritten input with a byte budget #71

Merged
natsukium merged 26 commits from feat/bounded-io-queues-15-v2 into main 2026-09-04 15:02:41 +09:00
Owner

Fixes #15.

What this changes

Both ends of the input path were unbounded, so a peer could turn a
same-UID felis sessions send loop — or a stalled carrier — into
unbounded memory growth.

Daemon side. PtyWriter queued into an unbounded channel and
answered every write with Ready(Ok(len)), so nothing above it could
tell "queued" from "written". The writer now carries a queued/written
gauge, and each session admits at most 16 MiB of unwritten input against
a per-session semaphore. The connection's inbound pump reserves before
the bytes enter the command channel, so an exhausted budget stops the
peer that is typing — backpressure reaching its socket — while the
session actor stays free to serve every other subscriber. felis daemon status reports it as the new pty_input_bytes row.

Window side. The window's outgoing frame queue is bounded at 4 MiB
and coalesces the kinds whose queued value is state (resize, focus,
color scheme, viewport, buttonless motion) in place; keystrokes, pastes
and every other kind keep their order. Past the cap the window treats
the carrier as lost and reconnects, since megabytes a socket has not
accepted describe a dead transport rather than a slow one.

CLI side. felis sessions send now returns only once the daemon has
admitted the payload against that budget, so it no longer exits 0 on
bytes the child never received; --timeout bounds the admission wait as
well as the mark watch.

Wire: protocol minor 7 — ResourceKind::PTY_INPUT_BYTES. It degrades to
no other row, so a daemon answering a client below minor 7 omits it.

Doc cascade

docs/reference/ipc.md (the minor ledger, the backpressure section),
docs/reference/cli.md (send, daemon status, the doctor example),
docs/reference/spec.md (REQ-1011a), docs/explanation/security-model.md,
docs/explanation/architecture/session-lifecycle.md,
docs/explanation/architecture/control-surfaces.md, CHANGELOG.md, and
skills/felis/SKILL.md.

Review follow-ups

A sol review of the rebased branch found three defects, all fixed here:

  • the rebase left two MAX_PASTE_BYTES constants disagreeing by 64
    bytes, so payloads in the gap passed a sender's preflight and were
    then refused by the daemon's admission, which closes the connection.
    There is now one constant, and send_input carries the validation
    the coalescing queue had stopped inheriting from send_control;
  • felis bridge's sessions.send answered at the write rather than at
    admission, reporting success for bytes a stalled child never received;
  • an ordered mouse event sealed only the motion slot, so a later resize
    could take an earlier one's place and reflow the child before it read
    coordinates computed against the old grid. seals is now a set.

Rebase note

This branch was built on the issue #14 branch, which landed on main by
rebase with review follow-ups, and main moved again while this was in
flight. fix(cli): settle the first-operation deadline on the bridge anchor was dropped: main solved the same problem differently, by
exempting Ops connections from the first-operation deadline outright.
The branch is feat/bounded-io-queues-15-v2 because the first push no
longer rebased cleanly onto main and force-pushing was not on the table.

Verified: just check green (fmt, clippy, nextest 3163 passed, deny,
proto-compat), pi review request-changes on the first round with all
three findings accepted and fixed.

Fixes #15. ## What this changes Both ends of the input path were unbounded, so a peer could turn a same-UID `felis sessions send` loop — or a stalled carrier — into unbounded memory growth. **Daemon side.** `PtyWriter` queued into an unbounded channel and answered every write with `Ready(Ok(len))`, so nothing above it could tell "queued" from "written". The writer now carries a queued/written gauge, and each session admits at most 16 MiB of unwritten input against a per-session semaphore. The connection's inbound pump reserves before the bytes enter the command channel, so an exhausted budget stops the peer that is typing — backpressure reaching its socket — while the session actor stays free to serve every other subscriber. `felis daemon status` reports it as the new `pty_input_bytes` row. **Window side.** The window's outgoing frame queue is bounded at 4 MiB and coalesces the kinds whose queued value is state (resize, focus, color scheme, viewport, buttonless motion) in place; keystrokes, pastes and every other kind keep their order. Past the cap the window treats the carrier as lost and reconnects, since megabytes a socket has not accepted describe a dead transport rather than a slow one. **CLI side.** `felis sessions send` now returns only once the daemon has admitted the payload against that budget, so it no longer exits `0` on bytes the child never received; `--timeout` bounds the admission wait as well as the mark watch. Wire: protocol minor 7 — `ResourceKind::PTY_INPUT_BYTES`. It degrades to no other row, so a daemon answering a client below minor 7 omits it. ## Doc cascade `docs/reference/ipc.md` (the minor ledger, the backpressure section), `docs/reference/cli.md` (`send`, `daemon status`, the doctor example), `docs/reference/spec.md` (REQ-1011a), `docs/explanation/security-model.md`, `docs/explanation/architecture/session-lifecycle.md`, `docs/explanation/architecture/control-surfaces.md`, `CHANGELOG.md`, and `skills/felis/SKILL.md`. ## Review follow-ups A sol review of the rebased branch found three defects, all fixed here: - the rebase left two `MAX_PASTE_BYTES` constants disagreeing by 64 bytes, so payloads in the gap passed a sender's preflight and were then refused by the daemon's admission, which closes the connection. There is now one constant, and `send_input` carries the validation the coalescing queue had stopped inheriting from `send_control`; - `felis bridge`'s `sessions.send` answered at the write rather than at admission, reporting success for bytes a stalled child never received; - an ordered mouse event sealed only the motion slot, so a later resize could take an earlier one's place and reflow the child before it read coordinates computed against the old grid. `seals` is now a set. ## Rebase note This branch was built on the issue #14 branch, which landed on main by rebase with review follow-ups, and main moved again while this was in flight. `fix(cli): settle the first-operation deadline on the bridge anchor` was dropped: main solved the same problem differently, by exempting `Ops` connections from the first-operation deadline outright. The branch is `feat/bounded-io-queues-15-v2` because the first push no longer rebased cleanly onto main and force-pushing was not on the table. Verified: just check green (fmt, clippy, nextest 3163 passed, deny, proto-compat), pi review request-changes on the first round with all three findings accepted and fixed.
Three surfaces were about to pick their own numbers for the same
question -- how much unwritten input either end may hold -- and a
daemon budget smaller than the largest paste a client may send is not
a tuning mismatch but a deadlock: a reservation past the semaphore's
total permits never completes. The new `limits` module puts the budget,
the paste ceiling, the bracketing overhead, and the window's queue cap
side by side with a `const` assertion pinning the paste plus its
brackets under the budget, so the relation is checked at build time
rather than rediscovered when a 16 MiB paste wedges a connection.

`ResourceKind::PtyInputBytes` carries the daemon's view of that budget
to `felis daemon status`, which makes it minor 7: no other row means
what it means, and an unknown enum value costs a client the whole
reply, so an older peer gets the report without the row.

Refs #15

Assisted-by: Claude Code
`PtyWriter` queued into an unbounded channel and answered every write
with `Ready(Ok(len))` and every flush with `Ready(Ok(()))`, so "flushed"
meant "queued" and nothing above it could tell the two apart. A child
that stops reading its stdin -- a stopped job, a TUI wedged in a
syscall -- therefore turned a same-UID `sessions send` loop into
unbounded daemon growth, and the connection's 64-slot command channel
bounded nothing because the session actor drained it into a write that
never blocked.

The writer now carries a queued/written gauge: writes park at the
pending cap, a flush completes only once the OS writer has taken every
byte or the write failed, and a caller may hand in an opaque
reservation released at the same point. The daemon reserves against a
per-session semaphore in the connection's inbound pump, before the
bytes enter the command channel, and the permit rides with them to the
writer thread. An exhausted budget therefore stops the pump that
produced the input -- backpressure reaching the peer's socket -- while
the session actor stays free to serve every other subscriber.

Reserving inside the actor was the rejected alternative: `write_pty` is
called from the loop that composes diffs for every mirror, so a bounded
writer awaited there would let one stuck child freeze the screen for
everyone. That is also why the actor's own replies (`DA` answers, focus
and color-scheme reports) are dropped at the cap rather than awaited: a
child not reading its stdin cannot observe them either way. With the
PTY write no longer an await, the actor's whole command path is
synchronous.

A paste reserves its bracketing overhead whatever the mode, because
`?2004` is read at write time and the child can flip it after
admission; one past the paste ceiling is refused outright, since a
reservation larger than the budget could never be granted.

Refs #15

Assisted-by: Claude Code
Both connection generations queued into an unbounded channel, so a
carrier that stopped draining -- an SSH link over a dead network, a
local peer wedged behind its own backlog -- moved every resize sample,
every `?1003` motion report, and every keystroke into the window's
memory with nothing to stop it.

`OutgoingQueue` bounds that backlog in bytes and decides what to do at
the cap by message kind. Kinds whose queued value is state rather than
history -- resize, focus, color scheme, viewport, buttonless motion --
are replaced in place by the newer one, keeping the older frame's slot:
the daemon applies a resize before the keystrokes queued after it, so a
coalesced resize that moved to the back of the queue would reach the
shell after bytes it was meant to precede. Everything else appends and
is never dropped.

Past the cap the window declares the carrier lost and takes the
reconnect path. Blocking the winit loop would freeze the window on a
network condition, and silently dropping keystrokes is the one failure
a user cannot see; four megabytes a socket has not accepted describe a
transport that is gone rather than one that is slow, which is the
daemon's "cut on volume, never on time" applied from the other end.

The rule lives in `felis-client-core` and reads the `InputMsg` variant
alone -- a typed rule, never a heuristic on the payload -- so it is
testable without a window and available to any other frontend.

Refs #15

Assisted-by: Claude Code
The backpressure story documented only daemon to client, where an
unbounded outbox is cut on volume. The other direction takes the
opposite answer and the asymmetry is the part worth writing down: a
session has N subscribers but exactly one child, so blocking on a
mirror punishes everyone for one peer's stall while blocking on the
child punishes only the connection whose bytes it is not taking.

Refs #15

Assisted-by: Claude Code
A pump waiting on `acquire_many_owned` polls nothing else, so a peer
that hangs up while the child it feeds is wedged was never noticed:
the connection kept its admission permit and stayed registered as a
subscriber — input owner, idle-reap accounting and all — until the
child drained, which a wedged child never does. That is a same-UID
resource leak inside the very path the input budget was added to bound.

The acquisition is now raced against the peer hanging up and against
the session ending. The hangup watch reads at most one read-ahead
window past what the pump had already taken and keeps the bytes in the
reader's buffer, so backpressure still reaches the peer and no frame is
lost; a peer that left more than that pipelined is still only noticed
when the child drains, which the docs now say. The session arm matters
when the outbound half is itself blocked writing to a peer that stopped
reading, the one case where the sibling pump cannot end the connection.

Tests: a peer hanging up mid-park gives its connection slot back
against a child producing nothing (the only place the hangup can be
seen); a destroy under a parked pump completes and releases it; and the
stalled-child acceptance test now attaches a second window while the
typist is parked and requires live deltas past its rehydrate boundary,
which is what proves the diff fan-out — not just the ops reply path —
stays alive, plus an unsubscribe answered under the same park.

Refs #15

Assisted-by: Claude Code
At 4 MiB the queue refused any single frame larger than itself, even
against an empty queue, and the window answers a refusal by declaring
the carrier lost. So Ctrl+Shift+V of a 5 MiB clipboard — or a pipe of
that much command output — dropped a perfectly live connection,
re-dialed, replayed the grid, and never delivered the paste, while the
daemon would have accepted up to `MAX_PASTE_BYTES`. The refusal means
"this transport is gone", so it must never fire on a message the daemon
would have taken.

The cap is now that largest message plus 4 MiB of backlog. It has to
clear one whole message or a lone paste reads as a dead carrier, and
clear it with room to spare or the next keystroke typed while that
paste drains does. A const assert pins the relationship.

Refs #15

Assisted-by: Claude Code
The `daemon status` example on the same page moved to 1.7 with this
series' two minor bumps; the `doctor` block a few sections down still
showed 1.5, leaving one reference page quoting two protocol minors.

Refs #15

Assisted-by: Claude Code
`wait_for_hangup` took its ceiling as `buf.len() + MAX_READAHEAD`,
re-measured on every call. The daemon's inbound pump calls it once per
input message it cannot admit, retiring one frame between two parks, so
a peer that keeps writing to a session whose budget is full earned
another 256 KiB of read-ahead per park — an unbounded per-connection
buffer growing outside the very byte budget the park exists to enforce.

The ceiling is now the buffer's absolute size. A peer that has already
left a read-ahead's worth pipelined was never noticed anyway (EOF sits
behind those bytes), so nothing that used to be detected stops being
detected.

Refs #15

Assisted-by: Claude Code
Writing the frames is not delivering them. A connection whose session
has no input budget left parks its pump mid-message, and a peer that
closes its socket while it is parked is noticed there and its
admitted-but-unwritten input abandoned with the connection. `sessions
send` wrote its frames, wrote `Detach`, awaited nothing and exited, so
against a wedged or merely busy child it reported exit 0 and a `reached`
object for text no child ever saw.

It now reads a query back across the same connection before exiting.
The daemon handles one connection's frames in order, so that reply
proves the input frames ahead of it cleared admission and are the
daemon's to write. The cost is that `send` waits while a child is not
reading its stdin, which is the backpressure this series is for;
`--wait --timeout` is what bounds it.

Refs #15

Assisted-by: Claude Code
`Viewport` coalesces in place while `JumpPrompt` is ordered, but both
write the subscriber's viewport in the session actor and the jump
computes its target from wherever that viewport already is. Queue
`Viewport(0)`, `JumpPrompt`, `Viewport(50)` on a backlogged queue and
the replacement hoisted the second scroll into the first one's slot:
the daemon scrolled to 50 and then jumped from 50, discarding the
user's last scroll and anchoring the jump wrong.

A frame may now name the coalescing slot it closes behind it. Every
other replaceable kind commutes with the ordered frames around it,
which is what lets one slot serve the whole queue, so `JumpPrompt` is
the only frame that seals one.

Refs #15

Assisted-by: Claude Code
`MAX_PASTE_BYTES` is pinned below the budget so the largest paste the
daemon accepts is grantable in a single reservation, and only a `const`
assert in `limits.rs` stood behind that: the reservation-size unit test
computes byte counts and never sends a message. Nothing exercised one
message consuming almost the whole budget at once, nor the release of
that reservation once the child starts reading — the two halves the
plan named and the multi-chunk stall test does not reach.

Refs #15

Assisted-by: Claude Code
`sessions send` now waits for the daemon to admit its payload, and a
queued `Viewport` stops being replaceable once a `JumpPrompt` is behind
it; both are user-visible facts the reference pages and the shipped
skill owe their readers.

The two prose fixes ride along because they are the same paragraphs: the
CHANGELOG's window-queue bullet had accumulated four nested em-dash
asides in one sentence and a 112-column line, and the "daemon status is
not optional" paragraph was left ragged by the connection-cap edit.

Refs #15

Assisted-by: Claude Code
`InputMsg::Mouse` reserved nothing and reached the PTY through the
unreserved path, where the writer's gauge drops bytes silently once it
is full. Under `?1002`/`?1003` that can write a press and drop its
release, leaving a TUI in a drag no further input clears — the "a lost
keystroke is gone and the user cannot tell" case the drop rule exists to
avoid. A mouse report is a window's input, so it now takes a reservation
like a keystroke: the widest form the encoders produce, because the
actor picks protocol and encoding after admission.

The gauge that governs the drop also counted admitted bytes, which made
the rule's own premise false: a child merely behind a 16 MiB paste is
still reading, still owed its `DA` answer, and had every self-generated
reply dropped until the paste drained. The writer now gauges unreserved
bytes apart from the queued total, so admitted input cannot silence the
replies.

The paste test the round-5 amendment named rides along: a paste admitted
while `?2004` was off, written bracketed after the child flipped it, has
to come out of the reservation already granted.

Refs #15

Assisted-by: Claude Code
`send` confirms admission by reading a query back across the same
connection, and against a child that has stopped reading its stdin that
read never completes. The confirmation sat outside `--timeout`, so
`send --wait --timeout N` promised a bounded failure and delivered a
hang — the shipped skill tells agents to rely on exactly that bound. The
deadline now spans everything after the writes, admission included, and
a wedged child ends the call as a `timeout` (exit 1) naming the child as
the cause.

The over-limit payload is the other half. The daemon answers an input
frame past the budget by closing the connection, which the caller can
only report as the transient `daemon_lost` (exit 2, "retry") though the
condition is permanent and locally visible. Both senders now refuse it
themselves: `send` as `invalid_request` before the dial, and a window by
logging and dropping the paste rather than losing its carrier.

Refs #15

Assisted-by: Claude Code
The preface and the `Hello` read of a capacity refusal were
deadline-bounded, but the write of the refusal itself was not. A peer
that completes the handshake and then stops reading parks that task in
the socket write for as long as it likes while holding one of the
sixteen refusal slots; sixteen such peers exhaust the path permanently,
after which every over-cap dial is dropped unanswered — the one failure
a client cannot tell from a crashed daemon, which is the whole reason
the typed refusal exists.

Refs #15

Assisted-by: Claude Code
`poll_shutdown` delegated to `poll_flush`, so an `AsyncWrite` caller
that shut the writer down was left with a live channel: the writer
thread kept waiting, and a later write queued bytes for a thread the
caller believed was finished. Flush then drop the sender, which is what
ends that thread and what makes the next write fail with `BrokenPipe`
rather than succeed into a queue nobody drains.

Refs #15

Assisted-by: Claude Code
Replacing a session dropped the pump's join handles, which detaches the
tasks rather than ending them. The writer exits when its queue closes,
but a writer parked in `write_frame` on a carrier that stopped draining
never reaches that check, so it held the carrier and the frames in its
hand for the life of the process. Each queue is capped, but nothing
capped how many stranded ones a window could accumulate, so a run of
retargets across dead links grew its memory without bound — the very
thing the cap exists to prevent.

Retiring gives that writer a bounded grace to push the queued `Detach`
and then aborts it. Aborting immediately was rejected: the detach is
what tells the daemon this subscriber left, and dropping it would leave
the old session believing the window is still attached.

Refs #15

Assisted-by: Claude Code
The reference page and the changelog both said a window past its
outgoing cap re-dials. It does not: the overflow takes the same path a
hangup takes, and that path closes the window until a reconnect story
exists. Promising recovery the client does not perform sends a reader
looking for a retry that never happens.

Refs #15

Assisted-by: Claude Code
A buttonless motion coalesces because only its newest value means
anything — but a press, a release, a drag sample or a wheel tick is a
point in the same positional stream. With nothing sealing the motion
slot, a motion queued before a click was overwritten by one that
happened after it, so the program was told the pointer had already
moved when the button went down and was left holding the older position
as the pointer's last known place. Any-motion tracking then highlights
the cell the pointer left rather than the one it is on.

An ordered mouse event now closes the motion slot behind it, the same
rule `JumpPrompt` applies to the viewport slot, and motions on each side
of a click coalesce among themselves.

Refs #15

Assisted-by: Claude Code
A refusal drops an ordered message, so it is not a condition the
connection recovers from — but it was reported only as one
`DaemonClosed` event, and that event is absorbed as `AwaitSwitch` while
a landing is in flight. A landing that then failed put the window back
on the old connection, which by then may have drained enough to look
healthy again, with a keystroke or a paste silently missing.

The queue now latches on the first refusal: every later frame is
refused too, and a failed switch re-decides the outcome instead of
resuming a carrier that already lost a message.

Refs #15

Assisted-by: Claude Code
A failed `write_all` ends the writer thread with items still in the
channel. Those were charged to `queued` and would never be charged to
`written`, so the gauge reported a backlog that no longer existed and
no one would ever drain — a stale figure for whoever reads it.

Refs #15

Assisted-by: Claude Code
`limits::MAX_PASTE_BYTES` (the PTY input budget less the room a paste's
bracketing needs) and `messages::MAX_PASTE_BYTES` (a flat 16 MiB, from
REQ-105a) were separate constants naming the same bound, and they
disagreed by 64 bytes. Payloads in that gap passed the sender's
preflight -- `felis sessions send`, the bridge, the window's notice --
and were then refused by the daemon's admission, which closes the
connection: the caller was told the payload was legal and lost its link
for sending it. The wire limit is now the input-path constant itself, so
the number a sender checks is the number the daemon admits. `--raw`
keeps the whole budget, since raw bytes reach the child as written.

`send_input` gained the validation it used to inherit from
`send_control`: the coalescing queue encodes bodies itself instead of
handing messages to `FrameWriter::send`, so nothing was left to stop an
over-limit frame from costing the window its carrier. With that backstop
and `admit_paste` above it, the window's second log-only paste check had
no reachable caller and is gone.

Refs #15

Assisted-by: Claude Code
`sessions.send` on the bridge flushed the input frame and reported
success; `with_session` then released the link, wrote `Detach`, and
closed it. A session at its input budget parks the daemon's pump
mid-frame, and that is exactly where a peer hanging up is noticed and
its written input abandoned — so the bridge answered a request for
bytes no child ever received, the same hole the one-shot verb closed.

The link now issues one ordered query behind the input and waits for
its reply, which the daemon can only send once the input passed
admission. `felis sessions send`'s own confirmation is the model; the
bridge has no `--timeout` to bound it with, so the wait lasts as long
as the child is not reading, which is the backpressure itself.

Refs #15

Assisted-by: Claude Code
fix(client-core): let an ordered mouse event seal the resize slot too
Some checks failed
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m52s
windows / cargo nextest (Windows) (pull_request) Successful in 6m4s
pr / nix flake check (pull_request) Successful in 31s
pr / cargo build / clippy / test / deny (pull_request) Failing after 1m41s
pr / wire schema is compatible with the base (pull_request) Successful in 9s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 54s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Failing after 14s
windows / frontend smoke (Windows) (pull_request) Successful in 1m35s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 46s
bench / Criterion regression gate (pull_request) Failing after 2m33s
287c26248f
A frame could seal exactly one coalescing key, so a click sealed the
motion slot and nothing else. Under a backlog, `Resize(A)`,
`MousePress` (coordinates computed against A), `Resize(B)` left the
queue as `Resize(B)`, `MousePress`: the daemon reflowed the child to B
and only then handed it coordinates that were cells of A, and the click
landed on whatever had moved into that cell.

A click reads two queued states, not one -- the positional stream and
the grid its coordinates are cells of -- so `seals` is now the set of
keys a frame closes rather than a single key. `JumpPrompt` still names
one; an ordered mouse event names motion and resize.

Refs #15
`handle_input` was `#[cfg(test)]` while every caller lives in a
`#[cfg(all(test, unix))]` module, so the Windows-cross clippy job
compiled the method with nothing calling it and failed on `dead_code`,
which the workspace denies. The Linux gate never saw it because there
the callers are compiled in.

Refs #15
test(client-core): let the shell-exit watches use their whole deadline
All checks were successful
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) Successful in 2m22s
windows / cargo nextest (Windows) (pull_request) Successful in 7m1s
windows / frontend smoke (Windows) (pull_request) Successful in 1m55s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 25s
fuzz / cargo fuzz nightly long-run (push) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 48s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 2m11s
pr / nix flake check (pull_request) Successful in 43s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m21s
pr / wire schema is compatible with the base (pull_request) Successful in 14s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m9s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
pr / wire schema is compatible with the base (push) Successful in 9s
pr / frontend smoke (x86_64-linux) (push) Successful in 6s
windows / cargo clippy (Windows cross) (push) Successful in 12s
pr / publish felis (x86_64-linux) (push) Successful in 9s
windows / cargo nextest (Windows) (push) Successful in 5m6s
windows / frontend smoke (Windows) (push) Successful in 2m0s
darwin / build felis (aarch64-darwin) (push) Successful in 14s
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m29s
pr / nix flake check (push) Successful in 7s
pr / cargo build / clippy / test / deny (push) Successful in 1m46s
windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 2m39s
47773a845c
Both watches bounded the wait twice: a 15 s deadline around the loop and
a 3 s timeout on each read, where the inner one *ended* the wait. Any
quiet stretch therefore failed the test rather than being waited
through, and this branch's new tests -- a stalled child, two 16 MiB
pastes -- load the machine enough that a run of the whole suite in
parallel produces one. The inner timeout now only paces the loop; the
deadline is the bound, as it reads.

Refs #15
natsukium deleted branch feat/bounded-io-queues-15-v2 2026-09-04 15:02:42 +09:00
Sign in to join this conversation.
No description provided.