Repository navigation
chainsource: escalate unrecoverable block epoch loss to the daemon - #762
Conversation
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
176ace8 to
d6fbb56
Compare
|
@claude review this |
|
Claude finished @Roasbeef's task in 2m 44s —— View job Review: chainsource: escalate unrecoverable block epoch loss to the daemonI read the full diff across Two correctness issues stand out, one of which I'd treat as blocking. 🔴 1. Without an
|
d6fbb56 to
54f7075
Compare
|
Thanks — addressed the two correctness issues, folded into the existing commits (force-pushed #1 (🔴 silent subscription death without #2 (🟠 spurious escalation on shutdown) — fixed. Added an #3 (🟡 open-but-silent stream) — documented, not implemented. Agreed it's narrower than the closing-stream symptom this PR targets, and Nits confirmed (no change needed): the |
|
@Roasbeef, remember to re-request review from reviewers when ready |
10 similar comments
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
|
@Roasbeef, remember to re-request review from reviewers when ready |
54f7075 to
9705d04
Compare
|
/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:
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. |
There was a problem hiding this comment.
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:
- Closed streams.
downSinceis stamped only on the first close (chainsource/block_epoch_actor.go:493-495). A successful re-registration doesn't clear it, and thereturnafterescalateFatalmeans escalation fires at most once per actor. When dozens of actors escalate,CancelCauseFunckeeps the first cause and ignores the rest. - Registration errors.
lastErrkeeps the backend cause and is wrapped with%w. A failed registration leavesregistrationnil, so nothing leaks. - Recovery. A delivered block clears the streak (
:512-513). An open, idle stream never reaches the exhaustion check, which is intended. - No hook. The
OnFatal.IsSome()gate preserves indefinite retry for hookless callers. - Cancellation. The
a.ctx.Err()guard runs before the exhaustion check. If the parent is cancelled first, the derived context's cause is alreadycontext.Canceled, so a latersignalFataldoes nothing andrun()returns normally. TheerrFatalSubsystemmarker check still returns the fatal cause when the backend error wrapscontext.CanceledandrunInnerreturns early. Health withdrawal follows directly becauserunCtxis now the derived context. - Lifetimes and races.
fatalCancelis written beforerunInnerstarts any goroutine. The actor's context still derives fromcontext.Background(), not from the RPC that started the subscription. The test fixtures' shared state (registrations,steppedClock) is ordered by goroutine start andwg.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)
|
🤖 gateway audit metadata for this PR — auto-generated, please don't edit. |
|
/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:
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:
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. |
There was a problem hiding this comment.
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:
initChainBackendsetsOnFatal: fn.Some(s.signalFatal)(waved/server.go:2651-2652).NewLndClientChainNotifiercopies it intoepochs.onFatal(chainbackends/lndclient_adapters.go:145).runUpstreamnotes the first loss and callsreconnect(chainbackends/block_epoch_hub.go:445-457).- Before each retry,
reconnectchecksctx.Err()first and then the fatal budget. It callsonFatalonce, wrappinglastErr, and returns nil (block_epoch_hub.go:491-503). signalFatalcancels the run context witherrors.Join(errFatalSubsystem, err).runreplacesrunErrwith that cause whenevercontext.Cause(ctx)matcheserrFatalSubsystem, even ifrunInnerreturned a wrappedcontext.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 clearsdownSince. - Repeated registration failures (path 2). The last backend error is kept in
lastErron both layers. Failed hub opens cancel their per-attempt context insideopenBlockEpochStream. - 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 != niland the actor checksOnFatal.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, sorundoes not treat it as fatal. - Publication and races (path 6).
fatalCancelis set beforerunInner.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
downSinceonly when!haveEpochor the height or hash differs fromlastEpoch(chainsource/block_epoch_actor.go:520-532). The hub clears it only whencursor == nilor the height or hash changed (chainbackends/block_epoch_hub.go:466-472). The hub's cached-tip seed inaddSubLockedis now a duplicate and no longer resets the budget. The "replayed tip" cases inTestBlockEpochActorEscalatesWhenBackendStaysDownandTestBlockEpochHubFatalRecoverycover 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)
|
/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. |
|
/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. |
|
🚫 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. |
|
/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. |
There was a problem hiding this comment.
✅ Approved — all prior findings addressed.
Findings recap
Findings:
F1(minor) — addressed: Any delivered epoch clearsdownSince, 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 everyRunWithContextembedder, 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. AfteronFatal, `reconnect…
Dismissed:
F2by @bhandras — promoted to #1505F3by @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.
|
/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:
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. |
There was a problem hiding this comment.
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 againstcursor. 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, plusTestBlockEpochHubFatalBudgetResetsandTestBlockEpochActorRecoveryResetsFatalBudget.
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)
|
/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. |
There was a problem hiding this comment.
✅ Approved — all prior findings addressed.
Findings recap
Findings:
F1(minor) — addressed: Any delivered epoch clearsdownSince, 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 everyRunWithContextembedder, 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. AfteronFatal, `reconnect…
Dismissed:
F2by @bhandras — promoted to #1505F3by @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.
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.
e6f5e5b to
6a86b0d
Compare
|
/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:
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. |
There was a problem hiding this comment.
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 againstlastEpoch(chainsource/block_epoch_actor.go:524-532). The "replayed tip" cases inTestBlockEpochHubFatalRecoveryandTestBlockEpochActorEscalatesWhenBackendStaysDowncover replays of the same tip.TestBlockEpochHubFatalBudgetResetscovers 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)
|
/gateway approve |
There was a problem hiding this comment.
✅ Approved — all prior findings addressed.
Findings recap
Findings:
F1(minor) — addressed: Any delivered epoch clearsdownSince, 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 everyRunWithContextembedder, 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. AfteronFatal, `reconnect…
Dismissed:
F2by @bhandras — promoted to #1505F3by @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.
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.Canceledor 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]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
/livezand/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
Local validation:
dev nologbuild tags.make unit pkg=chainsource log="stdlog trace" timeout=5m: passed.TestLNDBlockFanoutReorg: passed with SQLite and PostgreSQL.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’sClient.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.