Skip to content

Release completed cancellable tasks - #997

Open
tnull wants to merge 10 commits into
lightningdevkit:mainfrom
tnull:2026-07-cancel-task-leaks
Open

Release completed cancellable tasks#997
tnull wants to merge 10 commits into
lightningdevkit:mainfrom
tnull:2026-07-cancel-task-leaks

Conversation

@tnull

@tnull tnull commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Avoid retaining completed Tokio task allocations for the node lifetime while preserving shutdown cancellation and restart semantics. This fixes a considerable memory leak as previously we'd retain the JoinSeted task's allocations until stop/join_next, as tokio thankfully only notes in the https://docs.rs/tokio-util/latest/tokio_util/task/task_tracker/struct.TaskTracker.html#comparison-to-joinset.

Thanks to @elnosh for reporting this leak.

@ldk-reviews-bot

ldk-reviews-bot commented Jul 20, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @tankyleo as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-reviews-bot
ldk-reviews-bot requested a review from tankyleo July 20, 2026 09:00
@tnull
tnull force-pushed the 2026-07-cancel-task-leaks branch from 3553a89 to fb5fbc8 Compare July 21, 2026 09:05
@tnull

tnull commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased after #956 landed.

@elnosh elnosh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ACK fix in 08d3b5d

I'm less familiar with the code in some of the other commits, specially in liquidity. Some of the changes implementing the different Guards took me quite a bit to understand so I would've appreciated some comments but they look correct. Specifically:

  • reason for PendingRequestGuard needing the tokens for the pending requests in liquidity/mod.rs
  • PendingRequest in liquidity separating the owner Sender from the other followers Vec<oneshot::Sender<T>>. In connection.rs there's just one vec for all the senders.

These make sense now but not particularly obvious so adding comments is just a suggestion, I'm fine with it as-is

Comment thread src/connection.rs Outdated
Comment on lines 301 to 305

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

similar to fb5fbc8 this debug_assert could be removed as receiver from this pending connection attempt might have been dropped.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Dropped.

@tankyleo tankyleo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So far just looked at the first commit, will continue tomorrow

Comment thread src/runtime.rs Outdated
tasks: JoinSet<()>,
tasks: TaskTracker,
cancellation_token: CancellationToken,
accepting_tasks: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can delete this field here, and track is_closed on TaskTracker instead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

Comment thread src/runtime.rs Outdated
let mut cancellable_background_tasks =
self.cancellable_background_tasks.lock().expect("lock");
if cancellable_background_tasks.cancellation_token.is_cancelled() {
debug_assert!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe this debug_assert is reachable here: further below we cancel the token, then drop the lock, then wait. So here the token could be canceled, but the tasks not yet empty.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, it's def. not reachable from the current callsites as they are serialized.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agreed the current callsites are serialized. Might be good to not rely on this external lock for serialization, I vibed with codex on this issue, here's the patch it came up with below. Your call if you want to incorporate this or leave the PR as is :)

    Isolate cancellable task generations

    Replace cancelled cancellable-task state as a unit so a restart cannot reopen the tracker being drained by an in-flight abort.

    AI-Assisted-By: OpenAI Codex

diff --git a/src/runtime.rs b/src/runtime.rs
index d4ea7ce..3fe04d1 100644
--- a/src/runtime.rs
+++ b/src/runtime.rs
@@ -146,14 +146,10 @@ impl Runtime {
                let mut cancellable_background_tasks =
                        self.cancellable_background_tasks.lock().expect("lock");
                if cancellable_background_tasks.cancellation_token.is_cancelled() {
-                       debug_assert!(
-                               cancellable_background_tasks.tasks.is_empty(),
-                               "Expected all cancellable background tasks to be stopped"
-                       );
-                       cancellable_background_tasks.cancellation_token = CancellationToken::new();
+                       // An abort may still be waiting on a clone of the previous task tracker. Start a new
+                       // generation instead of reopening that tracker underneath the in-flight wait.
+                       *cancellable_background_tasks = CancellableBackgroundTasks::new();
                }
-               cancellable_background_tasks.tasks.reopen();
-               cancellable_background_tasks.accepting_tasks = true;
        }

        pub fn spawn_background_processor_task<F>(&self, future: F)

Comment thread src/runtime.rs
debug_assert!(tasks.len() > 0, "Expected some cancellable background_tasks");
tasks.abort_all();
self.block_on(async { while let Some(_) = tasks.join_next().await {} })
self.block_on(tasks.wait())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IIRC while we are waiting on tasks.wait, we could call allow_cancellable_background_task_spawns, reopen the TaskTracker, and thus tasks.wait() would never finish.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, yes, if we only look at Runtime alone, but note that we are always acquiring the is_running lock in start/stop. Let me know if you think it's crucial to make Runtime more robust by itself.

@tankyleo tankyleo Jul 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

let me know what you think of the patch above, it would address this here too.

@tnull tnull added this to the 0.8 milestone Jul 24, 2026
Comment thread src/connection.rs
Self { pending_connections, node_id, active: true }
}

fn disarm(&mut self) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Is this really needed ? On drop, we'd briefly lock the pending connections, see that we have no subscribers for the node_id, and do nothing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes. Although result propagation normally removes the entry, another thread could register a replacement after the mutex is released but before this guard drops. Disarming prevents the old guard from removing that new attempt.

Comment thread src/connection.rs
drop(connection_guard);

assert!(
pending_connections.lock().expect("lock").is_empty(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Should we also make sure that key-values for a different node_id are not dropped ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I’d prefer to leave this out since removal is explicitly scoped by node_id, and the existing test covers the relevant cancellation behavior.

Comment thread src/liquidity/mod.rs
drop(request_guard);

assert!(
pending_requests.lock().expect("lock").is_empty(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Similar here perhaps some coverage to make sure we don't drop requests with a different request key.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

See above, would also prefer to leave it out for now.

Comment thread src/runtime.rs
Comment thread src/liquidity/mod.rs
Comment thread src/chain/bitcoind.rs Outdated
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Aug 7, 2026
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>
@tnull
tnull force-pushed the 2026-07-cancel-task-leaks branch from fb5fbc8 to a4461ac Compare August 10, 2026 11:11
tnull added 10 commits August 10, 2026 13:13
Avoid retaining completed Tokio task allocations for the node
lifetime while preserving shutdown cancellation and restart semantics.

Co-Authored-By: HAL 9000
Check the tracker directly instead of duplicating spawn state.

This keeps task admission aligned with shutdown tracking.

Co-Authored-By: HAL 9000
Replace closed task state as a unit so a restart cannot reopen the
tracker being drained by an in-flight shutdown.

Co-Authored-By: HAL 9000
Signal cancellation when cancellable task state is dropped so
detached tasks cannot outlive a node that fails during startup.

Co-Authored-By: HAL 9000
Remove pending LSPS request state when callers time out or are
cancelled so unresponsive services cannot grow request maps.

Co-Authored-By: HAL 9000
Treat responses for expired requests as expected so delayed LSP
messages do not panic the liquidity event loop.

Co-Authored-By: HAL 9000
Clear per-peer connection state when the leading task is cancelled
so later callers can retry and existing subscribers do not hang.

Co-Authored-By: HAL 9000
Connection subscribers may cancel before a shared attempt finishes.

Treat the failed send as expected instead of panicking in debug builds.

Co-Authored-By: HAL 9000
Restore wallet sync status and notify waiting callers when the task
performing a sync is cancelled, allowing later sync attempts to run.

Co-Authored-By: HAL 9000
Wait for an active manual sync and retry initial ownership so startup
races do not permanently stop background polling.

Co-Authored-By: HAL 9000
@tnull
tnull force-pushed the 2026-07-cancel-task-leaks branch from a4461ac to 2c1682e Compare August 10, 2026 11:17
@tnull
tnull requested a review from tankyleo August 10, 2026 11:21
@tnull

tnull commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Now updated to address pending feedback. This is now great quite a bit in scope, which I'm not super happy with. But let me know what you think.

@Jolah1

Jolah1 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Reviewed runtime.rs, connection.rs, chain/mod.rs, liquidity/mod.rs and both LSPS clients - looks correct to me.

One thing worth a second look: PendingRequest backs six maps, but only pending_lsps0_discovery ever touches followers (pushed at liquidity/mod.rs:477, drained at :432). The identifier doesn't appear in lsps1.rs or lsps2.rs at
all — their five handlers do request.sender.send(..) and drop request.followers. Harmless today, since each LSPS1/LSPS2 request has a unique LSPSRequestId and nothing pushes followers there. But if someone later dedups

e.g.check_order_status, those followers would never be notified and it'd surface as an LSP timeout rather than a local bug. Maybe drain them in those handlers too, or split the type.

On the beta failure: it died at tests/common/mod.rs:865 in premine_blocks, where exponential_backoff_poll allows only ~9.2s (20 tries, 512ms cap) for bitcoind to generate and electrs to index 101 blocks. build (self-hosted, 1.85.0) passed on the same runner, so it's probably worth a re-run before digging.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants