Skip to content

multi: contain panics at fail-stop async boundaries - #11097

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

multi: contain panics at fail-stop async boundaries#11097
ziggie1984 wants to merge 8 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 at selected asynchronous boundaries. Recovery is enabled only where an
arbitrary panic can be contained without continuing to use uncertain mutable
state.

The goal is not to make a panicking component healthy. The goal is to reduce
the failure scope while preserving fail-stop behavior for the affected peer,
worker, or protocol instance.

Safe Recovery Boundary Definition

A recovery boundary is considered safe when, after recovery, no code continues
processing with state that may have been partially mutated before the panic.
The boundary must satisfy one of these models:

  1. Fail-stop containment: the affected execution unit and its state are
    retired or quarantined. It does not process another event.
  2. Retry-safe maintenance: the current attempt ends, completed per-item
    effects remain valid, and unfinished work stays discoverable for a later
    clean retry. No partially advanced protocol state machine is resumed.

Attacker reachability is evaluated separately. It determines how important a
boundary may be for denial-of-service protection, but it does not prove that
recovering there is safe. A highly exposed state machine is still excluded if
its partial effects cannot be rolled back or quarantined.

Boundary Evaluation Requirements

Every candidate site was evaluated individually against the same requirements:

  1. Identify the trigger, trust boundary, owning goroutine, and exact function
    that returns after recovery.
  2. Trace in-memory mutations, persistent writes, wallet or signer operations,
    messages, broadcasts, subscriptions, and launched goroutines that may have
    completed before the panic.
  3. Prove that uncertain state is retired or quarantined, or prove that the work
    is independently retryable without resuming a partial protocol transition.
  4. Trace every mutex, wait group, worker token, stream, and cleanup defer to
    ensure panic unwinding releases ownership correctly.
  5. Trace teardown ordering and bounded channel operations so cleanup does not
    wait for a signal that only a later cleanup step can produce. Any remaining
    producer and buffer assumptions must be explicit.
  6. Preserve recoverable persistent state when teardown occurs after an external
    effect such as broadcasting a cooperative-close transaction.
  7. Exercise the boundary with fault injection, goroutine-exit checks, lock
    reacquisition checks, and race-detector coverage.

The analysis assumes a panic can occur at any instruction, including after an
external effect but before the corresponding in-memory bookkeeping completes.

Recovery Boundaries Retained in This PR

Boundary Trigger Containment outcome Why recovery is safe
peer.readHandler Remote peer message Reports the panic and disconnects the peer Recovery retires the complete peer lifecycle. Disconnect happens before discovery-stream shutdown so waiting consumers can observe the peer quit signal.
pool.Worker.runTask Submitted buffer-pool task Converts the panic to an error and retires the worker state Current production callers propagate the task failure into peer teardown. The panicking worker does not continue with its previous task state.
peer.handleCloseMsg Remote legacy cooperative-close message Fails and removes the legacy closer, resets its in-memory state, and disconnects the peer The close negotiation and peer are retired. Persisted ChanStatusCoopBroadcasted state is retained so an already broadcast transaction remains recoverable after restart.
peer.handleChanFlushed Legacy close flush notification Uses the same legacy-close failure and peer teardown path The closer remains owned by channelManager and is not resumed after recovery.
funding.pruneZombieReservations Internal sweep timer Ends the current sweep and permits a later timer tick to retry Reservation and timestamp locks are released. Completed reservation cancellations remain valid and unprocessed reservations remain discoverable. No active funding protocol transition is resumed.

The legacy close path also validates that a final closing transaction exists
before removing live channel state. Its internal impossible-message assertion
is intentionally retained. If that invariant is violated, recovery reports the
panic and scopes the failure to the affected close and peer instead of
terminating the process.

Deliberately Excluded Boundaries

This PR excludes recovery where the same stateful component could continue
after an unknown subset of its effects completed. This includes actor mailbox
delivery, message-router endpoints, protocol state machines, the HTLC manager,
remote and local funding workflows, late funding messages, asynchronous
ChannelReady processing, and local close initiation.

Those paths can persist state, consume mappings, reserve wallet inputs, create
signer state, send messages, register subscriptions, install channels, or
broadcast transactions before panicking. They require path-specific rollback,
quarantine, or transaction-like ownership before recovery can be enabled
safely. They will be handled in focused follow-up changes.

This boundary-specific policy remains enabled by default. It does not require
node operators to anticipate an unknown remotely triggerable crash or opt in to
protection before a vulnerability is disclosed.

Additional Changes

  • Recovered failures use shared structured reporting with bounded stack output.
  • Existing gossip and RPC recovery behavior is unchanged. Only its reporting is
    migrated to the shared helpers.
  • The final RBF cooperative-close update is cancellable so an abandoned caller
    cannot hold observer teardown open.
  • A custom analyzer requires every fn.RecoverPanic call to be deferred
    directly so Go recovery semantics cannot be bypassed accidentally.

Validation

  • Targeted fn, linter, discovery, funding, peer, pool, and RPC permission
    tests pass.
  • All affected packages pass under the race detector.
  • Focused peer, pool, and funding recovery tests pass under -race -count=20.
  • Tests verify peer goroutine exit, cooperative-close cleanup, lock
    reacquisition, funding reservation lock release, and synchronized reservation
    timestamp access.
  • The repository-wide native lint suite passes with zero issues.

@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
@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
@ziggie1984
ziggie1984 force-pushed the recover-backstops branch 2 times, most recently from 278c3c5 to 4080b75 Compare August 19, 2026 18:00
@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
Comment thread msgmux/msg_router.go Outdated

@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 Outdated
Comment thread discovery/gossiper.go
Comment thread htlcswitch/link.go Outdated
Comment thread funding/manager.go Outdated
Comment thread fn/stack.go
Comment thread funding/manager.go
Comment thread funding/manager.go Outdated
Comment thread fn/stack.go
Comment thread fn/stack.go Outdated
Comment thread peer/brontide.go
@ziggie1984 ziggie1984 moved this to Ready in v0.21 Aug 25, 2026
@starius

starius commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Can we make panic recovery opt-in, i.e. adding a flag to explicitly enable it, please?

There are two potential situations resulting in panics and we want recovery only in the first case:

  1. crash which could be avoided without leaving toxic state in the node, i.e. in this case the harm is from the crash itself. E.g. a bug in a parser of messages sent by a peer resulting in segfault on some inputs.
  2. crash which stops operation when an important invariant is broken. There are bugs for which it is better to crash than to leave the node in half-valid intermediate state. In this case a panic recovery might increase the attack surface.

Given we don't know what is the bug (if we knew, we would fix it), can we keep panic recovery behind a flag? Is there is a bug of (1) type and it is exploited in the wild, users can just restart the node with the panic recovery flag. But if (1) does not happen (most of the time), then we do not enable attacks of type (2).

@ziggie1984 ziggie1984 moved this from Ready to In review in v0.21 Aug 27, 2026
@ziggie1984 ziggie1984 changed the title multi: standardize panic recovery at async boundaries multi: contain panics at fail-stop async boundaries Aug 27, 2026
@ziggie1984
ziggie1984 force-pushed the recover-backstops branch 8 times, most recently from f35070c to 311c659 Compare August 27, 2026 21:41
@ziggie1984

ziggie1984 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

@starius Thanks, this is a fair concern. We took a different approach from making recovery configurable.

We narrowed the PR to recovery boundaries where the affected state is either fully retired or the operation can safely stop and be retried. We removed recovery from stateful workflows that could continue after an unknown subset of side effects.

We prefer this over a flag because configuration cannot make an unsafe recovery boundary safe, and operators may not know that they need to enable it before a vulnerability is exploited. We also added fault-injection and race tests covering teardown, lock release, and goroutine cleanup.

Does this narrower boundary-based approach address your concern?

@ziggie1984
ziggie1984 force-pushed the recover-backstops branch 2 times, most recently from d5bdd81 to 5278a3f Compare August 27, 2026 22:42
Add RecoverPanic for direct use at selected deferred recovery boundaries.
Preserve the original panic value, capture an 8 KiB bounded stack trace,
and invoke a caller-provided callback so each subsystem controls its own
cleanup. Contain callback panics and emit a best-effort fallback report.

Add structured logging helpers with consistent panic type, value, stack,
and caller-provided attributes. Provide a debug-stack variant for remotely
triggerable paths and prevent logging failures from interrupting cleanup.

Add a custom analyzer that requires RecoverPanic to be deferred directly.
This is necessary because recover only works from the deferred frame.

Test recovery, ordinary returns, named-return updates, stack truncation,
structured attributes, raw panic values, callback failures, logging
failures, and analyzer diagnostics.
Contain panics raised while the read loop reads or dispatches an incoming
peer message. Report the panic and disconnect the affected peer through the
normal cleanup path.

Keep discovery-stream ownership in an outer frame and place recovery in the
inner message loop. Recovery therefore closes the peer quit signal before
stream shutdown waits for its consumer, allowing blocked gossip delivery to
exit. Stop the idle timer when the handler returns.

Construct the disconnect error from the panic type rather than formatting
its value. This prevents a broken String or Error method from interrupting
peer teardown.

Add regression tests for an unsafe panic-value String method and a discovery
consumer blocked on peer shutdown. Verify that the peer disconnects, stream
shutdown completes, and all peer goroutines exit.
Execute each submitted task through runTask. If a task panics, report the
panic, deliver an error wrapping ErrWorkerTaskPanic exactly once, and tell
the caller to retire the affected worker instead of reusing its state.

Let the worker's existing defers clean up its state, wait-group entry, and
semaphore slot so the pool remains available for later work. Add a POOL
subsystem logger for structured panic reports.

Test panics in newly spawned and reused workers, unsafe panic-value
formatting, and full pool saturation after recovery to prove that worker
slots are not leaked.
Add recovery boundaries to the legacy close-message and channel-flush entry
points owned by channelManager. Report the channel ID and route a recovered
panic through the ordinary close error handler at most once.

The error path resets the in-memory close state, removes the active closer,
fails a local close request, and disconnects the peer. If recovery happens
before that handler is available, or the handler itself panics, contain the
secondary failure and still disconnect the peer.

Keep persisted ChanStatusCoopBroadcasted state intact so an already
broadcast closing transaction remains recoverable after restart.

Validate the final closing transaction before removing live channel state. An
unfinished closer now reports its state error and returns without dereferencing
a nil transaction, wiping the channel, or deleting its active closer.

Make local failure notification best effort. Remove the active closer before
the non-blocking error send so a full or closed caller channel cannot prevent
mandatory cleanup and peer teardown. Document the bounded-channel assumption
that the legacy pending update still relies on.

Add tests for both recovery entry points, secondary failure reporting, full
and closed error channels, closer cleanup, peer goroutine exit, release of the
affected channel mutex, and the unfinished finalization path.
Move the existing gossiper and RPC panic reports to the shared structured
logging and bounded-stack helpers without adding or moving recovery
boundaries.

Keep remotely triggerable gossip stacks at debug level to limit error-log
amplification. Continue reporting RPC stacks at error level with the RPC
method as structured context.

Remove the duplicate rpcperms stack truncation code and retain its coverage
in the shared fn tests.
Send the final cooperative-close update through a helper that selects on the
client update channel, request cancellation, and peer shutdown. This prevents
a full bounded update channel from blocking close-observer teardown.

Continue to remove the active channel closer after the helper returns,
whether the update was delivered or teardown cancelled the send.

Add regression tests for a full update channel followed by request
cancellation or peer shutdown.
Contain panics in the periodic zombie-reservation sweep so one malformed
reservation does not terminate the funding coordinator. Report the failed
operation and return to the coordinator loop so a later tick can retry.

Move reservation discovery into findZombieReservations and defer release of
resMtx. Replace the split lock check and unlocked timestamp read with an
isExpired method that reads lastUpdated under updateMtx. Preserve the existing
PSBT reservation exclusion.

Add tests that trigger a malformed-reservation panic, verify that resMtx is
released, and exercise concurrent timestamp updates and expiry checks under
the race detector.
Document the selected panic-containment boundaries in the 0.20.4 and 0.21.3
release notes. Explain that recovery is limited to execution units that can
be retired safely, including peers, pool workers, legacy close negotiations,
and periodic zombie-reservation sweeps.

Mention the shared bounded-stack reporting used by recovered failures.
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: In review

Development

Successfully merging this pull request may close these issues.

4 participants