Skip to main content

zeus-node-lifecycle-campaign

Load when working on Zeus's node lifecycle races - the

Quellinformationen

Repository
ZeusLN/zeus
Letzte Quellaktivität
4. Oktober 2026 um 16:18
Erkannte Sprache von SKILL.md
Englisch
Sterne
1.399
Forks
252

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
zeus-node-lifecycle-campaign
description
Load when working on Zeus's node lifecycle races - the
# Zeus Node Lifecycle Campaign This is an executable, decision-gated investigation campaign for Zeus's hardest live problem (maintainer-confirmed, 2026-07-06): **races in starting, stopping, deleting, and switching embedded nodes** (embedded LND and LDK Node), including Tokio-thread crashes and `Wallet.tsx` `fetchData` re-entrancy. Everything below was verified by reading the code at master `c5fd094fb` (v13.1.3-alpha) unless explicitly labeled HYPOTHESIS. ## When to use / When NOT to use **Use this skill when** you are reproducing, diagnosing, or fixing node start/stop/delete/switch races, or reviewing any PR that touches `views/Wallet/Wallet.tsx` connection flow, `SettingsStore` lifecycle flags, `utils/LndMobileUtils.ts`, `utils/LdkNodeUtils.ts`, or the native node modules. **Do NOT use this skill for:** | Need | Go to sibling skill | |---|---| | General symptom→triage for other bugs | zeus-debugging-playbook | | How to capture logs / measure (general tooling) | zeus-diagnostics-and-tooling | | Past incident narratives beyond lifecycle | zeus-failure-archaeology | | Backend capability matrix, adding an RPC | zeus-backends-and-capabilities | | Formal race-analysis method (generic recipes) | zeus-proof-and-analysis-toolkit | | PR/commit/review rules, the non-negotiables | zeus-change-control | | Running the app / connecting nodes at all | zeus-run-and-operate | | Settings blob, migrations, keychain | zeus-storage-and-migrations | | What counts as test evidence, jest traps | zeus-validation-and-qa | ## Jargon (one line each, both domains) - **Embedded LND**: a full LND Lightning node compiled to a mobile library (gomobile) running *inside* the Zeus app process; Zeus implementation string `'embedded-lnd'`. - **LDK Node**: a Rust Lightning node (`ldk-node` crate, ZeusLN fork) embedded via uniffi FFI bindings; implementation string `'ldk-node'`. - **Tokio**: the Rust async runtime ldk-node uses. If the *last* reference to the Node object is dropped from one of Tokio's own worker threads, the runtime is destroyed from inside itself → Rust panic → native crash (SIGABRT), killing the whole app. - **uniffi**: Mozilla's Rust↔Kotlin/Swift binding generator; the checked-in `ldk_node.kt` / `LDKNode.swift` are its output. - **gomobile / Messenger IPC**: embedded LND is a Go library; on Android it runs behind `LndMobileService.java`, a bound Android Service the RN module talks to via `Messenger` message passing (Blixt-derived code, `TODO(hsjoberg)` debt). - **Foreground service**: an Android service with a persistent notification, allowed to keep running in background; used for "persistent mode". - **Focus event**: react-navigation fires `'focus'` every time a screen becomes active — including returning from another screen. `Wallet.tsx` re-runs its connect logic on focus. - **AppState**: React Native's app-level state (`active`/`background`/`inactive`); Wallet also re-runs connect logic on `active`. - **MobX observable**: mutable store field that re-renders observers; the lifecycle flags below are plain observables with **no atomicity guarantees**. - **Neutrino**: LND's light-client chain backend (connects to Bitcoin P2P peers). **Esplora/RGS/VSS**: LDK Node's chain API server / rapid gossip sync server / remote channel-state storage (`vss.zeusln.com`). - **fetchData**: the ~800-line method in `views/Wallet/Wallet.tsx` that resets stores, starts/attaches to the node for the active backend, and loads wallet data. It IS the boot sequence (not App.tsx). ## The lifecycle state machine as it actually exists (verified 2026-07-06) There is **no single owner**. Lifecycle state is smeared across four layers: | Layer | File | Owns | |---|---|---| | Orchestration | `views/Wallet/Wallet.tsx` | `handleFocus` / `handleAppStateChange` / `handleOpenURL` → `getSettingsAndNavigate()` → `fetchData()`; the `refreshRunner` instance (`utils/WalletRefreshRunner.ts`); a 60s slow-startup alert timer | | Flags | `stores/SettingsStore.ts` | `connecting` (init **true**), `fetchLock`, `embeddedLndStarted`, `walletJustCreated`, `triggerSettingsRefresh`, `ldkNodeSyncing`; `setConnectingStatus()`; `updateNodeProperties()` (swaps `implementation`, dirs, creds on every settings write) | | JS node control | `utils/LndMobileUtils.ts` (`initializeLnd`, `startLnd`, `stopLnd`, `waitForRpcReady`, `createLndWallet`, `deleteLndWallet`) and `utils/LdkNodeUtils.ts` (`startLdkNodeWallet`, `stopLdkNode`, `waitForLdkNodeReady`, `createLdkNodeWallet`, `deleteLdkNodeWallet`) | retry loops, sleeps, error classification (`utils/LndMobileErrors.ts`) | | Native | Android `android/app/src/main/java/com/zeus/LndMobileService.java` (+ `LndMobile.java`, `LndMobileScheduledSyncWorker.java`), `android/app/src/main/java/app/zeusln/zeus/LdkNodeModule.kt`; iOS `ios/LndMobile/`, `ios/LdkNodeMobile/LdkNodeModule.swift` | the actual daemon/Node object, `lndStarted` boolean, `nodeLock`/`takeNode()` | | Mutation entry points | `views/Settings/Wallets.tsx` (switch), `views/Settings/WalletConfiguration.tsx` (create/edit/delete), `utils/WalletCreationUtils.ts` (Quick Start), `views/Settings/SeedRecovery.tsx` | write `settings.nodes`/`selectedNode` via `updateSettings`, then `setConnectingStatus(true)` + `popTo('Wallet')` | ### The guards, in evaluation order 1. **`refreshRunner.running`** (`WalletRefreshRunner`, Wallet.tsx instance) — guards `getSettingsAndNavigate` re-entry from the focus event and AppState `active`. Added as the `_navigating` instance field in `c71a04ce9` (2026-04-07) because the focus event fires twice on startup and both raced into `fetchData` → duplicate `buildNode`, second build times out holding VSS resources; moved into `utils/WalletRefreshRunner.ts` for #4751, which also clears `triggerSettingsRefresh` when a guarded refresh starts and runs at most one follow-up refresh if the flag is re-armed while it runs (only while the Wallet is focused). A `getSettingsAndNavigate` promise that never settles leaves `running` stuck `true`. **Bypassed by three call sites** that invoke `getSettingsAndNavigate()` directly: pull-to-refresh (`LayerBalances onRefresh`), the error-screen Restart button (iOS branch), and `handleOpenURL` for `zeusln://share`. 2. **`fetchLock`** (SettingsStore observable) — checked/set at `fetchData` entry (`if (fetchLock) return; SettingsStore.fetchLock = true;`). Cleared in exactly two places: the happy-path end of `fetchData`, and `setConnectingStatus(true)`. **Every early-return error path leaves `fetchLock === true`** — by design: the app then shows the error screen, and recovery requires `setConnectingStatus(true)` (Restart button, wallet switch, transient-retry loop), which clears the lock. Consequence: any code path that errors out and then expects a plain focus event to reconnect will hang silently. 3. **`connecting`** — initialized `true`, so the first `fetchData` after cold start runs the full connect branch (stop→init→start + reset of ~12 stores). Set `false` at the end of a successful connect or on fatal errors; set `true` by every wallet-switch/create/restart path. When `false`, `fetchData` becomes a light data refresh (embedded-lnd still calls `waitForRpcReady()` + full data fetch on every focus). 4. **`walletJustCreated`** — set by `createLndWallet` / LDK create paths; makes `fetchData` skip the stop→init→start cycle because the node is already running from wallet creation. Consumed (set `false`) on first use. 5. **`embeddedLndStarted`** — whether `stopLnd()` is called before re-init (`if (!recovery && SettingsStore.embeddedLndStarted) await stopLnd();`). Set `true` in `startLnd` (only when a wallet password is present), `false` in `stopLnd` and several views. 6. **Native guards** — LDK: `nodeLock` (Kotlin `synchronized`) / `takeNode()` (Swift) makes stop/build swap the node reference atomically; LND Android: a single `lndStarted` boolean inside `LndMobileService`; the Go daemon itself rejects double starts with "lnd already started". ### `setConnectingStatus(true)` side effects (SettingsStore.ts) Resets `error`/`errorMsg`/`lndFolderMissing`, calls `BackendUtils.clearCachedCalls()` (flushes the request dedup cache — see zeus-backends-and-capabilities), and clears `fetchLock`. This is the universal "reconnect" lever; treat any call to it as a lifecycle transition. ### Per-backend connect branch inside `fetchData` (only when `connecting`) - **ldk-node**: `stopLdkNode()` (unless `walletJustCreated`) → `startLdkNodeWallet` (`initializeNode` FFI + `node.start()` retried up to 5×500ms on "Node not initialized" + `syncWallets`) → later branch `waitForLdkNodeReady()` (polls `status()` every 500ms up to 60s, tolerating "Node not initialized" and not-running — added by `b54e8f138`, 2026-05-17; the companion `buildNode`-race fix `8fc9698a0`, same day, added the `walletJustCreated` skip). - **embedded-lnd**: `stopLnd()` (if started) → `initializeLnd` (writes lnd.conf, binds service) → optional express graph sync → `startLnd` (retry ladder incl. force-stop on `LND_ALREADY_RUNNING`, wallet unlock on `WALLET_LOCKED`) → `waitForRpcReady()` (polls `getInfo` 500ms up to 30s). Transient RPC errors route through `retryOnTransientError` (max 5 retries, 2s base exponential backoff; it toggles `connecting` false→true, which is also what re-clears `fetchLock`). - Remote backends (lndhub login, LNC `connect()`, NWC `connectNWC()`, REST default) have no native lifecycle but share the same flags. ### Native stop/delete rules (the Tokio contract) From `LdkNodeModule.kt` (`stop`, `buildNode`) and `LdkNodeModule.swift` (same methods), established by `d416052cd` + `0e2b89164` (both 2026-03-10): 1. Take the node reference out of the module under `nodeLock`/`takeNode()` **first**, so no other call can reach a stopping node. 2. Call `node.stop()` on a **dedicated non-Tokio thread** (raw `Thread` on Android, GCD global queue on iOS) — never on a Tokio callback thread. 3. Resolve the JS promise only **after** `stop()` returns — callers may delete wallet files or build a new node the moment `stop()` resolves. 4. Keep the reference alive **2 more seconds** after resolving (`Thread.sleep(2000)` + `hashCode()` on Android, `withExtendedLifetime` on iOS) so internal Tokio tasks drop their `Arc` refs before ours — otherwise the "Runtime dropped on worker thread" panic. 5. `buildNode` applies the same stop-and-hold to any existing node before building, and clears the JS-visible node reference up front — which is why **any FFI call during a rebuild window rejects with `"Node not initialized"`** (JS must tolerate it: `LDK_NODE_NOT_INITIALIZED` handling in `utils/LdkNodeUtils.ts`). Embedded LND has no equivalent contract; `stopLnd()` in `utils/LndMobileUtils.ts` approximates one (status check → `stopDaemon` → `killLnd` → poll status up to 10×500ms). ## Known race windows (the target list) Each window is verified by code read at `c5fd094fb`. Runtime confirmation status noted. | # | Window | Status | |---|---|---| | W1 | Double focus → duplicate `getSettingsAndNavigate` | FIXED by `_navigating` (`c71a04ce9`, now `WalletRefreshRunner`); residual: the three bypass call sites rely only on `fetchLock`, which does NOT stop a second `getSettingsAndNavigate` from re-running the pre-`fetchData` navigation logic concurrently | | W2 | JS call into LDK during `buildNode` rebuild → `"Node not initialized"` rejection | Tolerated in `waitForLdkNodeReady` (`b54e8f138`) and the pre-existing `start` retries; **unguarded** for every other store that calls the backend during the window (e.g. polling stores) | | W3 | Cross-implementation switch leaves the previous node running: `fetchData`'s `ldk-node` branch calls only `stopLdkNode()`, the `embedded-lnd` branch only `stopLnd()`. Switching embedded-lnd → ldk-node (or reverse) stops NEITHER the old node; `views/Settings/Wallets.tsx onWalletPress` only calls `setPersistentMode(false)` (dismisses the Android notification, does not stop the node) and `BackendUtils.disconnect()` for LNC | Confirmed by code read; runtime impact (two chain-syncing nodes, memory/battery, port/db contention) needs Phase 3 measurement | | W4 | Delete-active-wallet flow: `WalletConfiguration.deleteNodeConfig` stops node → `updateSettings({ justDeletedWallet: active })` → deletes files → navigates to Wallets; selecting the next wallet runs `handleJustDeletedWallet` which does `setConnectingStatus(true)` then **`sleep(2000)`** — a bare time-based wait standing in for "old node fully gone" | Open; the sleep is a fenced symptom-patch pattern (below) | | W5 | JS reload with live daemon: LND runs **in the app process** (`android/app/src/main/AndroidManifest.xml` declares `LndMobileService` with no `android:process` attribute), so a JS-only restart (`RNRestart`, dev-menu reload, JS crash) leaves the Go daemon running → next start hits `LND_ALREADY_RUNNING` → force-stop ladder in `startLndWithRetry` with 2–4s cleanup sleeps | Handled but fragile; see W6 | | W6 | `LndMobileTools.killLnd()` scans running processes for `packageName + ":zeusLndMobile"`, but no such process is ever created (no `android:process` in the manifest) — so `killLnd` can never find/kill anything and resolves `false`. Recovery therefore rests entirely on the Go `stopDaemon` call. Also `killLndProcess()` uses `getCurrentActivity()` which is null when no Activity is attached (background) → NPE risk | Code-read finding; runtime confirmation = log `killLnd` result in Phase 4. Blixt heritage: Blixt runs the service in a separate process, Zeus does not | | W7 | `LndMobileScheduledSyncWorker.java` (background sync) binds the same `LndMobileService` and — per its own FIXME at ~line 310 — calls `thread.run()` instead of `thread.start()`, executing the "thread" body synchronously on the worker's calling thread with handlers on the **main looper**. A scheduled sync racing a foreground app start can send `MSG_START_LND` against an already-started daemon (the worker's own comment: "Make sure we don't attempt to start lnd twice... better fix would be MSG_CHECKSTATUS") | Open, acknowledged debt (`TODO(hsjoberg)`) | | W8 | `LndMobileService.onUnbind`: when the last client unbinds it calls `stopLnd(null, -1)` + `stopSelf()` — but the RN module (`LndMobile.java`) binds once in `initialize()` and its `unbindLndMobileService` is flagged in `lndmobile/LndMobile.d.ts` as "function looks broken" and is never called from TS (verified: zero TS call sites). So service lifetime ≈ process lifetime; unbind-triggered stop is effectively dead code in the app (only the sync worker unbinds) | Confirmed by code read | | W9 | Background/foreground storm: AppState `active` triggers `getSettingsAndNavigate` (guarded by `WalletRefreshRunner`), `inactive` resets `NostrWalletConnectStore`; iOS fires `inactive` on mere app-switcher/notification-shade interactions | Guarded but untested under storm; Phase 0 E4 | | W10 | LDK delete-then-create same dir: `stop()` resolves before the 2s native Arc-hold expires; `deleteLdkNodeWallet` then `RNFS.unlink`s the storage dir while background Tokio cleanup may still touch it | HYPOTHESIS — the 2s hold was added for the ref-drop panic, not for file-handle safety; needs Phase 5 experiment | --- ## Phase 0 — Baseline: reproduce and measure Goal: a numeric crash/hang rate per scenario BEFORE any fix, so any fix can be judged. Do not skip; the maintainer's revert-first culture means an unmeasured "fix" is unlandable. ### Prerequisites - Dev builds on BOTH platforms (see zeus-run-and-operate; `yarn android` / `yarn ios`, Metro via `yarn start`). - Two test wallets on one device: one `embedded-lnd` (Mainnet or Testnet) and one `ldk-node`. Create via IntroSplash Quick Start + Settings → Wallets → Add. - Log capture: ```bash # Android — native lifecycle tags + crashes (works on debug and release): adb logcat -v time | grep -E "ReactNativeJS|LdkNodeModule|LndMobileService|LndMobile|AndroidRuntime|FATAL|Fatal signal" # Android — crash buffer only (count crashes across trials): adb logcat -b crash -v time # Android JS console in DEBUG builds goes to the Metro terminal, not logcat. # In RELEASE builds console.log appears under the ReactNativeJS logcat tag. # LDK Node's own Rust log (debug builds; substitute the wallet's uuid dir): adb shell run-as app.zeusln.zeus sh -c 'ls files/ldk-node/' adb shell run-as app.zeusln.zeus sh -c 'tail -f files/ldk-node/<uuid>/ldk_node.log' # Embedded LND's log: in-app Settings → Embedded node → LND Logs, or (debug): adb shell run-as app.zeusln.zeus sh -c 'tail -f files/<lndDir>/logs/bitcoin/mainnet/lnd.log' # iOS — native NSLog (simulator; process name is "zeus"): xcrun simctl spawn booted log stream --predicate 'process == "zeus"' --style compact # iOS JS console: Metro terminal. ``` ### Log milestones (the vocabulary of every experiment) All verified present in source: | Milestone | Exact string (grep-able) | Source | |---|---|---| | Focus fired, connect starting | `[Wallet] handleFocus: triggering getSettingsAndNavigate` | Wallet.tsx | | Re-entry suppressed | `skipping — getSettingsAndNavigate already in flight` | Wallet.tsx (focus + AppState variants) |
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen