Skip to content

chainsource: escalate unrecoverable block epoch loss to the daemon - #762

Merged
bhandras merged 3 commits into
mainfrom
fail-fast-block-epoch-loss
Nov 2, 2026
Merged

bhandras merged 3 commits into
mainfrom
fail-fast-block-epoch-loss

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Jun 18, 2026 •

Copy link
Copy Markdown
Member

The problem

The wallet daemon uses block notifications to advance chain-dependent work. If its backend repeatedly closes the notification stream or rejects reconnect attempts, the daemon currently retries forever. Its RPC listener can stay available while block consumers make no progress. Restarting the process can recover by constructing a fresh backend connection.

The fix

Track continuous failure at both reconnect boundaries: the shared LND upstream reader and each chainsource subscription. The shared reader normally keeps subscriber queues open while it reconnects, so its own failure budget is essential to detect a sustained LND outage.

Only a delivered block with a changed height or hash clears an outage. Repeated registration, or replaying the same cached tip before closing again, does not count as recovery. Reorg replacements still count as progress and all epochs continue to be forwarded. The failure budget pauses while a stream is open. A long Bitcoin block interval therefore cannot turn a later transient stream closure into a fatal error.

After a five-minute outage budget plus up to one minute of jitter, the daemon's fatal callback cancels its run context. Escalation is checked between reconnect attempts. The normal shutdown path drains services and returns the fatal cause, causing the command to exit nonzero so a supervisor can restart it. The fatal marker preserves that outcome even when the backend error wraps context.Canceled or cancellation interrupts startup.

flowchart LR
    A[Open stream; budget paused] -->|Closes| B[Retry; budget runs]
    B -->|Reopens| A
    A -->|New block identity| C[Clear spent retry time]
    C --> A
    B -->|Budget expires with fatal hook| D[Cancel daemon and fail health]
    D --> E[Drain services and exit nonzero]
Loading

Safety rule

A daemon must not remain alive forever against continuously closed or failing block streams. Transient reconnection still recovers in process. Callers without a fatal callback keep their existing indefinite retry contract. Normal cancellation wins over a later fatal report.

Health and scope

The daemon’s existing separate, opt-in health listener serves /livez and /readyz. Both immediately fail when the run context is cancelled. Fatal escalation uses that same context, so health withdrawal follows the shutdown decision directly.

Open but silent streams remain a separate follow-up. No Bitcoin inter-block idle deadline is introduced: a long interval without a block does not prove backend failure. Initial registration errors still return directly to the subscriber.

Tests

  • Repeated immediately closed streams, including streams that replay the same tip, exhaust the budget and escalate exactly once.
  • Repeated registration errors exhaust the same budget and preserve the backend cause.
  • The real notifier adapter’s shared reader escalates raw upstream failures while its subscriber queues remain open during reconnects. Hookless notifiers keep those queues alive and resume delivery.
  • Hookless subscriptions continue retrying past the fatal budget.
  • A changed block identity resets the outage budget; duplicate tip replays do not. Same-height reorg replacements count as recovery. Open time is paused even when only a duplicate tip was delivered; a later transient close does not turn a long Bitcoin block interval into a fatal error.
  • Cancellation while reconnecting exits without fatal escalation.
  • A real in-process daemon serves gRPC, then drains on fatal or ordinary cancellation. Fatal errors, including wrapped cancellation, return as errors; ordinary cancellation followed by a fatal report returns cleanly. Both health endpoints withdraw immediately.

Local validation:

  • Focused daemon/chainsource/health tests: passed with dev nolog build tags.
  • Targeted race tests for block subscriptions, daemon shutdown, and health: passed.
  • make unit pkg=chainsource log="stdlog trace" timeout=5m: passed.
  • Real-LND TestLNDBlockFanoutReorg: passed with SQLite and PostgreSQL.
  • Module tidiness: no changes. Formatting and changed-code lint are checked before publication.
  • Shared-hub epoch recovery tests and notifier adapter unit tests cover the current LND path.
  • Go documentation audit: no new missing comments relative to the base.

Published head: 6a86b0d, rebased onto main at ccc97b2.

Rebase validation passed: focused race tests for chainsource, the LND hub, daemon exit/health/cancellation, the upstream authorization expiry test, and cancellation cleanup; changed-code lint including actorblock; formatting, module tidiness, and commit-message checks. Gateway reviewed this exact head: no findings, then approved. Fresh full CI, including the race suite, and Mobile Bindings passed. Final check summary: 25 passed, 7 skipped, no failures. All three published commits are GitHub Verified. GitHub still requires a qualifying maintainer approval; the Gateway App approval does not satisfy that gate.

The runtime patches are unchanged by the rebase. The expiry-test scheduling fix is now supplied by main. The remaining test-only repair isolates the process-wide goroutine-count assertion from parallel sibling HTTP workers, preserving its threshold, map cleanup assertions, and repeated-cancellation checks.

Compatibility and rollout

No RPC, wire-format, or database migration changes. Hookless chainsource and notifier consumers retain indefinite retries. The daemon opts into escalation, including when hosted through RunWithContext. Embedded daemon hosts must handle its terminal error, or the SDK’s Client.Wait() result, and create a fresh runtime when recovery is required. Making this policy separately configurable for embedded daemons is a bounded follow-up. A deployment of the merged source and supervision configured to restart failed processes are required to activate recovery. Production deployment is outside this PR.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a fatal timeout mechanism for block epoch subscriptions to prevent the daemon from silently spinning when a backend connection is stuck. If a subscription remains down past the fatal timeout, the actor escalates the failure via an OnFatal callback, which triggers a non-zero exit of the daemon so it can be restarted. A potential issue was identified where a normal shutdown or unsubscribe could trigger a spurious fatal escalation if the subscription had already exceeded the timeout; checking for context cancellation before escalating resolves this.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread chainsource/block_epoch_actor.go Resolved

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b19d79ce9b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread chainsource/block_epoch_actor.go Resolved
@levmi levmi added the P0 Priority 0 — blocker label Jun 22, 2026
@Roasbeef
Roasbeef force-pushed the fail-fast-block-epoch-loss branch from 176ace8 to d6fbb56 Compare June 23, 2026 21:21
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 2m 44s —— View job


Review: chainsource: escalate unrecoverable block epoch loss to the daemon

I read the full diff across block_epoch_actor.go, chainsource.go, server.go, and the gateway/health changes. This is a well-motivated, carefully-built fix — the down-streak tracking (stamp on first loss, clear only on a delivered block) correctly defeats the "storming backend hands back streams that immediately close" false-healthy case, the once-sampled jitter is the right call for fleet de-correlation, and bubbling the failure through context.CancelCause instead of os.Exit keeps the shutdown path clean. The health/readiness backstop is a sensible companion. Comments are excellent.

Two correctness issues stand out, one of which I'd treat as blocking.


🔴 1. Without an OnFatal hook, the subscription is silently killed instead of retried (agree with Codex)

In monitorBlocks (chainsource/block_epoch_actor.go:430-438):

if a.reconnectExhausted(downSince, fatalTimeout) {
    a.escalateFatal(log, downSince, lastErr)
    return   // <-- exits the monitor unconditionally
}

escalateFatal is a no-op when OnFatal is None, but the return fires regardless. So for any caller that doesn't install the hook, a >5m backend outage now permanently closes the block subscription (and the iterator channel) rather than retrying forever as before #698. This is a behavioral regression, and it's reachable today:

  • systest/systest.go:209 builds ChainSourceConfig with no OnFatal — confirmed.
  • SDK/embedded and external BlockEpochConfig consumers, since the hook is documented as optional.

The daemon path is fine (server.go:1925 wires fn.Some(s.signalFatal)), but the actor shouldn't degrade into a silent dead-subscription for everyone else. Only take the fatal return when a hook is actually installed; otherwise keep retrying (preserving the old unbounded-retry contract):

if a.reconnectExhausted(downSince, fatalTimeout) &&
    a.cfg.OnFatal.IsSome() {

    a.escalateFatal(log, downSince, lastErr)
    return
}

Either that, or make OnFatal required for bounded retries and document it. Fix this →


🟠 2. Spurious fatal escalation during shutdown (agree with Gemini)

At block_epoch_actor.go:430, reconnectExhausted is evaluated before any check of a.ctx.Err() (that check only lives inside waitForReconnect, one step later). If the actor is stopped (Stop/OnStop → a.cancel()) while the subscription is already down past the budget, the loop escalates on the way out.

Worth flagging that this is slightly worse than Gemini described: signalFatal unconditionally does s.fatalFlag.Store(true) (server.go:1254) before the fatalCancel call. Even though fatalCancel(err) is a no-op once the run ctx is already cancelled (first cause wins), the latched fatalFlag flips LivenessCheck/ReadinessCheck to failing — so a clean shutdown that happened to coincide with a long outage reports a fatal failure it didn't really have. Cheap guard:

if registration == nil {
    if a.ctx.Err() != nil {
        return
    }
    if a.reconnectExhausted(downSince, fatalTimeout) ... {

Fix this →


🟡 3. A stream that opens but never delivers (and never closes) won't escalate

reconnectExhausted is only consulted while registration == nil (the reconnect loop). If a reconnect succeeds but the new stream then stays open and silent forever, the goroutine blocks in the select on registration.Epochs (block_epoch_actor.go:464). downSince (stamped at the prior close) is never cleared — no block was delivered — but it's also never re-checked, so the fatal budget never fires. This is narrower than the symptom you fixed (closing streams), and a healthy backend delivers blocks well within the budget, so it may be acceptable — but it's a gap in the "continuously down" guarantee worth a sentence in the comment, or a bounded wait on the open-but-idle stream if you want full coverage.


Nits / observations (non-blocking)

  • server.go:1253 signalFatal concurrency: fatalCancel is written once in run() before any subsystem starts and read from the actor goroutine afterwards, so the happens-before holds and the no-mutex read is fine. The doc comment ("safe to call from any subsystem goroutine") is accurate. Just confirming it isn't a data race.
  • fatalReconnectTimeout jitter on the explicit-timeout path: the design (auto-jitter only on the default path, exact on an explicit timeout) is reasonable and well-documented, and production uses the default path (the daemon never sets FatalReconnectTimeout), so production gets jitter. 👍
  • Gateway health routes: in-process-only checks, %q-escaped reason, 503 on failure, warn-and-skip when checks are unavailable — all good. The unauthenticated-but-no-backend-I/O reasoning is sound.
  • Test coverage: TestBlockEpochActorEscalatesWhenBackendStaysDown and the jitter test are clean and clock-driven. Consider adding a case asserting the no-OnFatal behavior once lib: start adding lib helpers #1 is resolved (subscription keeps retrying rather than exiting), and one for the shutdown-during-down race in multI: add initial repo scaffolding #2.

Overall: solid, the architecture is right. I'd block on #1 (silent subscription death for hookless callers) and fold in #2 while you're there; #3 is a judgment call.

  • Read the PR diff and changed files
  • Analyze block_epoch_actor.go fatal reconnect logic
  • Analyze config wiring (chainsource.go, server.go, gateway)
  • Review tests
  • Evaluate prior reviewer feedback (Gemini, Codex)
  • Post review
    · fail-fast-block-epoch-loss

@Roasbeef
Roasbeef force-pushed the fail-fast-block-epoch-loss branch from d6fbb56 to 54f7075 Compare June 23, 2026 21:29
@Roasbeef

Copy link
Copy Markdown
Member Author

Thanks — addressed the two correctness issues, folded into the existing commits (force-pushed 54f70758).

#1 (🔴 silent subscription death without OnFatal) — fixed. The fatal return is now gated on a.cfg.OnFatal.IsSome(): only callers that opted into bounded escalation take the exit path; hookless callers (systest, SDK/embedded) keep the original unbounded-retry contract instead of having the subscription silently killed after the budget. New test TestBlockEpochActorRetriesWithoutOnFatal asserts a hookless actor against a permanently-dead backend keeps reconnecting (attempt count climbs well past where an escalating actor would have stopped) rather than exiting.

#2 (🟠 spurious escalation on shutdown) — fixed. Added an a.ctx.Err() != nil early-return at the top of the registration == nil block, before reconnectExhausted, so a stop that coincides with a long outage exits cleanly and never latches fatalFlag (which would otherwise flip liveness/readiness to failing). I didn't add a dedicated unit test for this one: the trigger is an inherent goroutine-timing boundary (cancellation observed precisely between the loop top and the exhaustion check), so any test would be racy/flaky; the guard itself is a cheap, obviously-correct ordering check.

#3 (🟡 open-but-silent stream) — documented, not implemented. Agreed it's narrower than the closing-stream symptom this PR targets, and downSince is only ever stamped on a close, so a healthy backend (which delivers blocks well within the budget) is unaffected. Added a comment on the downSince/budget block noting that an open stream that goes silent without closing is out of scope. Happy to add a bounded idle-wait on the open stream in a follow-up if you'd prefer full coverage.

Nits confirmed (no change needed): the fatalCancel happens-before is sound, and the jitter-on-default-path design is intentional. go test -race ./chainsource/... ./darepod/... green; make lint-changed-local and commitmsg-lint clean.

@levmi levmi added daemon safety Fund-safety: stuck, lost, or mis-counted funds labels Jul 6, 2026
@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

10 similar comments
@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

@bhandras
bhandras force-pushed the fail-fast-block-epoch-loss branch from 54f7075 to 9705d04 Compare November 2, 2026 15:50
@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway review high

Review the complete final diff of PR #762, base 3b7ed47, exact published head 9705d04. This is a full review, not just a review of the rebase edits. Verify the head before reviewing.

Purpose: after continuously closed or failing block-epoch streams, the daemon must exit through its normal cleanup path with a nonzero result so supervision can construct a fresh backend. Transient reconnects must still recover. Hookless chainsource users must retain indefinite retries. Ordinary cancellation must not become fatal. An open but silent Bitcoin stream is outside this change; inter-block time is not backend health evidence.

Changes since the old reviews: rebuilt only the original two-commit scope onto current main. Preserve current finality and subscriber lifecycle behavior. The old gateway probe additions are superseded by main's existing /livez and /readyz context-based health listener. Fatal escalation now marks the cancel cause explicitly and the outer run wrapper returns it even if startup returned a wrapped cancellation error. No independent fatal-health latch remains.

Independently verify these paths:

  1. A backend repeatedly returns immediately closed streams: the first loss starts the outage budget; registering again must not reset it; escalation is bounded and invoked once.
  2. Reconnect registration repeatedly fails: retain the last backend error, bound escalation, and release registration resources.
  3. A delivered block resets the outage streak; a transient loss reconnects and resumes delivery. Open, idle streams must not acquire an idle deadline.
  4. No OnFatal hook: retries must remain alive past the configured budget.
  5. Actor cancellation during an expired outage exits cleanly. Parent cancellation before fatal escalation remains clean. Fatal errors wrapping context.Canceled still cause nonzero daemon return. Verify startup and post-startup propagation, orderly cleanup, and existing health withdrawal.
  6. Callback/context lifetime and data-race safety: fatalCancel is published before subsystem goroutines, and notification lifetimes remain owned by their subscription, not by the initiating RPC.

For each finding provide exact preconditions, production execution trace and file:line evidence; whether this diff introduces or materially worsens it; earlier guards, producer contracts and later recovery; consequence and severity; realistic likelihood; smallest regression test; smallest useful fix and its regression risk. Classify as BLOCKER, FOLLOW-UP, or NON-ACTIONABLE. Prefer the owning boundary and compare defect risk with change risk. Do not expand scope for speculative invalid states, unchanged debt, optional cleanup, or an inter-block idle deadline. Re-raise prior findings only with new evidence against this exact head.

Validation: Focused dev/nolog daemon, block-subscription and health tests passed; targeted race tests passed; chainsource debug-log unit tests passed; real-LND block fanout/reorg/cancellation integration passed on SQLite and PostgreSQL; make lint-changed-local including actorblock passed with zero issues; make fmt-changed-check, make tidy-module-check and commit-message lint passed. Documentation audit has zero new findings (16 unchanged baseline comments). GitHub full-matrix CI has just been triggered on this published head; inspect current checks before the verdict.

Give an explicit verdict: BLOCKED only for a concrete reachable correctness, funds-safety, security or severe reliability defect introduced or worsened here; READY WITH FOLLOW-UPS for bounded useful improvements; READY if no material concern remains. Stop once the intended invariants are proven and no concrete blocker remains.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 2 findings

🔴 0 Blocker · 🟠 0 Major · 🟡 2 Minor · 🔵 0 Nit

Summary

This PR makes the daemon exit nonzero when a block-epoch subscription stays down past a budget of 5 minutes plus up to 1 minute of jitter. Chainsource users that don't install a hook keep retrying forever. I traced the six paths you listed against head 9705d045 and each one holds:

  1. Closed streams. downSince is stamped only on the first close (chainsource/block_epoch_actor.go:493-495). A successful re-registration doesn't clear it, and the return after escalateFatal means escalation fires at most once per actor. When dozens of actors escalate, CancelCauseFunc keeps the first cause and ignores the rest.
  2. Registration errors. lastErr keeps the backend cause and is wrapped with %w. A failed registration leaves registration nil, so nothing leaks.
  3. Recovery. A delivered block clears the streak (:512-513). An open, idle stream never reaches the exhaustion check, which is intended.
  4. No hook. The OnFatal.IsSome() gate preserves indefinite retry for hookless callers.
  5. Cancellation. The a.ctx.Err() guard runs before the exhaustion check. If the parent is cancelled first, the derived context's cause is already context.Canceled, so a later signalFatal does nothing and run() returns normally. The errFatalSubsystem marker check still returns the fatal cause when the backend error wraps context.Canceled and runInner returns early. Health withdrawal follows directly because runCtx is now the derived context.
  6. Lifetimes and races. fatalCancel is written before runInner starts any goroutine. The actor's context still derives from context.Background(), not from the RPC that started the subscription. The test fixtures' shared state (registrations, steppedClock) is ordered by goroutine start and wg.Wait.

I found no blocking defect. Two follow-ups:

  • A backend that replays the current tip on every re-registration resets the outage counter.
  • The hook is wired for every daemon instance, including embedded ones, which have no supervisor to restart them.

I can't see the GitHub checks from this context; confirm them separately. Verdict: READY WITH FOLLOW-UPS.

Lower-severity findings (2)

🟡 F1 (Minor) — Re-delivered tip block resets the outage budget · chainsource/block_epoch_actor.go:512

Any delivered epoch clears downSince, including one whose height and hash the actor already forwarded. A backend that sends its current best block on every new registration and then closes the stream again will never escalate; this is a major if the lnd backend does that. LND's block-epoch registration with no best block does dispatch the current tip. Whether lndbackend passes that replay through or dedupes it depends on code not in this context.

Precondition: a flapping notifier that accepts a registration, sends the tip, and closes shortly after. Each cycle sets downSince = time.Time{} at this line, so reconnectExhausted never passes the budget. That is the "registration that immediately closes must not reset the budget" invariant from the PR description, broken by one replayed epoch. The diff introduces the reset rule; before this PR there was no budget at all. Likelihood depends on the backend's replay behaviour and is unverified here.

Smallest regression test: extend TestBlockEpochActorEscalatesWhenBackendStaysDown with a backend that sends one fixed-height epoch on each registration and then closes; assert that OnFatal still fires.

Smallest fix: track the last forwarded (height, hash). Only clear downSince when the epoch differs from it, or when the stream has stayed open for some minimum time. The regression risk is low because the forwarding path doesn't change.

🟡 F2 (Minor) — Embedded daemons inherit escalation with no supervisor · waved/server.go:2563

The hook is wired for every wallet type and for every RunWithContext embedder, including SDK hosts. For those hosts, a 5–6 minute backend outage now stops the in-process daemon, where it used to heal in place. The rollout note assumes a supervisor restarts the process, which doesn't hold for an embedded runtime. Whether Esplora or Neutrino backends close streams or fail registrations while the network is down isn't visible in this context. Consider a config knob, or leaving the hook off when the daemon is embedded, as a follow-up.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

@lightninglabs-gateway

lightninglabs-gateway Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway re-review high

Review the complete final diff of PR #762, base 3b7ed47, exact published head 0227c36. This is a full review, not just a review of the rebase edits. Verify the head before reviewing.

Purpose: after continuously closed or failing block-epoch streams, the daemon must exit through its normal cleanup path with a nonzero result so supervision can construct a fresh backend. Transient reconnects must still recover. Hookless chainsource users must retain indefinite retries. Ordinary cancellation must not become fatal. An open but silent Bitcoin stream is outside this change; inter-block time is not backend health evidence.

Changes since the prior Gateway review at 9705d04:

  • F1 (same-tip replay resets the budget) is fixed at both layers: unchanged height/hash preserves spent retry time; a changed height or hash clears it. Epoch forwarding itself remains unchanged, including reorgs.
  • Independently tracing current main exposed a necessary owning-boundary fix: LNDBackend.RegisterBlocks calls LndClientChainNotifier, whose blockEpochHub keeps subscriber queues open while its raw stream reconnects. A chainsource timeout alone could not see that failure. The daemon now installs the same fatal callback in the shared hub. Raw upstream errors/closed streams and failed registration attempts therefore escalate once from the reader after its budget. Hookless notifiers keep retrying indefinitely with queues intact.
  • Both layers pause the failure budget while a stream is open. This is necessary because Bitcoin can have a long block interval: a later benign stream close must not count the preceding healthy open interval as downtime. Tests cover a duplicate tip followed by a long open interval, then further failed retries; no idle deadline or minimum block cadence is introduced.
  • F2 (embedded daemon lifecycle policy) is documented as a bounded follow-up. Raw notifier/chainsource consumers without OnFatal retain their contract. Full waved daemons, including RunWithContext embedders, opt into escalation; the host receives the terminal error through RunWithContext or wavewalletdk.Client.Wait and owns recreation. See sdk/wavewalletdk/embedded.go:175-180 and sdk/wavewalletdk/client.go:140-147. No additional embed configuration redesign is included.

Trace the complete final PR diff, not only the last commit. In particular independently trace waved.initChainBackend -> LndClientChainNotifierConfig.OnFatal -> blockEpochHub.runUpstream/reconnect -> Server.signalFatal -> explicit fatal cancel cause -> Server.run returning that error after cleanup -> cmd/waved nonzero exit. Verify that repeated tip replays cannot reset spent retry time, while open time is excluded and same-height reorg replacements reset it.

Independently verify these paths:

  1. A backend repeatedly returns immediately closed streams: the first loss starts the outage budget; registering again must not reset it; escalation is bounded and invoked once.
  2. Reconnect registration repeatedly fails: retain the last backend error, bound escalation, and release registration resources.
  3. A delivered block resets the outage streak; a transient loss reconnects and resumes delivery. Open, idle streams must not acquire an idle deadline.
  4. No OnFatal hook: retries must remain alive past the configured budget.
  5. Actor cancellation during an expired outage exits cleanly. Parent cancellation before fatal escalation remains clean. Fatal errors wrapping context.Canceled still cause nonzero daemon return. Verify startup and post-startup propagation, orderly cleanup, and existing health withdrawal.
  6. Callback/context lifetime and data-race safety: fatalCancel is published before subsystem goroutines, and notification lifetimes remain owned by their subscription, not by the initiating RPC.

For each finding provide exact preconditions, production execution trace and file:line evidence; whether this diff introduces or materially worsens it; earlier guards, producer contracts and later recovery; consequence and severity; realistic likelihood; smallest regression test; smallest useful fix and its regression risk. Classify as BLOCKER, FOLLOW-UP, or NON-ACTIONABLE. Prefer the owning boundary and compare defect risk with change risk. Do not expand scope for speculative invalid states, unchanged debt, optional cleanup, or an inter-block idle deadline. Re-raise prior findings only with new evidence against this exact head.

Validation: Final local checks passed: complete chainsource and chainbackends unit suites; targeted race tests for both block-epoch layers, real in-process daemon exit/cancellation, and health; changed-code lint with zero issues including actorblock; fmt/module/commit-message checks. The real-LND fanout test passed SQLite/PostgreSQL before the hub adaptation and is being rerun on the exact final head. Full GitHub CI has been refreshed for this new head; inspect its results separately. Both commits are GitHub Verified; original authorship is preserved.

Give an explicit verdict: BLOCKED only for a concrete reachable correctness, funds-safety, security or severe reliability defect introduced or worsened here; READY WITH FOLLOW-UPS for bounded useful improvements; READY if no material concern remains. Stop once the intended invariants are proven and no concrete blocker remains.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 2 findings

🔴 0 Blocker · 🟠 0 Major · 🟡 2 Minor · 🔵 0 Nit

Summary

Verdict: READY WITH FOLLOW-UPS. I checked the requested invariants against head 0227c360 and found no blocker. I could not inspect the GitHub CI results from this context, so that check is still open.

Fatal path for LND-backed daemons:

  1. initChainBackend sets OnFatal: fn.Some(s.signalFatal) (waved/server.go:2651-2652). NewLndClientChainNotifier copies it into epochs.onFatal (chainbackends/lndclient_adapters.go:145).
  2. runUpstream notes the first loss and calls reconnect (chainbackends/block_epoch_hub.go:445-457).
  3. Before each retry, reconnect checks ctx.Err() first and then the fatal budget. It calls onFatal once, wrapping lastErr, and returns nil (block_epoch_hub.go:491-503).
  4. signalFatal cancels the run context with errors.Join(errFatalSubsystem, err).
  5. run replaces runErr with that cause whenever context.Cause(ctx) matches errFatalSubsystem, even if runInner returned a wrapped context.Canceled (server.go:1193-1195).

Each requested path checks out:

  • Repeated closed streams (path 1). Escalation is bounded and happens once. On both layers, a successful re-registration only adds its open time to downSince. It never clears downSince.
  • Repeated registration failures (path 2). The last backend error is kept in lastErr on both layers. Failed hub opens cancel their per-attempt context inside openBlockEpochStream.
  • Recovery and idle streams (path 3). A changed height or hash clears the budget, so same-height reorg replacements count as recovery. Open time is excluded from the budget. Nothing checks the budget while a stream is open, so idle streams get no deadline.
  • No hook (path 4). Retries continue indefinitely: the hub checks h.onFatal != nil and the actor checks OnFatal.IsSome().
  • Cancellation (path 5). Cancellation wins at both layers because it is checked before the budget. A parent cancel that lands first sets cause context.Canceled, so run does not treat it as fatal.
  • Publication and races (path 6). fatalCancel is set before runInner. RPCAddr's mutex gives the test the required happens-before ordering. Notification lifetimes stay owned by the hub and actor contexts.

I traced both budget-reset tables by hand and they match the code.

Lower-severity findings (2)

🟡 F3 (Minor) — Docs say subscribers close at shutdown; they close at escalation · chainbackends/AGENTS.md:111

The new invariant text says "subscriber closure follows shutdown", but subscribers actually close at escalation. After onFatal, reconnect returns nil and runUpstream returns. Its deferred endUpstream then immediately closes every registration and clears h.up (block_epoch_hub.go:573-590). Each chainsource actor sees its channel close, starts its own downSince, and re-registers, which opens a fresh hub generation against the failing lnd while the daemon drains. This is harmless today because the hub still opens one shared stream and shutdown is bounded, but the doc misstates it. The same text is in CLAUDE.md:111. FOLLOW-UP: reword to "the shared reader escalates once, exits, and closes its registrations; consumers may re-register while shutdown proceeds."

🟡 F2 (Minor) · waved/server.go:2563 · unresolved

Still present, and now also wired at the shared hub (server.go:2651-2652). Every full waved runtime, including RunWithContext embedders, ends its run after roughly 5–6 minutes of sustained block-stream failure instead of healing in place. The PR description and the request now record this as an intentional, bounded follow-up: the host receives the terminal error and recreates the runtime. FOLLOW-UP, not a blocker.


Status of prior findings

  • F1 addressed: Fixed at both layers. The actor clears downSince only when !haveEpoch or the height or hash differs from lastEpoch (chainsource/block_epoch_actor.go:520-532). The hub clears it only when cursor == nil or the height or hash changed (chainbackends/block_epoch_hub.go:466-472). The hub's cached-tip seed in addSubLocked is now a duplicate and no longer resets the budget. The "replayed tip" cases in TestBlockEpochActorEscalatesWhenBackendStaysDown and TestBlockEpochHubFatalRecovery cover this.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway promote F2

Track this as a bounded follow-up for explicit embedded-daemon recovery policy. Full waved runtimes currently return the fatal error through RunWithContext or wavewalletdk.Client.Wait; hosts own recreating the runtime. Hookless notifier and chainsource consumers retain indefinite retries. The present PR documents that boundary and needs no further runtime redesign to fix sustained stream failure.

@lightninglabs-gateway

Copy link
Copy Markdown

📌 Promoted F2 (minor) → #1505 by @bhandras — tracked separately, no longer flagged on this PR.

Open findings on this PR: 🟡 F3 (minor)

@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway dismiss F3 Documentation wording ambiguity only: the hook signals shutdown before the reader closes registrations. The runtime ordering is correct and bounded; leave the copyedit as a follow-up.

Classification: FOLLOW-UP. The shared reader calls the fatal hook, exits, and closes its registrations while daemon cleanup proceeds. Re-registration during that interval remains bounded by shared-stream ownership and normal shutdown. This is a wording clarification, with no reachable runtime defect to fix in this PR. No product changes are needed for it.

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F3 (minor) by @bhandras — Documentation wording ambiguity only: the hook signals shutdown before the reader closes registrations. The runtime ordering is correct and bounded; leave the copyedit as a follow-up.

All findings on this PR are addressed or dismissed.

@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway approve

The complete final diff at 0227c36 has completed Gateway review with no blocker or major finding. F1 is addressed. F2 is tracked in #1505, and F3 has an explicit documentation-only disposition. No code changed after that review. Full CI is still running; merge readiness remains gated on its required checks and both system-test backends.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — all prior findings addressed.

Findings recap

Findings:

  • F1 (minor) — addressed: Any delivered epoch clears downSince, including one whose height and hash the actor already forwarded. A backend that sends its current be…
  • F2 (minor) — unresolved: The hook is wired for every wallet type and for every RunWithContext embedder, including SDK hosts. For those hosts, a 5–6 minute backend …
  • F3 (minor) — unresolved: The new invariant text says "subscriber closure follows shutdown", but subscribers actually close at escalation. After onFatal, `reconnect…

Dismissed:

  • F2 by @bhandras — promoted to #1505
  • F3 by @bhandras — Documentation wording ambiguity only: the hook signals shutdown before the reader closes registrations. The runtime ordering is correct and bounded; leave the copyedit as a follow-up.

Approved by @bhandras via /gateway approve. Last reviewed at 0227c36. Skill v0.6.0, model claude-opus-5-5.

@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway re-review high

Review the complete final PR #762 diff, base 3b7ed47, exact head e6f5e5b. This is a full review of the final combined diff, including prior production changes, rather than only a review of the last two-file patch. Verify the head before reviewing.

The prior full review at 0227c36 confirmed all six stream-recovery, cancellation, health and ownership paths. The runtime implementation is unchanged since that review. Only two test scheduling assumptions changed, in a separate signed CI repair commit:

  1. TestSignCreditAccountAuthorizationValidatesEnvelope creates expiry before parallel children run. On the unchanged base and on this PR's full race job, a child queued behind wallet setup for over the one-minute margin. The too-far expiry then passed validation and reached an empty RPCServer, returning Internal rather than the expected InvalidArgument. Run these four cheap cases sequentially while keeping the parent parallel.
  2. TestChainBackendCancelCleanup uses a process-wide goroutine count but also ran in parallel with siblings that create HTTP workers. Coverage failed with baseline54/current94. Isolate this assertion from parallel package tests. Its threshold, map cleanup assertions and double-Cancel checks remain unchanged.

Evidence: base main race failure https://github.com/lightninglabs/wavelength/actions/runs/41357558903/job/125000785555 and matching PR race failure https://github.com/lightninglabs/wavelength/actions/runs/41479228527/job/125393466619 . The PR coverage failure was https://github.com/lightninglabs/wavelength/actions/runs/41479228527/job/125393466705 . No product code was changed to mask these test failures.

Purpose and invariants: continuously closed or failing block streams must eventually cancel the daemon and return a fatal error through normal cleanup, so supervision can restart a fresh backend. Both the generic actor and the shared LND raw-stream hub own their reconnect budget. No-hook consumers retry indefinitely. A new height/hash clears spent retry time; replaying the same tip does not. Open time is paused so a long healthy Bitcoin block interval cannot cause a false fatal at the next close. An open but silent stream has no idle deadline. Parent cancellation first stays clean; a fatal backend error wrapping context.Canceled still returns nonzero. Existing /livez and /readyz withdraw on run-context cancellation.

Independently verify the complete final owning-boundary path: waved.initChainBackend -> LndClientChainNotifierConfig.OnFatal -> blockEpochHub.runUpstream/reconnect -> Server.signalFatal -> explicit fatal cancel cause -> Server.run returns that error after cleanup -> command exits nonzero. Verify closed/replayed streams, failed registrations, progress/reorg resetting budget, long open intervals, hookless retry survival, exactly-once callback per reader, cancellation precedence, and context publication/race safety.

Prior finding dispositions: F1 is fixed at both layers and regression-tested. F2 is documented and tracked as the bounded embedded lifecycle policy follow-up #1505: full daemon hosts receive terminal errors through RunWithContext or wavewalletdk.Client.Wait and own recreation, while hookless raw consumers retain indefinite retry. F3 is a documentation wording ambiguity with a recorded maintainer dismissal; the actual ordering is fatal shutdown signal, reader exit/registration closure, then daemon drain. Do not re-raise unchanged dismissed or refuted findings without new material evidence. No new runtime redesign is warranted for either follow-up.

Validation: the revised expiry test passed 20 repetitions under race detection; the complete covered lwwallet suite passed five repetitions. Changed-code lint passed with zero issues including actorblock; formatting, module and commit-message checks passed. All three commits are signed. Production recovery tests, targeted races and real-LND fanout on SQLite/PostgreSQL passed against the identical runtime source. The preceding full CI passed both system backends, both database unit variants, cross-builds, lint, static/generation checks and models; only the two diagnosed test fixture failures remained. Full CI is refreshed at this exact final head and must be confirmed separately.

For any new finding provide a concrete production or test execution trace, exact file:line evidence, change attribution, earlier guards/recovery, realistic likelihood, downside, smallest regression test, smallest useful fix and fix risk. Classify BLOCKER, FOLLOW-UP or NON-ACTIONABLE. Optional cleanup, unchanged debt, style preferences or speculative states must not block. Give an explicit READY, READY WITH FOLLOW-UPS or BLOCKED verdict. Stop once the intended invariants and test isolation are proven and no concrete material defect remains.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review

No findings.


Status of prior findings

  • F1 addressed: Fixed at both layers. chainsource/block_epoch_actor.go (~L524) clears the budget only when !haveEpoch || epoch.Height != lastEpoch.Height || epoch.Hash != lastEpoch.Hash. chainbackends/block_epoch_hub.go (~L467) does the same against cursor. Both values survive reconnects, so a replayed tip cannot reset spent retry time, while a reorg at the same height still counts as progress. Regression tests: the "replayed tip" cases in both escalation tests, plus TestBlockEpochHubFatalBudgetResets and TestBlockEpochActorRecoveryResetsFatalBudget.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway approve

The complete final diff at e6f5e5b has completed the full Gateway re-review with no findings, including the separate test-only CI repair. F1 is addressed. F2 is tracked in #1505, and F3 has an explicit documentation-only disposition. No code changed after that review. Full CI is still running; merge readiness remains gated on its required checks and both system-test backends.

@bhandras
bhandras self-requested a review November 2, 2026 16:53

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — all prior findings addressed.

Findings recap

Findings:

  • F1 (minor) — addressed: Any delivered epoch clears downSince, including one whose height and hash the actor already forwarded. A backend that sends its current be…
  • F2 (minor) — unresolved: The hook is wired for every wallet type and for every RunWithContext embedder, including SDK hosts. For those hosts, a 5–6 minute backend …
  • F3 (minor) — unresolved: The new invariant text says "subscriber closure follows shutdown", but subscribers actually close at escalation. After onFatal, `reconnect…

Dismissed:

  • F2 by @bhandras — promoted to #1505
  • F3 by @bhandras — Documentation wording ambiguity only: the hook signals shutdown before the reader closes registrations. The runtime ordering is correct and bounded; leave the copyedit as a follow-up.

Approved by @bhandras via /gateway approve. Last reviewed at e6f5e5b. Skill v0.6.0, model claude-opus-5-5.

Roasbeef and others added 3 commits November 2, 2026 19:07
Block subscribers currently retry forever after a backend loses its
notifier connection. The RPC listener can remain available while no
subscriber advances. Bound this outage so supervision can restart the
daemon with a fresh backend.

Track the first closed stream and reset the outage only on a delivered
block. After five minutes plus one-shot jitter, escalate through an
optional callback. Hookless consumers keep retrying indefinitely.

Cancel the daemon run context with an explicit fatal cause and return it
after normal cleanup, including when cancellation interrupts startup or
the backend error wraps context.Canceled. Ordinary cancellation remains
clean. Existing /livez and /readyz handlers withdraw on cancellation, so
the old gateway health additions are superseded by current main.

Test closed streams, failed registrations, exactly-once escalation,
hookless retries, recovery resetting the budget, and cancellation. Boot
a daemon over an in-memory gRPC listener to verify fatal versus ordinary
exit results and immediate withdrawal of both health endpoints. Leave
open-but-silent stream detection out of scope: Bitcoin block arrival
intervals alone cannot identify a failed backend.
The shared LND block hub absorbs upstream failures while keeping its
subscriber queues open. The chainsource actor cannot see those failures,
so its subscription timeout alone cannot restart a stuck LND backend.

Add an optional fatal callback at the shared reader's reconnect boundary
and wire it to the daemon's existing fatal cancellation path. Share the
five-minute timeout and jitter defaults with chainsource. Hookless
notifiers continue retrying indefinitely and retain their registrations.

Only a changed height or hash clears either outage budget. Replaying the
same tip before closing again must not keep a failing stream alive; a
same-height reorg replacement still proves progress. Pause the budget
while a stream is open, so a long Bitcoin block interval cannot turn a
later transient close into a false fatal. No idle deadline is
introduced.

Test raw closed streams, repeated tip replays, failed registrations,
hookless recovery, reorg recovery resetting the budget, and
cancellation. Keep callback execution on the owning reader and use the
normal daemon cleanup path. Embedded daemon hosts receive the terminal
run error and remain responsible for recreating their runtime.
The backend cancellation test samples the process-wide goroutine count.
Parallel sibling tests can start HTTP workers after that baseline and
falsely appear to leak registrations. This failed the required coverage
job even though the cancellation path was unchanged.

Run this test before parallel package tests are released. Preserve its
leak threshold, map cleanup assertions, and repeated-cancellation
checks. The expiry-test scheduling repair is already present on main.
@bhandras
bhandras force-pushed the fail-fast-block-epoch-loss branch from e6f5e5b to 6a86b0d Compare November 2, 2026 18:10
@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway re-review high

Review the complete PR #762 diff at exact published head 6a86b0d against main ccc97b2. Verify the SHA. This is a full review of the final rebased diff, not only the conflict hunk.

The previous full review at e6f5e5b found no findings. Independently re-check this head; do not treat that conclusion as proof. The rebase preserves the first two runtime commits unchanged (git range-diff reports equality). All five changed runtime-file blobs are identical to their pre-rebase versions. Roasbeef's original authorship is preserved and all rewritten commits are signed.

The only conflict was a redundant expiry-test scheduling fix in waved/rpc_wallet_test.go. Main now builds expiry timestamps after parallel admission (9f18037). Keep that upstream implementation unchanged. The remaining third commit only isolates lwwallet's process-wide goroutine-count assertion from parallel HTTP workers; its leak threshold, map cleanup and double-cancel assertions are unchanged.

Purpose and invariant: sustained closed/failing block streams must eventually cancel the daemon and return a fatal error after normal cleanup, allowing supervision to create a fresh backend. The shared LND raw-stream hub keeps subscriber queues open during reconnects, so the budget belongs at that owning boundary as well as the generic actor. Hookless consumers retain indefinite retry. A changed height/hash clears spent failure time; a duplicate tip does not. Open time is paused so healthy Bitcoin inter-block intervals cannot cause false fatal exits. An open but silent stream has no idle deadline.

Verify these concrete paths:

  1. Raw shared LND streams repeatedly close, or registration repeatedly fails: budget is bounded, preserves the backend cause and escalates once per reader.
  2. Streams replay an identical tip before closing: that replay must not reset spent retry time. Same-height reorg replacements do reset it. Epoch forwarding remains intact.
  3. Long open intervals, transient reconnects and hookless subscriptions remain healthy; no Bitcoin inter-block timer is introduced.
  4. Parent cancellation first returns cleanly. A fatal backend error wrapping context.Canceled still returns nonzero. Existing /livez and /readyz withdraw on the run-context cancellation.
  5. Trace waved.initChainBackend -> notifier OnFatal -> shared hub reconnect -> signalFatal -> explicit fatal cause -> run cleanup/error return -> command nonzero exit. Check context ownership and publication/race safety.
  6. The rebase preserves upstream resource hardening and the current authorization test. The cancellation cleanup test remains meaningful with parallel siblings paused.

Prior dispositions remain: F1 addressed at both recovery boundaries; F2 tracked as bounded embedded-host lifecycle policy in #1505; F3 documentation-only wording dismissed with a recorded reason. Re-raise dismissed or unchanged concerns only with new material evidence.

Local rebase checks passed: focused race tests across chainsource, chainbackends, waved and lwwallet including fatal/cancel/health, the current upstream expiry test and cancellation cleanup; changed-code lint including actorblock; formatting, module tidiness and commit-message checks. Fresh full CI is running on this exact head. Prior full CI was green but is not a substitute for the refreshed checks.

For a new finding provide a concrete reachable execution trace and exact file:line evidence; whether this diff introduces or worsens it; earlier validation and later recovery; likelihood and downside; smallest regression test and smallest useful fix; fix complexity and regression risk. Classify BLOCKER, FOLLOW-UP or NON-ACTIONABLE. Avoid speculative guards, unrelated debt and optional cleanup. Require an explicit READY, READY WITH FOLLOW-UPS or BLOCKED verdict. Stop when the named invariants hold and no concrete material defect remains.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review

No findings.


Status of prior findings

  • F1 addressed: Fixed at both layers. The hub resets the budget only on a new block identity: cursor == nil || epoch.Height != cursor.Height || *epoch.Hash != *cursor.Hash (chainbackends/block_epoch_hub.go:467-472). The actor does the same against lastEpoch (chainsource/block_epoch_actor.go:524-532). The "replayed tip" cases in TestBlockEpochHubFatalRecovery and TestBlockEpochActorEscalatesWhenBackendStaysDown cover replays of the same tip. TestBlockEpochHubFatalBudgetResets covers same-height reorg resets. Resolved.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

@bhandras

bhandras commented Nov 2, 2026

Copy link
Copy Markdown
Member

/gateway approve

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — all prior findings addressed.

Findings recap

Findings:

  • F1 (minor) — addressed: Any delivered epoch clears downSince, including one whose height and hash the actor already forwarded. A backend that sends its current be…
  • F2 (minor) — unresolved: The hook is wired for every wallet type and for every RunWithContext embedder, including SDK hosts. For those hosts, a 5–6 minute backend …
  • F3 (minor) — unresolved: The new invariant text says "subscriber closure follows shutdown", but subscribers actually close at escalation. After onFatal, `reconnect…

Dismissed:

  • F2 by @bhandras — promoted to #1505
  • F3 by @bhandras — Documentation wording ambiguity only: the hook signals shutdown before the reader closes registrations. The runtime ordering is correct and bounded; leave the copyedit as a follow-up.

Approved by @bhandras via /gateway approve. Last reviewed at 6a86b0d. Skill v0.6.0, model claude-opus-5-5.

@bhandras
bhandras merged commit 5adcad8 into main Nov 2, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

daemon P0 Priority 0 — blocker safety Fund-safety: stuck, lost, or mis-counted funds

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants