Derive shutdown scripts without blocking on wallet persistence - #1011
Derive shutdown scripts without blocking on wallet persistence#1011jkczyz wants to merge 5 commits into
Conversation
LDK invokes the sync `SignerProvider::get_shutdown_scriptpubkey` and `get_destination_script` callbacks on runtime worker threads while holding channel locks, e.g. when accepting an inbound channel. Blocking there on wallet persistence could deadlock the runtime: the parked callback still held the channel locks, other tasks blocking synchronously on those locks captured the remaining worker cores, and the persistence future the callback waited on could then never be polled. Observed as a permanent hang of integration test runs. Instead, reveal the address without waiting and persist the staged change set in the background. A persistence failure therefore no longer rejects the channel; if the node crashes before the flush lands, the revealed index may be handed out again after restart, which BDK's keychain lookahead tolerates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
👋 Thanks for assigning @tnull as a reviewer! |
tnull
left a comment
There was a problem hiding this comment.
Looks good I think, feel free to undraft.
| /// the remaining workers, leaving none to drive the persistence future the callback waits on. | ||
| /// | ||
| /// If the node crashes before the background flush lands, the revealed index is lost and the | ||
| /// address may be handed out again after restart. BDK's keychain lookahead still detects any |
There was a problem hiding this comment.
Hmm, so looking at this again one issue is that BDK's lookahead is not used for incremental syncs - there they really just check (all) previously revealed spks. So, if we fail persistence in the background task, we will miss a derived SPK, leading to address reuse (maybe acceptable in this narrow case) and only discovering transactions once it has been reused (maybe not acceptable as it could be considered funds loss, even if temporarily/recoverable - the user might not realize it's recoverable).
An alternative approach would be to create an AddressCache that is refreshed and persisted in the background, and returns Err(()) if no addresses are available. Something like:
- At startup, derive perhaps 64 external addresses and persist the entire derivation range.
- Only after persistence succeeds, place those addresses in an in-memory queue.
- The synchronous signer callbacks pop an address without blocking.
- Refill in the background below a low-water mark, publishing new addresses only after persistence succeeds.
- If storage remains unavailable and the pool empties, return Err(()) and fail closed.
On top, as an additional robustness measure we might want to consider adding a wallet_persistence_pending dirty marker than would trigger a full scan on next restart whenever we're not certain all persistence operations have cleanly succeeded before stopping?
Thoughs?
There was a problem hiding this comment.
Yeah, the cache is a better approach. Claude implemented it with some deviations:
🤖 Good catch — you're right, incremental syncs only query the revealed SPKs of the persisted wallet, so a crash before the deferred flush lands would leave the handed-out script unwatched. That's not acceptable, even if recoverable. I reworked the PR along the lines you sketched.
The callbacks now pop from a pool of addresses whose reveal is already persisted, and fail closed (Err(())) if the pool is empty. Newly revealed addresses are only published for handout after their change set persists; on failure they're retained and retried by the next refill, so no index is burned.
Two deviations from your sketch, both aimed at keeping the revealed-but-unused window small, since every pooled address widens what incremental syncs must watch:
- Pool size 16 rather than 64, refilled after every handout rather than at a low-water mark. Since the refill runs after each pop, the pool size only bounds how many channel opens persistence can miss in a row before opens fail closed — 16 covers 8 consecutive opens (destination + shutdown script each), which seems plenty for an outage budget.
- The pool's derivation indices are persisted (under
bdk_wallet/address_pool) and reloaded on startup, after validating them against the wallet's last revealed index. Without that, every restart would burn a pool's worth of fresh indices, permanently growing the watched set. The record is written before the reveals' change set, so a crash between the two writes just re-derives the same indices on restart instead of stranding them (a unit test replays a reload from every store-write boundary to check this).
On the wallet_persistence_pending dirty marker: with the pool, unclean shutdown no longer risks an unwatched handed-out script, so I left it out of this PR — but it could still make sense as a general robustness measure for other in-flight wallet writes. Happy to explore it as a follow-up if you think it's worth it.
There was a problem hiding this comment.
Codex:
The safer design is one FIFO external-address allocator with two consumption modes:
- Synchronous signer callbacks pop immediately and accept the documented crash-reuse window.
- Async public address requests pop from the same pool but persist the dequeue/refill before returning, preserving their current no-reuse guarantee.
There was a problem hiding this comment.
🤖 Implemented exactly this split: the signer callbacks only pop and accept the documented bounded reuse across a crash, while get_new_address pops from the same pool but persists the dequeue before returning. Both consume from a single FIFO pool in reveal order.
…rsistence Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tnull
left a comment
There was a problem hiding this comment.
I think if we going to do the address pool, we should redirect all calls to get_new_address through it, to avoid unnecessarily advancing the descriptor while still having unused ones lying around.
| /// unused tail. | ||
| /// | ||
| /// [`DEFAULT_FULL_SCAN_STOP_GAP`]: crate::config::DEFAULT_FULL_SCAN_STOP_GAP | ||
| pub(crate) const ADDRESS_POOL_TARGET_SIZE: usize = 16; |
There was a problem hiding this comment.
Codex:
[P1] Address pool can hide funds during seed recovery — /home/tnull/worktrees/ldk-node/pr-1011-upstream-20260807/src/wallet/mod.rs:96
The pool reveals 16 unused external addresses. A configured stop gap of 16 or less will therefore miss the first ordinary receive address during seed-only recovery. Even with the default gap of 20, two channel opens consume four still-unused scripts and refill through index 19; the next
receive address is index 20, beyond the full scan. The recovery strategy needs to account for pooled and handed-out-but-unused scripts, with a seed-restoration regression test.
There was a problem hiding this comment.
🤖 Good catch. Full scans now extend the configured stop gap by the pool size (ADDRESS_POOL_SIZE), and get_new_address serves the oldest revealed address first. The seed-restore test isn't new — it failed against the unfixed pool and now pins the extended gap on both the Esplora and Electrum paths.
There's still a residual on the restore path: a wallet restored from seed can hand out addresses a previous installation already served. That's bounded address reuse, not a loss-of-funds issue, and it's documented. Happy to make the pool skip used indices after the first scan as a follow-up if you'd prefer.
| let popped = self.address_pool.lock().expect("lock").available.pop_front(); | ||
|
|
||
| let wallet = Arc::clone(self); | ||
| self.runtime.spawn_background_task(async move { |
There was a problem hiding this comment.
Codex:
[P2] Refill tasks accumulate for the node’s lifetime — /home/tnull/worktrees/ldk-node/pr-1011-upstream-20260807/src/wallet/mod.rs:612
Every callback adds a task to the runtime’s JoinSet, but that set is only drained during shutdown at /home/tnull/worktrees/ldk-node/pr-1011-upstream-20260807/src/runtime.rs:178. Completed refills therefore remain tracked—two per channel—and empty-pool failures still schedule more tasks.
Refill work should be coalesced into one in-flight task or completed tasks should be reaped.
This could probably be a spawn_cancellable_background_task? (for which we'll land a fix for the accumulation in #997)
There was a problem hiding this comment.
🤖 Done — refills now spawn on the cancellable set. This needed one accompanying fix: an abort could drop a refill's taken reveals, so the refill now stages them with the persister in the same synchronous critical section that takes them from the wallet, with a regression test covering it. Note that completed tasks still sit in the cancellable set until shutdown (#997 will reap them continuously) — so the practical gain today is that shutdown aborts an in-flight refill immediately instead of waiting on it.
Handing out fresh reveals while pooled addresses sit unused fragments the wallet's revealed range: every request pushes the first on-chain use further past a growing unused tail, which a from-seed restore's full scan must step over. Serving all external handouts from the pool front instead consumes the oldest revealed index first, so on-chain use compacts the unused window back down to roughly the pool size. Unlike the sync signer callbacks, these callers may wait on persistence, so the handout is only returned once the rewritten pool record is durable, preserving the previous no-reuse-across-restart guarantee for user-facing addresses. On persistence failure the popped address returns to the pool, unhanded-out. Since every node now hands out the first derivation indices rather than minting past the pool, addresses funded early are covered by any same-seed node's initial pool reveal; the force-full-scan test's previously-unknown addresses must accordingly lie beyond the pool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refills ran on the joined background task set, which shutdown waits on — a refill wedged on an unresponsive store holds up every stop for the per-task timeout. Spawn them on the cancellable set instead, which the node aborts at shutdown. Completed tasks still accumulate there until shutdown; the runtime rework in lightningdevkit#997 is what reaps them continuously. Aborting a task mid-refill must not lose wallet state: the refill used to hold the taken change set in a local across its store writes, so an abort landing there dropped reveals that were already taken from the wallet's staged state — a later refill would then publish addresses no persisted wallet state covers, recreating the unwatched-script problem the pool exists to prevent. The refill now stages the taken change set with the persister in the same critical section that takes it from the wallet, so an abort at any await leaves the reveals pending for the next persist call to flush. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pool keeps a fixed number of addresses standing revealed-but-unused, and a wallet restored from seed alone has no record of them: they count against a full scan's stop gap exactly like a genuine unused gap, so a restore could stop scanning before reaching funds that lie past them — with the default gap of 20, two channel opens' worth of handouts sufficed. Extend the effective stop gap by the pool size so the pool's tail can never exhaust the configured gap on its own, and document the pool size as a public constant alongside the stop-gap bounds. Handed-out scripts that have yet to appear on-chain (e.g. shutdown scripts of open channels) still count against the configured gap, as they did before the pool existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jkczyz
left a comment
There was a problem hiding this comment.
I think if we going to do the address pool, we should redirect all calls to
get_new_addressthrough it, to avoid unnecessarily advancing the descriptor while still having unused ones lying around.
🤖 Done — get_new_address now serves from the front of the pool and persists the dequeue before returning. One caveat: every handout still nets one fresh reveal; what this buys is allocation in reveal order, which keeps the unused window roughly pool-sized.
| /// unused tail. | ||
| /// | ||
| /// [`DEFAULT_FULL_SCAN_STOP_GAP`]: crate::config::DEFAULT_FULL_SCAN_STOP_GAP | ||
| pub(crate) const ADDRESS_POOL_TARGET_SIZE: usize = 16; |
There was a problem hiding this comment.
🤖 Good catch. Full scans now extend the configured stop gap by the pool size (ADDRESS_POOL_SIZE), and get_new_address serves the oldest revealed address first. The seed-restore test isn't new — it failed against the unfixed pool and now pins the extended gap on both the Esplora and Electrum paths.
There's still a residual on the restore path: a wallet restored from seed can hand out addresses a previous installation already served. That's bounded address reuse, not a loss-of-funds issue, and it's documented. Happy to make the pool skip used indices after the first scan as a follow-up if you'd prefer.
| let popped = self.address_pool.lock().expect("lock").available.pop_front(); | ||
|
|
||
| let wallet = Arc::clone(self); | ||
| self.runtime.spawn_background_task(async move { |
There was a problem hiding this comment.
🤖 Done — refills now spawn on the cancellable set. This needed one accompanying fix: an abort could drop a refill's taken reveals, so the refill now stages them with the persister in the same synchronous critical section that takes them from the wallet, with a regression test covering it. Note that completed tasks still sit in the cancellable set until shutdown (#997 will reap them continuously) — so the practical gain today is that shutdown aborts an in-flight refill immediately instead of waiting on it.
| /// the remaining workers, leaving none to drive the persistence future the callback waits on. | ||
| /// | ||
| /// If the node crashes before the background flush lands, the revealed index is lost and the | ||
| /// address may be handed out again after restart. BDK's keychain lookahead still detects any |
There was a problem hiding this comment.
🤖 Implemented exactly this split: the signer callbacks only pop and accept the documented bounded reuse across a crash, while get_new_address pops from the same pool but persists the dequeue before returning. Both consume from a single FIFO pool in reveal order.
Fixes #1010
LDK invokes the sync
SignerProvider::get_shutdown_scriptpubkeyandget_destination_scriptcallbacks on runtime worker threads while holding channel locks, e.g. from the event handler when accepting an inbound channel. These callbacks calledRuntime::block_onto await wallet persistence, which can deadlock the runtime:block_onwhile holding the per-peer channelMutex(its worker core is handed off viablock_in_place).lightning-net-tokiotask whosePeerManager::process_eventsblocks synchronously on that sameMutex, capturing the core.Observed as permanent hangs of
integration_tests_rustruns under load (the tests run the node on a single-worker runtime, where one captured core is fatal). See #1010 for full thread samples and analysis.Instead of waiting on persistence, the wallet now keeps a small pool of pre-revealed addresses whose reveal is already persisted. The sync callbacks pop from the pool — no persistence work at all — and schedule a background refill. The refill reveals replacement addresses, persists the wallet change set, and only then publishes them for handout, so a handed-out script is always covered by persisted wallet state and a crash can never leave it unwatched by incremental chain syncs. The pool's derivation indices are persisted alongside the wallet, so a restart reloads the pooled addresses instead of revealing (and burning) fresh indices on every run.
Semantics worth calling out:
The gated-store test reproduces the deadlock: without the fix the acceptor wedges in
get_shutdown_scriptpubkeyand the open times out; with it the open completes from the pool and the refill's persistence lands once the store recovers. A restart test asserts that rebuilding a node from the same store performs no wallet writes (the pool is reloaded, not re-revealed) and that a channel open is served from the reloaded pool. Unit tests cover the publish-only-after-persist ordering, refill retry after a persistence failure without burning an extra index, validation of the persisted record on reload, crash replay from every store-write boundary, degradation to an empty pool on an undecodable record, and the fail-closed callbacks.