Skip to content

streams: expose SetReadDeadline so idle teardown actually engages - #31

Merged
TeoSlayer merged 2 commits into
mainfrom
fix/stream-read-deadline
Jul 29, 2026
Merged

streams: expose SetReadDeadline so idle teardown actually engages#31
TeoSlayer merged 2 commits into
mainfrom
fix/stream-read-deadline

Conversation

@TeoSlayer

Copy link
Copy Markdown
Contributor

The bug

Frame readers gate their slowloris/idle teardown on an optional interface:

dl, canDeadline := conn.(readDeadliner)   // dataexchange/service.go:227
if canDeadline && idle > 0 {
    _ = dl.SetReadDeadline(time.Now().Add(idle))
}

streamAdapter did not implement SetReadDeadline, so canDeadline was false for every in-daemon plugin connection and dataexchange's DefaultIdleTimeout (2 minutes) was never applied.

A peer that opened a stream, sent one frame and then stalled held its handler goroutine and the entire underlying Connection — a 512-slot RecvBuf, a 256-slot SendBuf, and three goroutine stacks, roughly 43 KiB — until the process died.

What it cost

On 2026-07-28 this filled the daemon's documented connection bound across the service fleet:

DefaultMaxTotalConnections = 65536
65536 × ~43 KiB ≈ 2.8 GB per daemon
observed at OOM ≈ 2.75 GB   (anon-rss 2,881,396 kB)

431 agents × that on a 256 GB host → system-wide kernel OOM. Agents were reaped, restart-looped, stopped heartbeating, and the registry expired every registration. The entire fleet became unresolvable.

The diagnostic that isolated it: idle daemons on other hosts were completely unaffected — one had run 24 weeks at 17 MB. That ruled out a time-based leak and pointed at per-request retention.

The fix

SetReadDeadline forwards to the underlying ConnReadWriter when that transport supports deadlines, and reports success otherwise so callers treating a failure as fatal aren't broken by transports that legitimately can't do deadlines.

Also added a compile-time assertion that *streamAdapter satisfies the interface. This failure mode was invisible precisely because a failed type assertion produces no diagnostic — it just quietly selects the no-timeout path. The assertion makes dropping the capability a build error.

⚠️ This is only half the chain

The daemon's connAdapter.SetReadDeadline was itself a stub returning nil without storing anything — a no-op that reported success. That's fixed separately in pilot-protocol/pilotprotocol.

Both are required for the timeout to engage, and web4 currently pins runtime v0.3.1, so this needs a release + a dependency bump before the fix reaches production.

🤖 Generated with Claude Code

teovl and others added 2 commits July 29, 2026 14:40
Frame readers gate their slowloris/idle teardown on an optional interface:

    dl, canDeadline := conn.(readDeadliner)   // dataexchange/service.go:227
    if canDeadline && idle > 0 {
        _ = dl.SetReadDeadline(time.Now().Add(idle))
    }

streamAdapter did not implement SetReadDeadline, so canDeadline was false
for every in-daemon plugin connection and dataexchange's DefaultIdleTimeout
(2 minutes) was never applied. A peer that opened a stream, sent one frame
and then stalled held its handler goroutine and the entire underlying
Connection — a 512-slot RecvBuf, a 256-slot SendBuf and three goroutine
stacks, roughly 43 KiB — until the process died.

On 2026-07-28 that filled the daemon's documented connection bound
(DefaultMaxTotalConnections = 65536 x ~43 KiB = ~2.8 GB per daemon) across
431 service agents on a 256 GB host, triggering system-wide kernel OOM. The
agents were reaped, restart-looped, stopped heartbeating, and the registry
expired every registration — the whole fleet became unresolvable. Idle
daemons on other hosts were unaffected and had run 24 weeks at 17 MB, which
is what identified this as per-request retention rather than a time-based
leak.

SetReadDeadline forwards to the underlying ConnReadWriter when that
transport supports deadlines, and reports success otherwise so callers that
treat a failure as fatal are not broken by transports that legitimately
cannot do deadlines.

Added a compile-time assertion that *streamAdapter satisfies the interface,
so the capability cannot be silently dropped again. The failure mode here
was invisible precisely because a failed type assertion has no diagnostic —
it just quietly selects the no-timeout path.

Note: this is only half the chain. The daemon's connAdapter.SetReadDeadline
was itself a stub returning nil without storing anything; that is fixed in
pilotprotocol separately. Both are required for the timeout to engage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ines

daemonEventBus.Subscribe wraps the daemon bus in a forwarder goroutine that
did a BLOCKING send onto the channel it hands back:

    out <- coreapi.Event{...}

The underlying bus deliberately uses non-blocking drop semantics so a slow
subscriber can never stall its publishers. Forwarding with a blocking send
silently converted that guarantee into a goroutine leak.

If a plugin consumer stopped draining out — handler loop exited, panicked
past its recover, or merely ran slower than the publisher for cap(src) events
— this goroutine parked on the send permanently, retaining itself, the
buffered contents of src, and every payload map they referenced.

cancel() does not rescue it. cancel closes src, which does nothing for a
goroutine already blocked on a send to out. Nothing else can ever unblock it,
so the leak lasts the process lifetime.

Every other subscriber in the tree consumes the bus channel directly rather
than through an intermediate forwarder, which is why this only affects the
plugin path.

Dropping matches what the bus would have done once its buffer filled, so a
slow consumer now loses events — the documented behaviour — instead of
leaking a goroutine forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TeoSlayer
TeoSlayer force-pushed the fix/stream-read-deadline branch from 0d341ad to bb16ae9 Compare July 29, 2026 11:41
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@TeoSlayer
TeoSlayer merged commit 0c9d22e into main Jul 29, 2026
4 checks passed
@TeoSlayer
TeoSlayer deleted the fix/stream-read-deadline branch July 29, 2026 11:46
TeoSlayer added a commit to pilot-protocol/pilotprotocol that referenced this pull request Jul 29, 2026
…imers (#435)

* daemon: make connAdapter read deadlines real, stop leaking registry timers

connAdapter's deadline setters were stubs:

    func (a *connAdapter) SetReadDeadline(t time.Time) error  { return nil }

They stored nothing and reported success. Anything that set a read deadline
got no deadline and no error — a silent lie, which is worse than not
implementing the method, because callers cannot detect the failure.

Read consequently blocked on <-a.conn.RecvBuf forever. A peer that opened a
stream and then went silent pinned its handler goroutine plus the whole
Connection: a 512-slot RecvBuf, a 256-slot SendBuf and three goroutine
stacks, roughly 43 KiB each.

On 2026-07-28 that filled the daemon's own documented bound —
DefaultMaxTotalConnections (65536) x ~43 KiB = ~2.8 GB — across 431 service
agents on a 256 GB host, causing system-wide kernel OOM. The agents were
reaped, restart-looped, stopped heartbeating, and the registry expired every
registration, so the whole fleet went unresolvable. The daemon was not
leaking past a bound; it was filling its bound and sitting there, which is
why growth was ~100x and then plateaued. Idle daemons elsewhere were fine at
17 MB after 24 weeks, identifying this as per-request retention.

Changes:

  - SetReadDeadline now stores an absolute deadline (atomic; it may be set
    from a goroutine other than the one parked in Read), and Read selects on
    RecvBuf against a timer, returning a net.Error with Timeout() == true.
  - SetDeadline delegates to SetReadDeadline instead of silently doing
    nothing. SetWriteDeadline stays a no-op but is now documented as such —
    Write already bounds itself via connAdapterWriteDeadline.
  - withRegistryDeadline no longer leaks a timer per call. time.After pins
    its timer for the full 8s duration, and this path runs repeatedly when
    the registry conn is half-open. Now an explicitly stopped timer. The
    result channel was already buffered, so the worker goroutine can always
    deposit and exit rather than parking on the send.

Regression coverage in zz_connadapter_deadline_test.go: the deadline fires
and reports Timeout(); a cleared deadline restores block-forever semantics;
an armed deadline does not break normal reads; an already-expired deadline
returns immediately. The first of these hangs for the full timeout and then
fails against the old stub.

NOTE: this is half the chain. Frame readers reach connAdapter through
runtime's streamAdapter, which did not expose SetReadDeadline at all, so the
type assertion that gates the idle teardown failed regardless of what the
daemon implemented. Fixed in pilot-protocol/runtime#31. web4 pins runtime
v0.3.1, so that must be released and bumped here before the timeout engages
in production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* deps: bump runtime to v0.3.2 — closes the idle-timeout chain

Without this the connAdapter deadline work in this PR is a no-op in
production. Frame readers reach connAdapter through runtime's streamAdapter,
and streamAdapter only gained SetReadDeadline in runtime v0.3.2 (#31). Pinned
at v0.3.1 the type assertion that gates dataexchange's idle teardown still
fails, so the timeout never arms no matter what the daemon implements.

Both halves are now in place:
  - runtime v0.3.2: streamAdapter exposes SetReadDeadline (+ a compile-time
    assertion so the capability cannot be dropped silently again)
  - this repo: connAdapter.SetReadDeadline stores a real deadline instead of
    being a stub that returned nil

Verified with GOWORK=off so the resolution matches CI rather than the local
go.work overlay, which shadows the module with the checkout and would have
hidden a missing go.sum entry: build clean, vet clean, pkg/daemon suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants