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 makes Lightning node shutdown deterministic and avoids teardown during brief background transitions. 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 management and makes lifecycle teardown non-cancellable. |
| app/src/main/java/to/bitkit/services/LightningService.kt | Stops the node and releases its native handle through a bounded asynchronous teardown path. |
| app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | Routes lifecycle-driven shutdown through the repository debounce. |
| app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt | Adds tests for debounce timing, foreground cancellation, lifecycle state, and stop-start ordering. |
| app/src/test/java/to/bitkit/services/LightningServiceTest.kt | Adds tests for native handle release across normal, cancelled, repeated, and failed stop paths. |
Reviews (2): Last reviewed commit: "fix: call release independently rather t..." | 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: |
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: