Skip to content

multi: standardize panic recovery at async boundaries - #11097

Open
ziggie1984 wants to merge 13 commits into
lightningnetwork:masterfrom
ziggie1984:recover-backstops
Open

multi: standardize panic recovery at async boundaries#11097
ziggie1984 wants to merge 13 commits into
lightningnetwork:masterfrom
ziggie1984:recover-backstops

Conversation

@ziggie1984

@ziggie1984 ziggie1984 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Change Description

This PR introduces shared helpers for recovering from and reporting unexpected
panics in asynchronous processing paths.

The helpers are applied to selected subsystem boundaries, including state
machines, worker tasks, peer processing, channel links, funding, actors, and
message routing. Existing recovery code in the gossip and RPC subsystems is
also migrated to the shared helpers.

Each subsystem retains its existing lifecycle and error-handling semantics.
Recovered failures are reported through structured logging, followed by the
appropriate local cleanup or shutdown behavior.

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Aug 18, 2026
@github-actions

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

gh pr view | 22 files | 2381 lines changed

🔴 Critical (3 files)
  • funding/manager.go - channel funding workflow coordination
  • htlcswitch/link.go - HTLC forwarding / link state logic
  • peer/brontide.go - encrypted peer connection (Noise/brontide) handling
🟠 High (3 files)
  • discovery/gossiper.go - gossip protocol message handling
  • protofsm/state_machine.go - generic protocol state machine used by core FSMs
  • rpcperms/interceptor.go - RPC permission/auth interceptor
🟡 Medium (7 files)
  • actor/actor.go - actor framework core
  • actor/interface.go - actor framework interfaces
  • fn/stack.go - new generic stack utility
  • log.go - logging subsystem registration
  • msgmux/msg_router.go - message router utility
  • pool/log.go - worker pool logging
  • pool/worker.go - worker pool implementation
🟢 Low (9 files)
  • actor/actor_test.go, fn/stack_test.go, funding/manager_test.go, htlcswitch/link_test.go, msgmux/msg_router_test.go, peer/brontide_test.go, pool/worker_test.go, protofsm/state_machine_test.go, rpcperms/interceptor_test.go - test-only changes

Analysis

This PR touches several critical subsystems directly: funding/manager.go (channel funding workflow), htlcswitch/link.go (HTLC forwarding), and peer/brontide.go (encrypted peer connection / Noise protocol handshake and message dispatch). Any of these alone would warrant CRITICAL classification, and together they represent three distinct critical packages being modified in the same PR.

The change is also broad: excluding test files, ~13 non-test files and ~896 lines are modified, which exceeds the 500-line threshold for a severity bump on its own. It also touches discovery/gossiper.go (gossip protocol) and rpcperms/interceptor.go (RPC auth), both HIGH-severity areas, plus supporting utility packages (actor, fn, msgmux, pool) that appear to back a new actor-model/message-routing abstraction being wired into the peer and link code paths.

Given the surface area (peer connection handling, HTLC link logic, and channel funding all changed together) this warrants careful expert review, particularly around message ordering/concurrency guarantees in the new actor/message-router plumbing and its integration into brontide.go and link.go.


To override, add a severity-override-{critical,high,medium,low} label.

@ziggie1984
ziggie1984 force-pushed the recover-backstops branch 9 times, most recently from ae9fd3f to c47271f Compare August 18, 2026 22:48
@ziggie1984 ziggie1984 self-assigned this Aug 18, 2026
Add RecoverPanic to capture a recovered value and bounded stack at a
goroutine boundary. A caller-provided callback determines how the owning
subsystem performs its local cleanup.

RecoverPanic must be invoked directly as a deferred call:

    defer fn.RecoverPanic(handler)

This is required because recover only works when called from the deferred
frame. Document and test this constraint.

Add LogRecoveredPanic to emit consistent structured reporting through a
minimal logger interface. It records the panic type, original panic value,
and bounded stack while allowing caller-specific attributes. Reporting is
best effort so a secondary logging panic cannot interrupt containment.

Add shared stack capture and truncation for direct recovery sites. Tests
cover recovery, ordinary returns, named-return updates, bounded stack
capture, structured attributes, raw value preservation, and reporting
failures.
@ziggie1984 ziggie1984 added backport-v0.20.x-branch This label is used to trigger the creation of a backport PR to the branch `v0.20.x-branch`. backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` labels Aug 18, 2026
@gijswijs
gijswijs self-requested a review August 19, 2026 07:19
@ziggie1984
ziggie1984 requested a review from starius August 19, 2026 12:26
@ziggie1984
ziggie1984 marked this pull request as ready for review August 19, 2026 12:26
Add a recovery boundary to the state machine event application loop. If
ProcessEvent panics, applyEvents converts it to an error and reports the
failure through the existing ErrorReporter path.

The state machine then follows its normal error handling and stops. Record
the recovered value and bounded stack using the shared structured logger.

Add a regression test with a state whose ProcessEvent panics. Verify that
the error is reported and the machine stops cleanly.
Add a recovery boundary to readHandler and report recovered panics using
shared structured logging. Reuse the normal peer disconnection and cleanup
paths.

Add a regression test verifying that the peer disconnects and readHandler
exits cleanly.
Run submitted tasks through a helper that reports recovered panics and returns
ErrWorkerTaskPanic to the caller. Release worker resources through the existing
cleanup path so the pool remains available for later work.

Add tests covering newly spawned and reused workers.
Add a recovery boundary to the link's htlcManager. Report recovered panics
through shared structured logging and use the existing link failure handling
to stop and clean up the link.

Add regression tests for the recovery and cleanup paths.
Add recovery boundaries to the legacy cooperative-close message and
channel-flush paths. Report recovered panics using shared structured logging
and route failures through the existing close and peer cleanup paths.

Keep persisted close state intact when resetting in-memory negotiation state.
Add regression tests for recovery and cleanup.
Add a recovery boundary around funding message dispatch. Report recovered
panics using shared structured logging and apply the existing stage-specific
cleanup and notification behavior.

Add regression tests covering recovery at different funding stages and
continued message processing.
Deliver each envelope through a helper that reports recovered panics using
shared structured logging. Complete pending Ask requests with ErrActorPanic
when no response can be produced, then continue processing the mailbox.

Add a regression test covering the error response and subsequent message
delivery.
Add a recovery boundary around endpoint routing and return ErrRoutePanic to
the waiting caller. Report recovered panics using shared structured logging
and keep the router available for later requests.

Avoid routing the same message again after a recovery because endpoint
delivery may already have started. Add tests covering recovery, subsequent
routing, partial delivery, and peer handling.
Move the existing gossiper and RPC recovery logging to the shared structured
reporting and bounded-stack helpers. Keep recovery at the existing deferred
call sites.

Remove the local rpcperms stack truncation implementation and retain its
coverage with the shared fn tests.
Send the final close update through a helper that also observes the request
context and peer shutdown. Remove the channel closer afterward regardless of
whether the update was delivered.

Add regression tests covering request cancellation and peer shutdown.
@ziggie1984 ziggie1984 added this to v0.21 Aug 21, 2026
@ziggie1984 ziggie1984 added this to the v0.21.3 milestone Aug 21, 2026
Comment thread htlcswitch/link.go
@@ -1319,6 +1319,13 @@ func (l *channelLink) handleChanSyncErr(err error) {
//
// NOTE: This MUST be run as a goroutine.
func (l *channelLink) htlcManager(ctx context.Context) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any reason htlcManager uses a dedicated recoverFromPanic method with manual recover() / fn.PanicStack() calls rather than defer fn.RecoverPanic(...) like peer.readHandler and protofsm?

I noticed this handler also needs to protect the l.failf cleanup path from a secondary panic, so maybe that's the reason. If not, could we inline the callback or have the helper take a callback to keep the recovery style consistent across the PR?

Comment thread msgmux/msg_router.go
var couldSend bool
for _, endpoint := range endpoints {
if endpoint.CanHandle(msg) {
log.Tracef("MsgRouter: sending msg %T to endpoint %s",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

%T on msg will always report msgmux.PeerMsg, rather than the actual wire message type. Should this use msg.Message like the log below?

log.Tracef("MsgRouter: sending msg %T to endpoint %s",
    msg.Message, endpoint.Name())

@TechLateef TechLateef 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.

Good fix! LGTM

@gijswijs gijswijs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Made some remarks throughout. Concept is ack, but it needs some hardening, and wider implementation.

Consider renaming fn/stack.go to fn/panic.go (and fn/panic_test.go)

Comment thread fn/stack.go
// This MUST be deferred directly:
//
// defer fn.RecoverPanic(func(p fn.Panic) {
// log.Errorf("recovered: %v\n%s", p.Value, p.Stack)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This example contradicts the PR's own goal of standardized structured reporting.

I would provide this example

defer fn.RecoverPanic(func(p fn.Panic) {
        fn.LogRecoveredPanic(ctx, s.log, p)
        // ...subsystem-specific cleanup...
})

Comment thread discovery/gossiper.go
if len(stack) > maxStackSize {
stack = stack[:maxStackSize]
}
log.DebugS(logCtx, "Panic stack trace",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Before this PR we recovered from panic in the gossiper and emitted the stack trace at Debug level.

A remote peer with a panic-inducing gossip message now generates ~8 KiB of ERROR logs per attempt; per-event size is bounded but repetition is not, and there is no rate limit.

Fix: keep the stack trace at Debug (or add a level parameter to LogRecoveredPanic), or rate-limit repeated panic logs per peer.

Comment thread htlcswitch/link.go
//
// NOTE: The loop does not resume after recovery because an update may
// have been applied only partially.
defer l.recoverFromPanic(ctx)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This recover now has a funny side-effect: There are several hook invocations on the remote commitment path with non-deferred Unlock. If a hook panics, recovery (in htlcManager) fires while the RWMutex is still write-locked: any goroutine with a stale link reference that calls a locking method hangs forever.

defer the unlocks at htlcswitch/link.go:2124-2126, 4496-4498, 4559-4563, 4664-4668 but check if those are all.

Comment thread funding/manager.go
// A recovered panic is reported through the existing funding failure handling,
// after which the coordinator can process the next message.
func (f *Manager) handleFundingMsg(fmsg *fundingMsg) {
defer fn.RecoverPanic(func(pnc fn.Panic) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If this loop panics:

https://github.com/ziggie1984/lnd/blob/4080b75b1ec40036f5ba8adc6f0bc56747ffffdf/funding/manager.go#L1601-L1607

resMtx.RUnlock()will never be called, and we recover, but with a potential deadlock.

Defer resMtx.RUnlock() at L1607

Comment thread fn/stack.go
// `defer func() { fn.RecoverPanic(..) }()`, silently does nothing: the language
// only lets recover() stop a panic when it's called by the deferred function
// itself, so one frame further down it returns nil and the panic carries on
// unwinding.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Add the following for proper documentation:

// Where the defer is placed decides what happens after recovery:
//
//   - Deferred at the top of a goroutine's main function, recovery ends
//     the goroutine: the unit of work stops (fail-stop). Any cleanup the
//     rest of the system relies on must run in onPanic or in other
//     deferred calls.
//   - Deferred inside a per-item function invoked from a loop, recovery
//     returns from that function and the loop continues with the next
//     item (fail-and-continue). State shared between iterations may be
//     partially applied; only use this posture when items are
//     independent.
//
// Choose the placement deliberately: recovering a state machine driver
// with the per-item pattern resumes it on possibly corrupt state, while
// recovering a request handler with the goroutine-top pattern kills a
// service that could have kept serving.
//
// onPanic runs while the goroutine is unwinding, so treat it as fragile:
// prefer logging the panic's type (%T) over calling methods on values
// involved in the panic, and keep cleanup simple. A panic inside onPanic
// is not recovered and crashes the process.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The last line wouldn't be true anymore if you apply my suggestion from #11097 (comment)

Comment thread funding/manager.go
f.handleErrorMsg(fmsg.peer, msg)
}
case req := <-f.fundingRequests:
f.handleInitFundingMsg(req)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Only the fundingMsgs select arm of reservationCoordinator is wrapped with recover logic. A
panic in handleInitFundingMsg (RPC-initiated) or the fundingRequests/zombie-sweeper branches on would still kill it.

Comment thread funding/manager.go

f.failFundingMsgAfterPanic(fmsg, err)
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There are still goroutines uncovered.

The spawned goroutines at:4202-4221 and :4275-4281 are unrecoverably, because defers don't propagate across goroutine boundaries.

Fix: add recovery inside each spawned goroutine.

Comment thread fn/stack.go
// })
//
// Wrapping it in a closure instead, as in
// `defer func() { fn.RecoverPanic(..) }()`, silently does nothing: the language

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We could add a tiny analyzer to check for this misuse.

The recommended fix: a tiny go/analysis analyzer in tools/linters/ _ right next to ll.go, registered the same way as a module plugin in
.golangci.yml, built by the existing make build-native-linter target. The rule: any call to fn.RecoverPanic whose parent node isn't
directly a defer statement is an error. That catches defer func() { fn.RecoverPanic(cb) }(), plain calls, and any future creative misuse
_ repo-wide, at CI time, with zero API churn.

The core is about 40 lines:

// tools/linters/deferrecover.go
func run(pass *analysis.Pass) (any, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

inspect.Preorder([]ast.Node{(*ast.CallExpr)(nil)}, func(n ast.Node) {
        call := n.(*ast.CallExpr)

        // Resolve the callee by type, not name: catches
        // fn/v2.RecoverPanic regardless of import alias.
        sel, ok := call.Fun.(*ast.SelectorExpr)
        if !ok || pass.TypesInfo.Selections[sel] != nil {
        return
        }
        obj := pass.TypesInfo.Uses[sel.Sel]
        if obj == nil || obj.Pkg() == nil ||
        obj.Pkg().Path() != "github.com/lightningnetwork/lnd/fn/v2" ||
        obj.Name() != "RecoverPanic" {
        return
        }

        // The call is fine iff its parent is a defer of this call.
        // (track parentage via a stack or a second pass over DeferStmts)
        if !directlyDeferred {
        pass.Reportf(call.Pos(),
                "fn.RecoverPanic only works when deferred directly: "+
                "use `defer fn.RecoverPanic(...)` _ wrapping it "+
                "in a closure silently recovers nothing")
        }
})
return nil, nil
}

ymmv

Comment thread fn/stack.go
return
}

onPanic(Panic{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No nil guard and no internal recovery around the callback: a nil onPanic, or a callback that itself panics, re-panics during unwinding and kills the
process. How about:

// RecoverPanic stops a panic from unwinding the calling goroutine any
// further and hands the details to onPanic. ...
func RecoverPanic(onPanic func(Panic)) {
r := recover()
if r == nil {
        return
}

// A nil callback would panic below, turning containment into a
// crash.
if onPanic == nil {
        return
}

p := Panic{
        Value: r,
        Stack: TruncatePanicStack(debug.Stack()),
}

// The original panic has been consumed at this point, so a panic
// inside onPanic is a NEW panic that nothing else will catch. Arm a
// second recover that stays pending while the callback runs. There
// is no logger available here, and the logging path itself may be
// the thing that failed, so fall back to stderr _ the same place
// the runtime reports unhandled panics.
defer func() {
        if r2 := recover(); r2 != nil {
        fmt.Fprintf(os.Stderr,
                "fn: panic handler panicked: %v (while "+
                "containing: %v)\n%s", r2, r,
                TruncatePanicStack(debug.Stack()),
        )
        }
}()

onPanic(p)
}

Comment thread peer/brontide.go
// message is received from the remote peer. We'll use this message to advance
// the chan closer state machine.
func (p *Brontide) handleCloseMsg(msg *closeMsg) {
// Install recovery around legacy close handling. Once the ordinary

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This keeps func handleLocalCloseReq uncovered by recovery.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.20.x-branch This label is used to trigger the creation of a backport PR to the branch `v0.20.x-branch`. backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` severity-critical Requires expert review - security/consensus critical

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants