multi: standardize panic recovery at async boundaries - #11097
multi: standardize panic recovery at async boundaries#11097ziggie1984 wants to merge 13 commits into
Conversation
1128734 to
c4f9ec2
Compare
🔴 PR Severity: CRITICAL
🔴 Critical (3 files)
🟠 High (3 files)
🟡 Medium (7 files)
🟢 Low (9 files)
AnalysisThis PR touches several critical subsystems directly: 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 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 To override, add a |
ae9fd3f to
c47271f
Compare
c47271f to
97fefb5
Compare
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.
97fefb5 to
278c3c5
Compare
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.
278c3c5 to
4080b75
Compare
| @@ -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) { | |||
There was a problem hiding this comment.
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?
| var couldSend bool | ||
| for _, endpoint := range endpoints { | ||
| if endpoint.CanHandle(msg) { | ||
| log.Tracef("MsgRouter: sending msg %T to endpoint %s", |
There was a problem hiding this comment.
%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())
gijswijs
left a comment
There was a problem hiding this comment.
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)
| // This MUST be deferred directly: | ||
| // | ||
| // defer fn.RecoverPanic(func(p fn.Panic) { | ||
| // log.Errorf("recovered: %v\n%s", p.Value, p.Stack) |
There was a problem hiding this comment.
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...
})| if len(stack) > maxStackSize { | ||
| stack = stack[:maxStackSize] | ||
| } | ||
| log.DebugS(logCtx, "Panic stack trace", |
There was a problem hiding this comment.
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.
| // | ||
| // NOTE: The loop does not resume after recovery because an update may | ||
| // have been applied only partially. | ||
| defer l.recoverFromPanic(ctx) |
There was a problem hiding this comment.
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.
| // 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) { |
There was a problem hiding this comment.
If this loop panics:
resMtx.RUnlock()will never be called, and we recover, but with a potential deadlock.
Defer resMtx.RUnlock() at L1607
| // `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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
The last line wouldn't be true anymore if you apply my suggestion from #11097 (comment)
| f.handleErrorMsg(fmsg.peer, msg) | ||
| } | ||
| case req := <-f.fundingRequests: | ||
| f.handleInitFundingMsg(req) |
There was a problem hiding this comment.
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.
|
|
||
| f.failFundingMsgAfterPanic(fmsg, err) | ||
| }) | ||
|
|
There was a problem hiding this comment.
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.
| // }) | ||
| // | ||
| // Wrapping it in a closure instead, as in | ||
| // `defer func() { fn.RecoverPanic(..) }()`, silently does nothing: the language |
There was a problem hiding this comment.
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
| return | ||
| } | ||
|
|
||
| onPanic(Panic{ |
There was a problem hiding this comment.
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)
}| // 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 |
There was a problem hiding this comment.
This keeps func handleLocalCloseReq uncovered by recovery.
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.