Context
PR #10895 adds cleanup for on-chain held HTLC entries via
preimageBeacon.CancelSubscription -> RemoveOnChainIntercept.
That cleanup path is currently synchronous:
CancelSubscription calls p.cancelInterceptor(inKey).
- In production,
p.cancelInterceptor is
InterceptableSwitch.RemoveOnChainIntercept.
RemoveOnChainIntercept sends the circuit key over the unbuffered
onchainInterceptDone channel.
- The send completes only when the
InterceptableSwitch event loop receives
it.
Liveness concern
The same switch event loop also performs synchronous interceptor delivery:
- An interceptor client connects or reconnects.
setInterceptor replays held HTLCs with
s.heldHtlcSet.forEach(s.sendForward).
sendForward calls the routerrpc interceptor callback.
- The routerrpc callback calls
stream.Send.
- If the external interceptor client stalls or stops reading,
stream.Send
can block.
While blocked in stream.Send, the switch loop cannot receive
onchainInterceptDone. If a contractcourt resolver then cancels a witness
subscription, resolver cleanup can block behind progress of the external
interceptor stream.
This is not a remote peer exploit and not a new funds-loss path by itself; it
is a robustness/liveness issue with an operator-controlled interceptor service.
The switch loop is already blocked by a stalled interceptor, but synchronous
resolver cleanup increases the blast radius by pinning resolver goroutines too.
Possible follow-ups
Short-term options:
- Make on-chain intercept cleanup asynchronous from the witness subscription
cancellation path, so resolver cleanup does not wait on the switch loop.
- Add a dedicated cleanup queue with explicit shutdown semantics.
Larger design option:
- Decouple interceptor
stream.Send from the InterceptableSwitch event loop
so stalled external clients cannot block unrelated switch-loop work.
References
Context
PR #10895 adds cleanup for on-chain held HTLC entries via
preimageBeacon.CancelSubscription->RemoveOnChainIntercept.That cleanup path is currently synchronous:
CancelSubscriptioncallsp.cancelInterceptor(inKey).p.cancelInterceptorisInterceptableSwitch.RemoveOnChainIntercept.RemoveOnChainInterceptsends the circuit key over the unbufferedonchainInterceptDonechannel.InterceptableSwitchevent loop receivesit.
Liveness concern
The same switch event loop also performs synchronous interceptor delivery:
setInterceptorreplays held HTLCs withs.heldHtlcSet.forEach(s.sendForward).sendForwardcalls the routerrpc interceptor callback.stream.Send.stream.Sendcan block.
While blocked in
stream.Send, the switch loop cannot receiveonchainInterceptDone. If a contractcourt resolver then cancels a witnesssubscription, resolver cleanup can block behind progress of the external
interceptor stream.
This is not a remote peer exploit and not a new funds-loss path by itself; it
is a robustness/liveness issue with an operator-controlled interceptor service.
The switch loop is already blocked by a stalled interceptor, but synchronous
resolver cleanup increases the blast radius by pinning resolver goroutines too.
Possible follow-ups
Short-term options:
cancellation path, so resolver cleanup does not wait on the switch loop.
Larger design option:
stream.Sendfrom theInterceptableSwitchevent loopso stalled external clients cannot block unrelated switch-loop work.
References
htlcswitch: separate onchain and offchain intercpeted HTLCs cleanly #10895 (comment)