fix: harden ldk node teardown on stop - #1100
Conversation
Greptile SummaryThis PR hardens LDK node shutdown and restart behavior. 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 stops, release-aware lifecycle work, serialized server changes, and Electrum validation. |
| app/src/main/java/to/bitkit/services/LightningService.kt | Adds bounded native release, release gates, non-cancellable teardown, and safer event-listener lifecycle handling. |
| app/src/main/java/to/bitkit/services/ElectrumProbeService.kt | Adds bounded TCP, TLS, protocol, and network validation for Electrum servers. |
| app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | Cancels deferred stops before startup guards and defers ordinary background teardown. |
Reviews (6): Last reviewed commit: "fix: probe electrum server before node r..." | 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.
|
checking the best approach for the electrum restart taking too long https://github.com/synonymdev/bitkit-android/actions/runs/30292125774/job/90105118405?pr=1100 |
|
everything re-checked and description updated |
…the handle on the LDK queue instead of leaving it to the GC finalizer
…the LDK event handler, which runs on listenerJob, so listenerJob.cancelAndJoin() waits on itself
76974b0 to
140ba5e
Compare
ovitrif
left a comment
There was a problem hiding this comment.
I verified the contiguous 685d478..4a6968b fix delta. The Electrum probe now rejects mismatched id values, non-null error responses, and missing result values before node teardown, with regression coverage for each path. My previous blocker is resolved, and all 18 hosted checks are green.
Relates to #982 , #986
Fixes #1078
Upstream issue: synonymdev/ldk-node#94
This PR hardens LDK node teardown so it cannot orphan, deadlock, or overlap the native node:
free_nodecan never brick startupDescription
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 teardown that made the window wide, non-deterministic, and prone to overlapping native lifetimes.
Deterministic, bounded, non-cancellable release
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, not 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 — long enough that a healthy release completes inline, but bounded so the lifecycle state (Stopped) is published promptly rather than blocking behind a wedged drain.Teardown was also abandonable. The stop ran 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 whole teardown — including the listener cleanup join at the top — is now non-cancellable once committed. Reading ldk-node also showed the stop path could not fail the way the code assumed: it reports an error only when the node is already stopped and swallows every internal failure, so a failed stop no longer rethrows and skips the release.
Listener lifecycle safety
WakeNodeWorkercallsstop()synchronously from its registered LDK event handler, which runs on the listener job.listenerJob.cancelAndJoin()then waited on the very job it was running on — a self-join. The same shape applied tostartEventListener()'s re-arm. A stop from inside a handler now skips joining its own job (detected viacurrentCoroutineContext(), not the shadowing class-scopecoroutineContext), and a re-arm from inside a handler is a no-op. The listener loop also keys on node identity, not just the sharedshouldListenForEventsflag (now@Volatile), so a racingstart()cannot make the old loop poll a nodedestroy()already freed.startEventListenerusesrunSuspendCatchingso it no longer silently swallows the cancellation.Overlap prevention: release gate + non-blocking recovery
Even with a bounded stop, the old node can still be draining after
Stoppedis published. The destructive storage operations —wipeStorage(),resetNetworkGraph(), and the pathfinding-scores VSS deletes — plus the normal rebuild path now wait on the previous node's release before touching storage, so a delete never races, and a new node never builds over, storage the old node still owns. That wait is itself bounded (~90 s, comfortably above the observed ~40 s wedge): a stuckfree_nodethrowsNodeReleaseTimeoutrather than proceeding (which would overlap) or blocking forever (which would permanently brick every future rebuild).Nothing opts out of that gate. An earlier revision of this PR let the Electrum server-change rebuild skip it (
awaitRelease = false), because gating serialized each back-to-back change in@settings_10behind the previous — possibly wedged, ~40 s — node's drain. That bypass is gone: the pre-flight probe below removes the wedge it was working around, so the parameter itself has been deleted fromLightningService.setup,LightningRepo.setup, andLightningRepo.start.Electrum pre-flight probe
The ~40 s wedge was never intrinsic. It is produced by a failed
node.start(), which leaves the node's Electrum background tasks stuck mid-handshake sofree_nodecannot drain. Every wedge measured in this PR came from tearing the node down and rebuilding it against a server that could never work.So the Electrum change now probes the candidate server over its own socket before the node is touched, exactly as
restartWithRgsServeralready validates its URL before stopping anything.ElectrumProbeServiceconnects, performs the TLS handshake when SSL is selected, and speaks enough of the protocol to classify four failure modes:Unreachable— wrong host or portProtocolMismatch— TLS against a plain-TCP server, the@settings_10conditionserver.versionNotElectrum— reachable, but not an Electrum serverserver.features→genesis_hashNetworkMismatch— right protocol, wrong chain (regtest vs mainnet)The genesis-hash check closes #1078: a regtest wallet pointed at a mainnet server used to be accepted ("Successfully changed electrum server"), after which the on-chain sync timed out forever and the screen silently showed Disconnected with an endless retry loop. That server is now rejected before the switch, with the failure surfaced immediately instead of as a permanent Disconnected state. #1078 proposed exactly this mechanism — comparing the server's genesis hash from
server.featuresagainst the wallet's network — and its reproduction case (electrum.blockstream.infoon a regtest build) is one of the device journeys below.Each reply is validated as a JSON-RPC envelope rather than merely parsed: the probe requires the
idit asked for, noerror, and a non-nullresult. Without that, a server erroring on version negotiation — one the real LDK client rejects at startup — would pass the version check, and a subsequentserver.featureserror would then look like "no genesis hash" and probe clean, recreating the failed-start wedge. Version negotiation must succeed first; only then isserver.featurestreated as optional.server.featuresis optional in the protocol, so a server that omitsgenesis_hashis accepted rather than rejected — the network check degrades instead of false-rejecting working servers. Every step is bounded so the probe itself cannot become the thing that hangs.Because the node is no longer torn down for a server that cannot work, this path stops manufacturing wedged releases — which is what makes the release gate affordable again on every rebuild, including recovery.
This is a mitigation, not a guarantee: a server can pass the probe and still fail at start (it goes down in between, or fails in a way the probe does not model). The gate therefore keeps its ~90 s bound and
NodeReleaseTimeoutfor that residual case.Server-change transaction serialization
A failed change still runs its recovery in the background, on the repository's process-lifetime scope, so the user sees the failure immediately rather than waiting on the restore. That detachment opened a race:
stop()andstart()take the lifecycle mutex separately, so a recovery could restart the previous config in the gap between a later change's stop and its start. The later change would then hit the already-running fast path instart(), report success, and persist a server that was never applied.Two changes close it. A dedicated transaction lock is held across the whole stop → start → persist sequence, and is taken by the recovery too, so a change and a recovery can no longer interleave. And a
start()carrying an explicit config is never satisfied by an already-running node — one that is already up demonstrably was not built with that config — so it fails withNodeConfigNotAppliedinstead of reporting a success the caller would persist. That second guard also covers racers the lock cannot see, such as a foreground start orWakeNodeWorker.Brief-background debounce
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. 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 so it cannot be silently dropped when the activity goes away, and every
start()cancels a pending stop up front so a start that short-circuits on its guards can't leave one to fire against a foregrounded node.Preview
short-pause.webm
long-pause.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 to a valid server: node stops and restarts normally.regression:Receive a Lightning payment in the background (app closed, Background Payments on): payment is received and the notification is delivered.@settings_10shape): the valid change applies without waiting on anything.Automated Checks
Release + teardown coverage in
LightningServiceTest.kt: deterministic handle release, release when the node is already stopped / when node stop throws, the no-node case, no double release, and teardown completing when the caller is cancelled (including cancellation during listener cleanup).Listener-safety coverage in
LightningServiceTest.kt: a stop from inside an event handler completes without self-join deadlock, a re-arm from inside a handler keeps the listener running, an external stop cancels and joins the listener, and the loop stops polling a node once it is swapped out (no use-after-free).Release-gate coverage in
LightningServiceTest.kt: rebuild (setup),wipeStorage, andresetNetworkGrapheach wait for the previous release before proceeding; the gate adds no latency when no release is pending; the gate throws instead of blocking forever when the release never finishes; and the stop returns within its bound while the release is wedged. These use a real IO dispatcher plus a controllably delayeddestroy().Probe coverage in
ElectrumProbeServiceTest.kt, driven by a fake Electrum server on a local socket: a server on the expected network is accepted, one on a different network is rejected on the genesis hash, a server that omitsserver.featuresis still accepted, a host that never answers the handshake and an unreachable host are both rejected, TLS against a plain-TCP server is rejected, and the error names the server that was probed. Envelope validation is covered by four more cases — a version reply carrying anerror, one with noresult, one answering a differentid, and a server that errors on both version and features — each asserting rejection, and each verified load-bearing by reverting the check.Server-change coverage in
LightningRepoTest.kt: a probe rejection returns the probe error without stopping the node or callingsetup, astart()carrying a custom server URL fails instead of riding an already-running node, and a change issued while a recovery is in flight waits for it and then genuinely rebuilds with the requested server.Repo coverage in
LightningRepoTest.kt: deferred stop timing and cancellation, a foreground/background cycle within the window never stopping the node, andrestartWithElectrumServersurfacing failure before the background recovery completes.WalletViewModelTest.kt: a foreground start cancels a pending deferred stop even while startup is still active.Each new regression test was verified load-bearing by reverting its fix and confirming it fails (or, for the unbounded-gate case, hangs) before restoring.
Device validation on the emulator, backgrounding for 1–8 seconds: under the window the node stays running and start is skipped; past it the log order is always
Stopping node…→Node stopped→Building node…→Node started, confirming teardown completes before startup on the healthy path.Device validation of the foreground-service path: with background payments and keep-active enabled, backgrounding produced no stop and the node kept processing LDK events.
Device validation of the notification stop action (immediate, no double-stop from service teardown) and of a background Lightning receive.
Device validation of the Electrum pre-flight probe against the local regtest stack (electrs on plain TCP), with the release gate restored on every rebuild. Each rejection left the node completely untouched — no
Stopping node…, noBuilding node…, and noNode handle release still pendinganywhere in the session:@settings_10condition)ProtocolMismatch, node untouchedNetworkMismatch(expected0f9188f1…, got00000000…0a8ce26f)UnreachableDevice validation that the restored gate costs nothing on the healthy path: no
Waiting for previous node release…line appeared on any rebuild, because a healthy release finishes in ~38 ms.Device validation of the transaction lock on a real concurrent dispatcher: with a recovery in flight, a second Electrum change logged its entry and then blocked for ~9.5 s until the recovery finished, before running its own full stop → rebuild → start and only then persisting. Without the lock that window is where the recovery's start lands, leaving the change to report success without applying.
No
Fatal signal,SIGABRTor tombstone appeared in logcat across any of the runs above.CI: standard compile, unit test, and detekt checks run by the PR bot.
Notes for review
settings__es__server_error_description; the specific reason (NetworkMismatch/ProtocolMismatch/NotElectrum/Unreachable) is distinguished in the probe error and the logs only. Wiring those to distinct user-facing strings is a small, self-contained follow-up — say the word and I'll add it here instead. The mismatch path is covered by unit tests inElectrumProbeServiceTest.ktandLightningRepoTest.ktrather than inElectrumConfigViewModelTest.ktas Validate Electrum server network before switching #1078 suggested, since the check lives at the service/repo layer.@settings_10timing changes. Its Electrum error toasts now appear in ~5 s (wrong protocol) and ~50 ms (unreachable host) instead of ~22 s, since the probe rejects before any node work. Faster and more deterministic, but the E2E's timing assumptions are worth a look — CI is the real check.start()guard is the part pinned by a load-bearing unit test, and it is what prevents the user-visible symptom (persisting a server that was never applied).