Skip to content

fix(hub): surface fanout slow-listener disconnects - #164

Merged
josealekhine merged 2 commits into
ActiveMemory:mainfrom
CoderMungan:fix/hub-fanout-drop-observability
Sep 8, 2026
Merged

fix(hub): surface fanout slow-listener disconnects#164
josealekhine merged 2 commits into
ActiveMemory:mainfrom
CoderMungan:fix/hub-fanout-drop-observability

Conversation

@CoderMungan

@CoderMungan CoderMungan commented Aug 27, 2026

Copy link
Copy Markdown
Member

Closes #94.

Covers items #1 (make the drop-counter observable) and #2 (regression
test)
from the issue. Item #3 (configurable fanOutBuffer) is deliberately
left out — as the issue puts it, tuning the constant before the counter is
observable would be tuning blind.

#1f.dropped is no longer dead

The counter is now readable on two surfaces, both options the issue listed:

Warning at the moment of disconnect. broadcast() calls logWarn.Warn
when it cuts a slow listener loose, using a new HubFanOutSlowListener
format constant in internal/config/warn (same pattern as the existing
HubReplicate* family — no literal in the hub package). The message carries
the cumulative count so log aggregators can rate it:

ctx: hub fanout: disconnected slow listener (buffer full); cumulative disconnects: 3

Cumulative count on the Status RPC. StatusResponse gains
DroppedListeners uint64 (json:"dropped_listeners"), populated by
hubStatus from a new droppedCount() accessor. ctx hub status prints

Dropped listeners: 3 (slow subscribers disconnected)

only when the count is non-zero, so a healthy hub's output is byte-for-byte
what it was before and no existing test or doc example changes.

On sync/atomic

Per the third acceptance item, broadcast increments with
atomic.AddUint64 (its return value is what the warning reports) and
droppedCount reads with atomic.LoadUint64, so the Status RPC handler
reads the counter without contending with an in-flight broadcast.

Every release target in hack/build-all.sh is 64-bit (darwin, linux and
windows on amd64/arm64), so the struct-field alignment caveat that applies to
the raw atomic.*Uint64 helpers on 32-bit platforms doesn't bite here.

#2 — regression test

TestFanOut_DisconnectsSlowListener overflows the buffer against a listener
that never drains, then asserts all three halves of the contract: the channel
is closed, the subscriber is gone from f.subs, and the counter incremented.
TestFanOut_DroppedCountStartsAtZero pins the healthy path so the counter
can't start drifting upward.

TestFanOut_DroppedCountRaceWithBroadcast covers the concurrency the
acceptance item is really about: four goroutines call droppedCount() — the
Status RPC handler's read path — while broadcast disconnects listeners.

I verified both by mutation rather than trusting them:

  • deleting the delete(f.subs, ch); close(ch) block fails
    TestFanOut_DisconnectsSlowListener (count = 1, want 0 after disconnect
    and disconnected channel never closed) while the original three tests
    still pass — the exact silent regression the issue describes;
  • reverting the counter to a plain f.dropped++ / return f.dropped makes
    go test -race report WARNING: DATA RACE and fail
    TestFanOut_DroppedCountRaceWithBroadcast.

The tests redirect logWarn.SetSink(io.Discard) so the new warning doesn't
pollute test output.

The rendered line is pinned too

desc.Text returns "" for an unknown key, so a renamed text key would blank
the new ctx hub status line silently — the same shape of bug this issue is
about. internal/write/hub gains TestClusterStatus_DroppedListeners
(asserts the rendered count) and TestClusterStatus_NoDroppedListeners
(asserts the omission at zero, and that the existing stats line survives).
Renaming DescKeyWriteHubDroppedListeners fails the first one.

Docs

  • docs/cli/hub.md — notes the conditional Dropped listeners: line.
  • docs/operations/hub-failure-modes.md — new Slow Listener Disconnected
    entry under Network, explaining that the disconnect is the loss-prevention
    mechanism (the client reconnects with its last-seen sequence and the hub
    replays), what the warning and counter mean, and when a climbing count is
    worth acting on.
  • internal/hub/doc.go — the Concurrency section said "slow subscribers are
    dropped", which read as entry loss; corrected to describe the disconnect
    and point at the counter.

Verification

Ran the CI commands verbatim:

Check Result
CGO_ENABLED=0 go build ./... clean
CGO_ENABLED=0 go test ./... 181 packages, 0 failures
go test -race ./internal/hub/ clean
CGO_ENABLED=0 go vet ./... clean
golangci-lint run 0 issues
hack/lint-docstrings.sh clean

Nothing here touches the shell, PowerShell, OpenCode-plugin, or VS Code
extension surfaces.

@CoderMungan
CoderMungan force-pushed the fix/hub-fanout-drop-observability branch from 1a16152 to a3543b0 Compare August 27, 2026 08:57
The fanout broadcaster disconnects a listener whose buffer is full
rather than dropping its entries, but the only record of it was
f.dropped -- a counter incremented at fanout.go and read nowhere
else in the codebase. Operators had no way to know listeners were
being kicked.

Make the counter observable on two surfaces:

- broadcast() warns on stderr at the moment of disconnect, through
  a new HubFanOutSlowListener format in internal/config/warn, so
  log aggregators can rate the event as it happens.
- StatusResponse gains DroppedListeners, populated from a new
  droppedCount() accessor. ctx hub status prints "Dropped
  listeners: N" only when N > 0, so a healthy hub's output is
  unchanged.

The counter moves to sync/atomic as the acceptance list asks:
broadcast increments with atomic.AddUint64 (its return value is
what the warning reports) and droppedCount reads with
atomic.LoadUint64, so the Status RPC handler never contends with an
in-flight broadcast. Every release target is 64-bit, so the
struct-field alignment caveat for the raw atomic.*Uint64 helpers
does not apply here.

Pin the contract with two tests. TestFanOut_DisconnectsSlowListener
asserts the channel closes, the subscriber is removed from f.subs,
and the counter increments; verified by mutation -- removing the
delete/close block fails it while the original three pass.
TestFanOut_DroppedCountRaceWithBroadcast reads the counter from
four goroutines while broadcast disconnects listeners, so -race
fails if the counter stops being atomic; also verified by mutation.

The rendered status line gets its own pin. desc.Text returns "" for
an unknown key, so a renamed text key would blank the line silently;
TestClusterStatus_DroppedListeners asserts the rendered count and
TestClusterStatus_NoDroppedListeners asserts the omission at zero.
Verified by mutation: renaming the key fails the first.

Leaves fanOutBuffer alone -- tuning it before the counter is
observable would be tuning blind.

Closes ActiveMemory#94

Signed-off-by: CoderMungan <codermungan@gmail.com>
@CoderMungan
CoderMungan force-pushed the fix/hub-fanout-drop-observability branch from a3543b0 to 472b684 Compare August 27, 2026 10:16
@josealekhine

Copy link
Copy Markdown
Member

Hey @CoderMungan ; thanks for the hard work, here are some changes that need to be made.

The observability plumbing this PR adds is well built: the warning
constant follows the HubReplicate* convention, the DescKey +
write.yaml pair is complete, the conditional Dropped listeners:
line keeps a healthy hub's output byte-identical, the StatusResponse
field is additive and wire-compatible, and the atomic counter usage is
correct (writes serialized under f.mu, atomic for cross-goroutine
visibility). The TestMain/lookup.Init() pattern matches the sibling
write/* packages. I reproduced every verification claim in the PR
description: build, hub/write/cli test packages, go test -race ./internal/hub/, and go vet are all clean at head.

The PR cannot merge as-is because the documentation it adds asserts a
recovery story that provably does not exist, and the mechanism it
documents crashes the hub.

I verified all three failure legs empirically against head with a scratch
integration test driving the real listenEntries:

  1. No EOF, silent losslistenEntries
    (internal/hub/handler.go:217) receives with case entries := <-ch
    and never checks the closed state. After broadcast disconnects a
    slow listener, the closed channel is always receivable, so the
    handler drains the 64 buffered slices, then spins on nil forever
    at 100% CPU. The RPC never returns; the client never sees the stream
    end and silently misses every subsequent entry. Test observed the
    handler still running 500 ms after the disconnect.
  2. Delayed daemon crash — when the stream context finally ends
    (client TCP drop, shutdown), defer s.listeners.unsubscribe(ch)
    (handler.go:211) calls close(ch) on the channel broadcast
    already closed → panic: close of closed channel. Confirmed by
    test. grpc.NewServer() is constructed with no recovery interceptor
    (internal/hub/server.go:30), so grpc-go does not recover it: every
    real-world slow-listener disconnect is a pending hub crash.
  3. No client reconnectctx connection listen
    (internal/cli/connection/core/listen/listen.go:59-71) calls
    client.Listen once with sinceSequence hardcoded to 0, and
    Client.Listen returns nil on EOF, so the command would exit 0.
    No backoff/reconnect implementation exists anywhere in the repo
    (grep -ri backoff over Go sources: zero hits), including
    ctx-desktop.

So the new failure-modes entry — "The client sees an EOF and reconnects
with its last-seen sequence… Nothing is lost; the reconnect is the
recovery… What you should do: nothing" — tells operators to ignore
what is actually total silent entry loss for that client followed by a
hub-daemon crash. The counter this PR wires up would tick once and then
the process it lives in would eventually die.

To be fair, the busy-spin, the double-close panic, and the missing reconnect all
predate this PR; but since you are working on these and per anchor CONSTITUTION
we forbid laziness, and we "see it, fix it", and the code and quality belongs to US,
and excuses such as "I didn't do that" doesn't exist, this is your hot potato now :).

More importantly, this PR is the one closing #94, whose entire point was that this
failure path was invisible; documenting it with an invented recovery
story makes the visibility worse than silence. Under this project's own
constitution ("No Broken Windows", "Completion Over Motion"), surfacing
the counter while codifying fiction about what it counts is not
complete.

  • Fix the mechanism here (small): make unsubscribe idempotent
    (guard on f.subs membership before close), have listenEntries
    use entries, ok := <-ch and return a sentinel error on !ok, and
    add a stream-level regression test (I have a ~60-line harness that
    exposes both defects; sketch in the comment on fanout_test.go).
    Client reconnect can then be a fast follow, with the doc section
    softened until it lands.

Other Findings that Need Fixes

  • internal/write/hub/doc.go:35 usage example is stale against the new
    ClusterStatus signature (hack/lint-docstrings.sh doesn't
    type-check examples).
  • logWarn.Warn now performs stderr I/O inside the f.mu critical
    section; a stalled stderr pipe would freeze broadcast, subscribe,
    unsubscribe, and the Status RPC (which takes f.mu via count()).
  • With Go 1.26, dropped atomic.Uint64 would delete the 32-bit
    alignment caveat the PR description spends a paragraph defending.

Review of ActiveMemory#164 showed the disconnect this PR made observable was
itself broken in two ways, so the counter would have ticked once
and then the process holding it would have died.

listenEntries received with `case entries := <-ch` and ignored the
closed state. A closed channel is always receivable, so after
broadcast disconnected a listener the handler drained the buffer
and then spun on nil forever at full CPU: the RPC never returned,
the client never saw the stream end, and it silently missed every
entry published afterwards. It now receives with `entries, live`
and returns errSlowListener -- a package-level ResourceExhausted
sentinel over a new cfgHub.ErrSlowListener -- so the stream ends
with a reason that reaches the client.

unsubscribe closed unconditionally, and every Listen stream runs
it via defer. Once broadcast had already closed that channel, the
deferred close panicked with "close of closed channel"; the gRPC
server is built with no recovery interceptor, so every real
slow-listener disconnect was a pending hub-daemon crash.
Membership in f.subs is now the open/closed record and unsubscribe
is idempotent.

Two smaller review points. logWarn.Warn ran inside the f.mu
critical section, where a stalled stderr pipe would have frozen
subscribe, unsubscribe, every publisher, and the Status RPC (which
takes f.mu via count()); broadcast now splits into a locked
deliver plus an unlocked warn loop. And `dropped` becomes an
atomic.Uint64, which drops the 32-bit alignment caveat the raw
atomic.*Uint64 helpers carried.

Three new tests, each verified by mutation.
TestFanOut_UnsubscribeAfterDisconnect panics without the
membership guard. TestListenEntries_SlowListenerEndsStream drives
the real handler with a stalled send and hangs to its deadline if
the closed-channel check is reverted; it also pins that the
already-buffered entries are delivered before the error.
TestIntegration_SlowListenerReachesClient proves the same contract
over a real gRPC stream: Client.Listen returns ResourceExhausted,
which is what makes `ctx connection listen` exit non-zero instead
of reporting success on a stream it no longer receives.
TestListenEntries_ContextCancelEndsStream keeps the clean shutdown
path pinned.

Docs follow the mechanism rather than an invented recovery. The
Slow Listener entry no longer claims the client "sees an EOF and
reconnects"; reconnect is manual, and the failure-modes doc says
so and names the duplicate-append consequence of the hardcoded
sinceSequence=0. Also corrected while here, because the new text
contradicted them: three claims across hub.md,
hub-failure-modes.md and render/doc.go that the hub or store
"deduplicates by entry ID" -- Store.Append assigns a new sequence
unconditionally and render.appendShared appends without inspecting
what is there. render/doc.go's WriteEntries example was stale
against its signature, as internal/write/hub/doc.go's
ClusterStatus example was.

Spec: specs/fix-hub-fanout-drop-observability.md
Signed-off-by: CoderMungan <codermungan@gmail.com>
@CoderMungan

Copy link
Copy Markdown
Member Author

Thanks for the review @josealekhine — you were right on all three
legs, and the framing was the useful part: the counter would have
ticked once and then the process holding it would have died. Fixed
here in cf2a541e, along with a spec
(specs/fix-hub-fanout-drop-observability.md) that the first commit
was missing.

The mechanism

1. No EOF, silent loss. listenEntries now receives with
entries, live := <-ch and returns errSlowListener on !live
a package-level status.Error(codes.ResourceExhausted, ...) sentinel
backed by a new cfgHub.ErrSlowListener, so the stream ends with a
reason that travels to the client instead of spinning on nil. I
put the sentinel in internal/hub/err_check.go rather than
internal/err/hub: the message is wire-visible, which is the case
internal/config/hub's doc already describes for
ErrInvalidAdminToken and friends, and a *status.Error keeps the
code on the wire while still matching under errors.Is.

2. Delayed daemon crash. unsubscribe is idempotent. Membership
in f.subs is now the open/closed record for each channel: a channel
already gone from the map has already been closed and is left alone,
so the disconnect and the stream's deferred unsubscribe can run in
either order. I went with the guard rather than a recovery
interceptor on purpose — containing the panic isn't the same as not
having one, and a server-wide interceptor is a bigger decision than a
fan-out fix. Noted in the spec's Out of Scope.

3. No client reconnect. Not implemented — taking you up on the
fast follow. What I did do is stop the docs from asserting it. The
Slow Listener entry now says what actually happens, and the existing
"Client Loses Connection Mid-Stream" entry above it claimed the same
exponential-backoff reconnect, so I corrected that one too rather than
leave the file contradicting itself. It now names the consequence of
listen asking for sinceSequence: 0: the re-run appends its backlog
to .context/hub/ a second time.

Stream-level regression test

TestListenEntries_SlowListenerEndsStream drives the real handler
with a stalled send, and covers both defects at once — the deferred
unsubscribe runs on the way out, so the double close is on the same
path. Reverting case entries, live := <-ch hangs it to its deadline;
removing the membership guard panics.

TestIntegration_SlowListenerReachesClient does it over a real gRPC
stream, because leg 3's real cost was client-side: a client whose
handler stalls saturates the stream window, gets disconnected
server-side, and Client.Listen now returns ResourceExhausted. That
is what makes ctx connection listen exit non-zero rather than report
success on a stream it is no longer receiving. Same mutation hangs it
for 30s.

Plus TestFanOut_UnsubscribeAfterDisconnect (panics without the
guard) and TestListenEntries_ContextCancelEndsStream to keep the
clean shutdown pinned.

Your other three

  • internal/write/hub/doc.go:35 — fixed. While chasing it I found
    the same class in internal/cli/connection/core/render/doc.go:
    WriteEntries(dir, entries) has no dir parameter, and the
    paragraph claimed the function is "idempotent by entry sequence
    number" when it appends unconditionally — the tracking lives in
    core/sync, and listen never touches it. Corrected.
  • logWarn.Warn under f.mubroadcast now splits into a
    locked deliver that returns the post-increment count per
    disconnect, and an unlocked warn loop. Nothing writes to stderr
    while holding the mutex.
  • atomic.Uint64 — done; the alignment paragraph is gone from the
    description.

One thing I fixed that you didn't ask for

Three places claimed the hub or store "deduplicates by entry ID"
(docs/operations/hub.md:151, the failure-modes quick-reference table,
and the --share backfill advice). Store.Append assigns a new
sequence unconditionally and render.appendShared appends without
inspecting what is already in the file — there is no dedup anywhere.
My new text about duplicate appends contradicted those lines head-on,
so leaving them would have made the file argue with itself. Happy to
split them out if you'd rather keep this PR to the fan-out.

Verification

Check Result
CGO_ENABLED=0 go build ./... clean
CGO_ENABLED=0 go test ./... 0 failures
go test -race ./internal/hub/ clean, also at -count=5
CGO_ENABLED=0 go vet ./... clean
golangci-lint run (v2.11.4) 0 issues
hack/lint-docstrings.sh clean
hack/lint-drift.sh / lint-mixed-funcs.sh clean
hack/lint-imports.sh no new findings vs. base

Still nothing here touching the shell, PowerShell, OpenCode-plugin, or
VS Code extension surfaces.

@josealekhine
josealekhine merged commit 05e0bd1 into ActiveMemory:main Sep 8, 2026
7 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.

Hub fanout: drop-counter is dead, disconnect path untested, buffer size unconfigurable

2 participants