fix: harden ldk node teardown on stop#1100
Conversation
…the handle on the LDK queue instead of leaving it to the GC finalizer
Greptile SummaryThis PR hardens Lightning node shutdown and avoids unnecessary restarts during short background periods. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| app/src/main/java/to/bitkit/repositories/LightningRepo.kt | Adds synchronized deferred-stop handling and protects lifecycle teardown from cancellation. |
| app/src/main/java/to/bitkit/services/LightningService.kt | Releases native node resources deterministically with a bounded wait. |
| app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | Cancels deferred stops on foreground entry and debounces background stops. |
| app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt | Adds tests for deferred stopping, cancellation, lifecycle state, and teardown ordering. |
| app/src/test/java/to/bitkit/services/LightningServiceTest.kt | Adds tests for handle release, cancellation, stop failures, and repeated shutdown. |
| app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt | Adds coverage for cancelling a deferred stop while startup is active. |
Reviews (3): Last reviewed commit: "Merge branch 'master' into fix/node-stop..." | Re-trigger Greptile
This comment was marked as outdated.
This comment was marked as outdated.
iOS port assessment: not neededChecked this against the iOS teardown paths (
The one genuinely shared item is the underlying defect, synonymdev/ldk-node#94 — which this PR explicitly does not fix, it only narrows the timing window. That's a Rust-side fix and applies to both platforms whenever it lands upstream; it isn't something to port at the app layer. One minor, non-blocking note for the iOS side: |
ovitrif
left a comment
There was a problem hiding this comment.
I re-audited the teardown and lifecycle paths against the current head and reproduced the two inline races with focused coroutine tests.
ovitrif
left a comment
There was a problem hiding this comment.
I re-audited exact head 2955bd0 after the two earlier P1 fixes. Those cancellation and deferred-stop findings are resolved, and all 18 hosted checks plus the focused LightningServiceTest, LightningRepoTest, and WalletViewModelTest suites pass.
Two distinct high-priority native-lifecycle findings remain inline, so I’m leaving this as a neutral review. I also installed this head on the Android 17 / 16 KB emulator and completed short and long background/foreground smoke cycles without a crash or ANR. The preserved dev wallet’s configured local Electrum endpoint (10.0.2.2:60001) was unavailable, so that device pass did not exercise teardown from a fully running node.
| runSuspendCatching { node.destroy() } | ||
| .onFailure { Logger.warn("Node handle release error", it, context = TAG) } | ||
| } | ||
| withTimeoutOrNull(NODE_RELEASE_TIMEOUT) { release.join() } |
There was a problem hiding this comment.
I’m treating native destruction as part of the lifecycle gate here. After this timeout, stop() reports success and LightningRepo publishes Stopped, even though the old Rust node can still be draining for tens of seconds. Immediate restart paths can then build a replacement over the same LDK state, while wipeStorage() and graph-reset paths can mutate that storage before the old owner releases it. That recreates the overlapping native lifetime this PR is intended to remove. Please retain an explicit release gate that prevents rebuild and destructive storage work until destroy() completes, or cross a safe process-recovery boundary, and cover it with distinct dispatchers plus a controllably delayed destroy().
| // Teardown must not be abandoned midway: a cancelled caller would leave the rust node alive and | ||
| // let the GC free it later on the finalizer thread, racing the next node. This covers the whole | ||
| // body, including the listener join, so cancellation during listener cleanup cannot skip it. | ||
| suspend fun stop() = withContext(NonCancellable) { |
There was a problem hiding this comment.
I need this stop path to handle calls made from the listener itself. WakeNodeWorker.deliver() calls lightningRepo.stop() synchronously from its registered LDK event handler while the app is backgrounded. That callback runs inside listenerJob, so listenerJob.cancelAndJoin() waits for the currently executing job to finish while that job is suspended inside stop(). Teardown never reaches node.stop() or destroy(), deliverSignal never completes, and the lifecycle mutex remains held. Please move handler-triggered teardown outside the listener job or avoid joining the current listener while preserving the external-caller cancellation guarantee, with a regression test whose event callback requests stop.
…the LDK event handler, which runs on listenerJob, so listenerJob.cancelAndJoin() waits on itself
Relates to #982 , #986
Upstream issue: synonymdev/ldk-node#94
This PR:
Description
Play Vitals reports a
SIGABRTon mainnet as the top production crash cluster. Symbolicating it locally against the unstrippedlibldk_node.sofor the shippedldk-node-androidversion resolved every frame and gave a clear cause:The defect itself is upstream: ldk-node's chain-sync task drops the last reference to the Electrum client, which owns the tokio runtime, while running on a worker thread of that same runtime. Release Rust aborts on panic, so the process dies with no catchable error. That is filed as synonymdev/ldk-node#94 and this PR does not fix it.
What this PR fixes is the Android side that made the window wide and the timing unpredictable. Stopping the node cleared the reference but never destroyed the handle, so the native node stayed alive until the garbage collector freed it on the finalizer thread at an arbitrary later moment, potentially while a new node was already running. The handle is now released as part of the stop itself.
The release is bounded rather than unconditionally synchronous.
free_nodereturns in tens of milliseconds for a healthy node, but blocks for tens of seconds when a failed start left the node's background tasks wedged (measured: ~38 ms healthy vs ~40 s wedged). So the release runs on the IO dispatcher and the stop waits on it only up to a short timeout: on the healthy path teardown still finishes before the next startup begins, and on the wedged path the stop returns and lets the release drain in the background instead of blocking the whole lifecycle — including any recovery restart — behind it. This bound is not just an optimization: an earlier revision that always waited inline turned a 0.6s recovery stop into a 40s one and timed out the Electrum "wrong server" E2E test; see the note below.Teardown was also abandonable. The stop runs through a cancellable context and was triggered from a ViewModel scope, so an activity finishing mid-stop left a live native node orphaned with the lifecycle state stranded. The teardown is now non-cancellable once committed.
Reading ldk-node showed the stop path could not fail the way the code assumed: it reports an error only when the node is already stopped, swallows and logs every internal failure including the background-task shutdown timeouts, and then returns success. A failed stop therefore no longer rethrows and skips the release.
Finally, backgrounding the app stopped the node immediately, so a short task switch cost a full stop plus a full rebuild. The stop from backgrounding is now deferred by a few seconds and cancelled if the app returns, so brief switches leave the node untouched. Only the backgrounding path is deferred; the notification stop action, service teardown, notification worker, and restore migration all still stop immediately. The deferred stop is owned by the repository's process-lifetime scope rather than a ViewModel scope, so it cannot be silently dropped when the activity goes away.
Preview
2-seconds-pause.webm
long-pause-with-channel.webm
QA Notes
Manual Tests
regression:Background Payments → enable Get paid when Bitkit is closed and Keep Bitkit active in background → background the app: node keeps running, no teardown.regression:Node notification → tap Stop: node stops immediately and the app task is removed.regression:Relaunch after a notification stop: node rebuilds and reaches started.regression:Settings → Lightning → restart node, and Electrum/RGS server change: node stops and restarts normally.Automated Checks
LightningServiceTest.kt.LightningServiceTest.kt. All three fail against the previous implementation.LightningRepoTest.kt.LightningRepoTest.kt.Stopping node…→Node stopped→Building node…→Node started, confirming teardown completes before startup on the healthy path:Fatal signal,SIGABRTor tombstone appeared in logcat across any of the runs above, including the stop paths that now drive the native handle release.ssl://pointed at a plain-TCP listener), which is the@settings_10"wrong Electrum server" condition. Before the release bound, the post-failure recovery stop took ~40s (past the 30s E2E toast wait) becausefree_nodeblocked on wedged background tasks; with the bound it returns in ~1s and the error surfaces. Timeline after the fix: