Skip to content

mobile: prevent wallet stalls on external I/O - #1131

Merged
bhandras merged 6 commits into
mainfrom
codex/mobile-invoice-timeout
Aug 26, 2026
Merged

mobile: prevent wallet stalls on external I/O#1131
bhandras merged 6 commits into
mainfrom
codex/mobile-invoice-timeout

Conversation

@bhandras

@bhandras bhandras commented Aug 11, 2026

Copy link
Copy Markdown
Member

The problem

Mobile hosts call the embedded Wavelength wallet through gomobile, which cannot carry a caller context.Context. If iOS or Android suspends the process while an external connection is using an old network path, wallet reads and Lightning invoice creation can wait for the embedded daemon's full lifetime instead of returning control to the app.

Wallet recovery can cause a second stall. btcwallet v0.18.0 keeps its database writer open while the chain backend filters recovery blocks. The lightweight backend may perform remote I/O during that call, so unrelated wallet operations can wait behind a slow recovery request.

Read calls are safe to repeat. Invoice creation is different: a deadline or lifecycle cancellation may race durable receive-session creation, so cancellation does not prove that no invoice exists.

The fix

  • Give repeatable mobile reads a 10-second deadline. This covers GetInfo, Balance, List, ExitStatus, ExitSummary, GetExitPlan, Status, and the scalar convenience helpers.
  • Give mobile Receive an optional TimeoutSeconds field. Older hosts receive a 20-second default. Values above five minutes are rejected.
  • Mark deadline and lifecycle cancellation of Receive as an uncertain outcome with a stable reconcile-before-retry error prefix.
  • Return the authoritative local satoshi balance when optional remote credit enrichment exceeds its own two-second deadline, and log that the enrichment was skipped.
  • Update btcwallet to the merged recovery implementation from btcsuite/btcwallet#1318. It performs chain-backend filtering without an open wallet transaction, then atomically persists discoveries and the batch sync marker.
  • Document that the host must stop and restart the embedded wallet after a real background/foreground transition so external transports are re-dialled.
  • Release the Postgres test-fixture slot after container startup and store migration. This keeps the resource-sensitive initialization boundary at four concurrent fixtures without holding slots through each test lifetime.

Safety rule

The host may retry timed-out reads. It must not blindly retry a canceled Receive; it must first reconcile the authoritative Activity view and recover any matching invoice.

Recovery keeps address-index operations serialized and commits each completed batch atomically, while unrelated database writers remain available during remote filter I/O.

What does not change

  • The Go SDK request type and server RPC schema do not change.
  • Existing mobile hosts may omit TimeoutSeconds.
  • A healthy credit endpoint still enriches the local balance with credit fields.
  • The mobile binding still cannot observe the application lifecycle. The host owns background/foreground recovery.

Tests

  • make lint-changed-local
  • make tidy-module-check
  • make unit-swapruntime
  • go test -tags='mobile wavewalletrpc swapruntime' ./sdk/wavewalletdk/mobile
  • go test -tags='wavewalletrpc swapruntime' ./swapwallet -run '^TestServiceBalance' -count=1
  • go test -tags=test_postgres ./waved -count=1 -timeout=30m
  • go test -tags=test_postgres ./db -count=1 -timeout=30m
  • make mobile-ios

The tests cover default and explicit mobile deadlines, invalid deadline values, repeatable read deadlines, uncertain Receive cancellation, healthy credit enrichment, credit timeout fallback and logging, and the Postgres fixture-slot lifecycle. The full wallet-runtime suite and iOS xcframework build pass against the updated btcwallet dependency.

Exact-head CI passes all 20 required checks, including Postgres unit and system tests, race tests, SQLite tests, lint, static checks, and every cross-build.

Compatibility and rollout

TimeoutSeconds is additive at the mobile JSON boundary. Older Wavelength bindings ignore the extra field. Updated bindings apply the bounded default even when an older host omits it.

The btcwallet fix is pinned to v0.18.1-0.20260826052527-33c252f3b4d6, the Go pseudo-version for merged commit 33c252f3b4d6. No btcwallet v0.18.1 tag exists yet.

After this PR merges, publish a Wavelength release containing both changes. wavelength-mobile#6 can then consume and validate the release xcframework.

Mobile hosts cannot pass caller contexts through gomobile.
They may retain stale transports across OS suspension. Bound repeatable
reads and invoice creation without retrying an uncertain receive.

Keep the local satoshi balance available when optional remote credit
enrichment stalls.
Mobile wallet recovery asks the chain backend to filter remote blocks.
The v0.18.0 wallet holds its database writer while that backend call is
in flight, which can stall unrelated wallet operations on a slow path.

Update to the merged btcwallet recovery change. It builds requests under
a short read transaction, performs filter I/O without an open
transaction, and atomically persists each batch while serializing
address derivation.

This keeps recovery progress atomic without blocking unrelated database
writers during remote filter calls. The iOS binding build and the full
wallet-runtime unit suite pass against the new version.
@bhandras
bhandras force-pushed the codex/mobile-invoice-timeout branch from 3e9504c to a4575fe Compare August 26, 2026 06:52
@bhandras bhandras changed the title mobile: bound wallet calls on stale transports mobile: prevent wallet stalls on external I/O Aug 26, 2026
@litbot-9000

Copy link
Copy Markdown
Collaborator

📚 Doc gardening advisory

This PR's Go changes leave sdk/wavewalletdk/mobile's package doc stale — the new binding-owned call deadlines, the mobile-only Receive.TimeoutSeconds field, and decodeReceiveRequest's reject-don't-clamp validation aren't reflected in its CLAUDE.md/AGENTS.md pair. (swapwallet was also in scope; its doc already covers the credit-enrichment deadline invariant, so no change is proposed there.)

diff --git a/sdk/wavewalletdk/mobile/AGENTS.md b/sdk/wavewalletdk/mobile/AGENTS.md
index b83bfdaa..65b71ee8 100644
--- a/sdk/wavewalletdk/mobile/AGENTS.md
+++ b/sdk/wavewalletdk/mobile/AGENTS.md
@@ -27,12 +27,19 @@ application-facing wallet API.
   `applyMobileConfig` into a `wavewalletdk.Config`; validated (non-negative
   durations/counts, `uint32`-safe recovery window) before merging onto
   `wavewalletdk.DefaultConfig()`.
+- `mobileReceiveRequest` — unexported wire struct for `Receive`, decoded by
+  `decodeReceiveRequest`. It carries the SDK's `AmountSat`/`Memo` plus a
+  mobile-only `TimeoutSeconds`, and is projected onto a
+  `wavewalletdk.ReceiveRequest` so the SDK DTO stays unchanged. Older
+  bindings that never send the field still decode.
 - RPC verbs (`GetInfo`, `CreateWallet`, `UnlockWallet`, `Balance`, `Deposit`,
   `Receive`, `PrepareSend`, `SendPrepared`, `List`, `Exit`, `ExitStatus`,
   `ExitSummary`, `GetExitPlan`, `SweepWallet`, `Status`, `Subscribe`,
   `OpenWalletFromPasskey`) — each dereferences the singleton `wavewalletdk.Client`
   via `activeClient()`, decodes a JSON request into the matching
   `wavewalletdk.*Request`, and marshals the `wavewalletdk.*Result` response.
+  `Receive` is the one verb whose request is not the SDK DTO verbatim: it
+  decodes `mobileReceiveRequest` to pick up the mobile-only deadline.
 - Scalar conveniences (`ConfirmedBalanceSat`, `PendingInboundSat`,
   `WalletReady`, `IsRunning`) — avoid a JSON round trip for hot-path reads;
   `IsRunning` never blocks on an RPC.
@@ -57,6 +64,23 @@ application-facing wallet API.
   can succeed (e.g. after OS suspend/resume).
 - `startEmbedded` separates its daemon-readiness deadline from the lifecycle
   context used to open or recover the wallet. `Stop` cancels both.
+- gomobile cannot carry a caller `context.Context`, so this package owns every
+  per-call deadline rather than letting a verb inherit the daemon lifetime.
+  Repeatable reads (`Balance`, `List`, `Status`) use `readContext`'s
+  `defaultReadTimeout` (10s) — safe to bound because the host can simply ask
+  again. `Receive` uses the caller's `TimeoutSeconds`, falling back to
+  `defaultReceiveTimeout` (20s) when zero or omitted and capped by
+  `maxReceiveTimeout` (5m).
+- `decodeReceiveRequest` rejects negative and over-cap `TimeoutSeconds`
+  instead of clamping them, so malformed host input cannot restore the
+  effectively unbounded receive this field exists to avoid.
+- A `Receive` deadline error does not prove the receive session was never
+  created. The host must reconcile the authoritative Activity view and recover
+  any matching invoice before deliberately requesting another one; the bounded
+  reads carry no such ambiguity.
+- Every bounded per-call context derives from the daemon-lifetime parent that
+  `activeClient()` returns, so `Stop` still cancels an in-flight verb
+  immediately rather than waiting out its deadline.
 - Startup through `startEmbedded` and `Subscription.Next` recover panics into
   a returned `error`; those are the entry points documented to survive a panic
   without crossing the gomobile boundary and killing the host process.
@@ -70,6 +94,9 @@ application-facing wallet API.
 
 ## Deep Docs
 
+- [docs/wavewalletdk_mobile.md](../../../docs/wavewalletdk_mobile.md) — Binding
+  ABI reference: JSON field-name casing rules, per-verb deadlines, and the
+  host-side background/resume contract.
 - [sdk/wavewalletdk/CLAUDE.md](../CLAUDE.md) — Wrapped SDK; see for full DTO and
   RPC method detail.
 - [ARCHITECTURE.md](../../../ARCHITECTURE.md) — System-wide package map.
diff --git a/sdk/wavewalletdk/mobile/CLAUDE.md b/sdk/wavewalletdk/mobile/CLAUDE.md
index b83bfdaa..65b71ee8 100644
--- a/sdk/wavewalletdk/mobile/CLAUDE.md
+++ b/sdk/wavewalletdk/mobile/CLAUDE.md
@@ -27,12 +27,19 @@ application-facing wallet API.
   `applyMobileConfig` into a `wavewalletdk.Config`; validated (non-negative
   durations/counts, `uint32`-safe recovery window) before merging onto
   `wavewalletdk.DefaultConfig()`.
+- `mobileReceiveRequest` — unexported wire struct for `Receive`, decoded by
+  `decodeReceiveRequest`. It carries the SDK's `AmountSat`/`Memo` plus a
+  mobile-only `TimeoutSeconds`, and is projected onto a
+  `wavewalletdk.ReceiveRequest` so the SDK DTO stays unchanged. Older
+  bindings that never send the field still decode.
 - RPC verbs (`GetInfo`, `CreateWallet`, `UnlockWallet`, `Balance`, `Deposit`,
   `Receive`, `PrepareSend`, `SendPrepared`, `List`, `Exit`, `ExitStatus`,
   `ExitSummary`, `GetExitPlan`, `SweepWallet`, `Status`, `Subscribe`,
   `OpenWalletFromPasskey`) — each dereferences the singleton `wavewalletdk.Client`
   via `activeClient()`, decodes a JSON request into the matching
   `wavewalletdk.*Request`, and marshals the `wavewalletdk.*Result` response.
+  `Receive` is the one verb whose request is not the SDK DTO verbatim: it
+  decodes `mobileReceiveRequest` to pick up the mobile-only deadline.
 - Scalar conveniences (`ConfirmedBalanceSat`, `PendingInboundSat`,
   `WalletReady`, `IsRunning`) — avoid a JSON round trip for hot-path reads;
   `IsRunning` never blocks on an RPC.
@@ -57,6 +64,23 @@ application-facing wallet API.
   can succeed (e.g. after OS suspend/resume).
 - `startEmbedded` separates its daemon-readiness deadline from the lifecycle
   context used to open or recover the wallet. `Stop` cancels both.
+- gomobile cannot carry a caller `context.Context`, so this package owns every
+  per-call deadline rather than letting a verb inherit the daemon lifetime.
+  Repeatable reads (`Balance`, `List`, `Status`) use `readContext`'s
+  `defaultReadTimeout` (10s) — safe to bound because the host can simply ask
+  again. `Receive` uses the caller's `TimeoutSeconds`, falling back to
+  `defaultReceiveTimeout` (20s) when zero or omitted and capped by
+  `maxReceiveTimeout` (5m).
+- `decodeReceiveRequest` rejects negative and over-cap `TimeoutSeconds`
+  instead of clamping them, so malformed host input cannot restore the
+  effectively unbounded receive this field exists to avoid.
+- A `Receive` deadline error does not prove the receive session was never
+  created. The host must reconcile the authoritative Activity view and recover
+  any matching invoice before deliberately requesting another one; the bounded
+  reads carry no such ambiguity.
+- Every bounded per-call context derives from the daemon-lifetime parent that
+  `activeClient()` returns, so `Stop` still cancels an in-flight verb
+  immediately rather than waiting out its deadline.
 - Startup through `startEmbedded` and `Subscription.Next` recover panics into
   a returned `error`; those are the entry points documented to survive a panic
   without crossing the gomobile boundary and killing the host process.
@@ -70,6 +94,9 @@ application-facing wallet API.
 
 ## Deep Docs
 
+- [docs/wavewalletdk_mobile.md](../../../docs/wavewalletdk_mobile.md) — Binding
+  ABI reference: JSON field-name casing rules, per-verb deadlines, and the
+  host-side background/resume contract.
 - [sdk/wavewalletdk/CLAUDE.md](../CLAUDE.md) — Wrapped SDK; see for full DTO and
   RPC method detail.
 - [ARCHITECTURE.md](../../../ARCHITECTURE.md) — System-wide package map.

How to apply

Save the diff above to a file and git apply it, or regenerate it locally by
running the doc-gardening skill scoped to the package:

/doc-gardening sdk/wavewalletdk/mobile

Either way, keep CLAUDE.md and AGENTS.md byte-identical
(cp sdk/wavewalletdk/mobile/CLAUDE.md sdk/wavewalletdk/mobile/AGENTS.md) and
re-run make doc-check.

This check is advisory only — it never fails the build.


Workflow run · doc-gardening PR advisory

@bhandras
bhandras marked this pull request as ready for review August 26, 2026 07:39
@bhandras

Copy link
Copy Markdown
Member Author

Gateway review context for head a4575febd52aaa4b9f55131ba653c43491c2fcf2:

  • Scope: bound mobile wallet calls that can otherwise stall on external I/O.
  • Read invariant: Balance, List, and Status are deadline-bounded and remain safe to retry.
  • Receive invariant: invoice creation is deadline-bounded, but the SDK does not blindly retry after an uncertain timeout.
  • Balance invariant: a remote-credit lookup failure must not hide a valid local satoshi balance.
  • Lifecycle invariant: wallet shutdown cancels in-flight mobile calls.
  • Dependency invariant: btcwallet recovery filter I/O now runs outside its DB writer while address-index serialization and batch atomicity remain intact. This uses the merge commit from wallet: filter recovery blocks outside database transaction btcsuite/btcwallet#1318.
  • Compatibility order: release this Wavelength change first, then update wavelength-mobile#6 to consume it.

Validation on this exact head:

  • make lint-changed-local
  • make tidy-module-check
  • make unit-swapruntime
  • go test -tags='mobile wavewalletrpc swapruntime' ./sdk/wavewalletdk/mobile
  • go test -tags='wavewalletrpc swapruntime' ./swapwallet -run '^TestServiceBalance' -count=1
  • make mobile-ios

For each proposed finding, require a concrete production trigger and execution trace, consequence, PR attribution, existing guards or recovery, smallest useful fix, and fix regression risk. Classify it as blocker, follow-up, or not worth changing. Do not block on speculative, unreachable, harmless, or pre-existing conditions.

@bhandras

Copy link
Copy Markdown
Member Author

/gateway review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gateway review — 5 findings

🔴 0 Blocker · 🟠 0 Major · 🟡 5 Minor · 🔵 0 Nit

Summary

The change is well-scoped and the central judgement call — bounding repeatable reads with a binding-owned deadline while treating Receive as a distinct, uncertain-outcome operation — is the right one. decodeReceiveRequest rejects rather than clamps out-of-range input and checks the negative and over-cap cases before the time.Duration multiply, so the overflow path the test names is genuinely unreachable. The swapwallet side is a clean two-second bound on optional enrichment that degrades to the authoritative local balance, and TestServiceBalanceBoundsStalledCreditRead pins the incident shape rather than the implementation.

What I'd tighten falls into three buckets: the mobile Receive DTO is hand-copied rather than embedded, so the SDK request type and the binding can drift apart silently; the "repeatable read" rule is applied to three verbs but not to the other read-only ones in the same file; and the new TimeoutSeconds contract is described as clamping in docs/wavewalletdk_mobile.md while the code rejects. None of these is a correctness failure on the paths the tests cover.

Nothing here changes the deadline semantics themselves, so this is COMMENT. Separately, sdk/wavewalletdk/mobile/CLAUDE.md / AGENTS.md still describe the pre-PR "every request decodes into the matching wavewalletdk.*Request DTO" rule; the doc-gardening bot already posted a patch for that, so I'm not duplicating it as a finding.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

// mobileReceiveRequest extends the SDK receive DTO with a mobile-only request
// deadline. The extra JSON field is backwards-compatible with older bindings,
// whose encoding/json decoder ignores it.
type mobileReceiveRequest struct {

@lightninglabs-gateway lightninglabs-gateway Bot Aug 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚫 Dismissed by @bhandras

🟡 F1 (Minor) — Mobile Receive drops any ReceiveRequest field beyond two · sdk/wavewalletdk/mobile/wallet.go:43

mobileReceiveRequest hand-copies AmountSat and Memo rather than embedding wavewalletdk.ReceiveRequest, so any other field on that DTO — today or after a future SDK change — is silently dropped at the binding instead of failing to compile; I cannot enumerate the DTO's fields because sdk/wavewalletdk/types.go is not in the loaded context, so this may be latent rather than live. Embedding (struct { wavewalletdk.ReceiveRequest; TimeoutSeconds int64 }) preserves the flat PascalCase wire shape, since encoding/json promotes embedded struct fields, and lets Receive forward the whole DTO.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not worth changing. wavewalletdk.ReceiveRequest currently has exactly AmountSat and Memo (sdk/wavewalletdk/types.go:191). No current field is dropped. This binding maps the mobile-only timeout at the owning boundary. Embedding would silently opt future SDK fields into the mobile wire ABI instead of forcing deliberate binding review, so there is no concrete defect to fix here.

Comment thread sdk/wavewalletdk/mobile/wallet.go Outdated

// GetInfo returns the daemon readiness snapshot as JSON (wavewalletdk.Info).
func GetInfo() ([]byte, error) {
client, ctx, err := activeClient()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F2 (Minor) — GetInfo/ExitStatus/ExitSummary keep the unbounded deadline · sdk/wavewalletdk/mobile/wallet.go:84

GetInfo, ExitStatus, and ExitSummary are read-only and just as safe for a host to request again, but they still pass activeClient()'s daemon-lifetime context straight through, so they stall on a frozen transport exactly the way Balance did before this PR. Either route them through readContext as well, or note in the binding doc why readiness polling is deliberately exempt.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5183f572. readContext now bounds GetInfo, ExitStatus, ExitSummary, and GetExitPlan, plus the scalar balance/readiness helpers. Balance, List, and Status remain bounded as before. Focused tests, changed-file lint, docs, module checks, and the iOS XCFramework build pass.

Comment thread docs/wavewalletdk_mobile.md Outdated
PascalCase rule.
PascalCase rule. `Receive` additionally accepts `TimeoutSeconds`: zero or an
omitted field selects a 20-second default, and positive values are capped at
five minutes. A deadline error does not prove that the receive session was not

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F3 (Minor) — Doc says over-cap TimeoutSeconds is clamped, not rejected · docs/wavewalletdk_mobile.md:57

decodeReceiveRequest returns an error when TimeoutSeconds exceeds 300 (sdk/wavewalletdk/mobile/wallet.go:67), but this line tells host authors that positive values are "capped at five minutes", which reads as clamping — a host that sends 600 gets a failed Receive, not a 300-second one.

Suggested change
five minutes. A deadline error does not prove that the receive session was not
omitted field selects a 20-second default, and values are rejected above

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5183f572. The binding documentation now says values above five minutes are rejected, matching decodeReceiveRequest and its negative/excessive/overflow tests.

defer cancel()

res, err := client.Receive(ctx, req)
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F4 (Minor) — Timed-out Receive is indistinguishable from other errors · sdk/wavewalletdk/mobile/wallet.go:198

The deadline error is returned verbatim, so a host cannot separate "timed out, outcome uncertain, reconcile Activity first" from "rejected, no session created" without matching on an error string that crosses gomobile as free text; whether the SDK already tags deadline errors is not visible here, since sdk/wavewalletdk is not in the loaded context. Wrapping the parentCtx.Err() == nil && ctx.Err() == context.DeadlineExceeded case in a stable, documented message would make the PR's stated safety rule mechanically checkable by the host rather than prose-only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5183f572. A binding-owned deadline now returns the stable receive timed out; outcome uncertain; reconcile Activity before retrying prefix while wrapping the original cause. Parent lifecycle cancellation remains unchanged. Tests cover both branches, and the ABI documentation names the prefix.

Comment thread swapwallet/service.go
}

creditCtx, cancel := context.WithTimeout(
ctx, s.creditReadTimeout,

@lightninglabs-gateway lightninglabs-gateway Bot Aug 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚫 Dismissed by @bhandras

🟡 F5 (Minor) — Zero-value creditReadTimeout disables credit enrichment · swapwallet/service.go:620

creditReadTimeout is populated only in newService, so a Service constructed any other way gets 0, and context.WithTimeout(ctx, 0) yields an already-expired context — every balance read then silently omits the credit fields with no error surfaced. The package's other wallet-level knobs resolve through Deps (resolveMaxListLimit, resolveLog); a resolveCreditReadTimeout() falling back to defaultCreditReadTimeout matches that convention and fails safe.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not worth changing. The production path is swapwallet.RegisternewService, which always sets creditReadTimeout; all package service fixtures also use newService. The dependencies are private, and a zero-value Service has nil dependencies and cannot serve a balance request. There is no production path to an otherwise initialized service with a zero timeout, so a fallback would defend an unreachable state.

@lightninglabs-gateway

lightninglabs-gateway Bot commented Aug 26, 2026

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

@bhandras

Copy link
Copy Markdown
Member Author

/gateway dismiss F1

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F1 (minor) by @bhandrasno reason given

Open findings on this PR: 🟡 F2 (minor) · 🟡 F3 (minor) · 🟡 F4 (minor) · 🟡 F5 (minor)

@bhandras

Copy link
Copy Markdown
Member Author

/gateway dismiss F5

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F5 (minor) by @bhandrasno reason given

Open findings on this PR: 🟡 F2 (minor) · 🟡 F3 (minor) · 🟡 F4 (minor)

@bhandras

Copy link
Copy Markdown
Member Author

Gateway re-review context for head 5183f5726490062d0c821b7fa3cc6762c1915f82:

  • F2 fixed: every repeatable mobile read now uses the binding-owned 10-second deadline, including scalar balance/readiness helpers and GetExitPlan.
  • F3 fixed: the ABI doc now says TimeoutSeconds values above five minutes are rejected.
  • F4 fixed: binding-owned Receive deadlines have a stable uncertain-outcome prefix; lifecycle cancellation remains unchanged. Tests cover both branches.
  • F1 dismissed: the current SDK DTO has exactly the two explicitly mapped fields. Future fields require deliberate mobile ABI review.
  • F5 dismissed: production registration and package fixtures use newService; no viable initialized service reaches a zero timeout.

Verification on the new head:

  • focused mobile tests pass;
  • changed-file lint passes;
  • documentation sync and cross-link checks pass;
  • module tidiness passes;
  • the iOS XCFramework builds successfully;
  • all three commits satisfy the repository commit-message rules.

Please verify the addressed findings and review only the new delta for concrete, reachable regressions. Do not re-raise dismissed speculative states without a production path. Classify any new item as blocker, follow-up, or not worth changing, including fix risk.

@bhandras

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gateway review — 1 finding

🔴 0 Blocker · 🟠 0 Major · 🟡 1 Minor · 🔵 0 Nit

Summary

All three actionable prior findings are addressed at 5183f572. readContext now bounds every repeatable read the doc claims it does — GetInfo, Balance, List, ExitStatus, ExitSummary, GetExitPlan, Status, and the three scalar helpers in convenience.go — so no read verb still inherits the daemon lifetime. The ABI doc's TimeoutSeconds wording now matches decodeReceiveRequest's reject-don't-clamp behavior, and binding-owned Receive deadlines carry the stable receiveTimeoutErrorPrefix while preserving the wrapped cause.

One gap remains in the new receiveError contract: it marks the deadline path as uncertain but deliberately leaves the lifecycle-cancellation path unmarked, even though a Stop racing an in-flight Receive is uncertain for the same reason. That is a minor documentation/contract inconsistency, not a defect in the deadline logic itself.

The btcwallet bump, credit-enrichment deadline, and their tests are unchanged from the prior head.


Status of prior findings

  • F2 addressed: GetInfo (sdk/wavewalletdk/mobile/wallet.go:105), ExitStatus (:318), ExitSummary (:342), and GetExitPlan (:366) now derive from readContext, as do ConfirmedBalanceSat, PendingInboundSat, and WalletReady in convenience.go. No read verb passes the daemon-lifetime context through any more. Resolved.
  • F3 addressed: docs/wavewalletdk_mobile.md:55 now reads "values above five minutes are rejected", matching decodeReceiveRequest's error return and its excessive/overflow test cases. Resolved.
  • F4 addressed: receiveTimeoutErrorPrefix is a documented constant, receiveError wraps with %w so the cause survives errors.Is, and both branches are pinned by TestReceiveErrorMarksUncertainTimeout / TestReceiveErrorPreservesLifecycleCancellation. The prefix is named in the ABI doc, so a host can match it without depending on transport error text. Resolved.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Comment thread sdk/wavewalletdk/mobile/wallet.go Outdated
// owned by this binding as an uncertain outcome. A parent cancellation means
// the daemon is stopping, so it keeps the lifecycle error unchanged.
func receiveError(parentCtx, callCtx context.Context, err error) error {
if err == nil || parentCtx.Err() != nil ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F6 (Minor) — Receive cancelled by Stop returns an unmarked uncertain outcome · sdk/wavewalletdk/mobile/wallet.go:50

receiveError returns the raw error whenever parentCtx.Err() != nil, but a Stop that cancels an in-flight Receive leaves exactly the same uncertain outcome as a deadline — the daemon may already have created the receive session — so a host following the new background/resume guidance (Stop, then Start and Receive again on resume) sees a plain lifecycle error rather than receiveTimeoutErrorPrefix and can create a second invoice for one user intent. Either extend the uncertain-outcome marking to lifecycle cancellation of Receive, or state in the ABI doc that a Receive error observed across Stop carries the same reconcile-before-retry obligation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a41cc55f.

Receive now treats cancellation from either its request deadline or wallet Stop as an uncertain outcome. Both paths return the stable receive outcome uncertain; reconcile Activity before retrying prefix while preserving the original cancellation cause. Tests cover deadline and lifecycle cancellation separately.

Apply the binding-owned read deadline to every repeatable mobile read.
Return a stable uncertain-outcome prefix when Receive reaches its own
deadline. Document that receive timeouts over five minutes are rejected.
@bhandras
bhandras force-pushed the codex/mobile-invoice-timeout branch from 5183f57 to 038df79 Compare August 26, 2026 07:59
Mark Receive cancellation by either its request deadline or wallet Stop
with the same stable uncertain-outcome prefix.

This tells mobile hosts to reconcile Activity before retrying after
foreground timeouts and lifecycle cancellation.
@bhandras

Copy link
Copy Markdown
Member Author

Gateway re-review context for exact head a41cc55f1533507c88f97c3325b8910bb5ece038:

  • F2, F3, and F4 remain fixed.
  • F1 remains intentionally dismissed: the mobile request DTO mirrors the complete current ReceiveRequest contract (AmountSat, Memo). New upstream fields should require an explicit mobile ABI decision rather than silently entering the binding.
  • F5 remains intentionally dismissed: production constructs Service through Register -> newService, which always sets creditReadTimeout; tests use the same constructor. A zero-value service has nil private dependencies and is not a reachable host instance.
  • F6 is fixed in a41cc55f: both request-deadline and wallet-lifecycle cancellation return the stable uncertain-outcome marker and preserve the original cause. Hosts are told to reconcile Activity before retrying.
  • The final head passes the repository formatter check, documentation checks, commit-message lint, module tidy check, focused mobile tests, lint-changed-local, and make mobile-ios.

Please re-check only concrete, reachable defects introduced or materially worsened by this PR. Treat pre-existing debt and speculative future API drift as follow-ups or not worth changing. Stop when the intended invariants are proven and no material defect remains.

@bhandras

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gateway review — 1 finding

🔴 0 Blocker · 🟠 0 Major · 🟡 1 Minor · 🔵 0 Nit

Summary

F6 is fixed. receiveError now keys off the derived call context (sdk/wavewalletdk/mobile/wallet.go:50), so a Receive cancelled by its own deadline or by Stop cancelling the daemon-lifetime parent both return the stable receive outcome uncertain; reconcile Activity before retrying prefix with the cause preserved under errors.Is; both branches are pinned by tests, and the prefix is named in the binding ABI doc. F2, F3, and F4 remain fixed at this head — the repeatable-read set in docs/wavewalletdk_mobile.md matches the call sites that actually route through readContext, including the scalar helpers and GetExitPlan.

The remaining item is on the daemon side and is not a mobile-binding concern: the new 2-second credit bound in fetchBalance turns a slow-but-healthy credit endpoint into a silent omission, with no log line and no response signal separating it from "zero credits". That is a follow-up, not a blocker — the local satoshi balance, which is the invariant this PR set out to protect, is correct either way.

Unbounded verbs that remain (Deposit, PrepareSend, SendPrepared, Exit, SweepWallet) are pre-existing and out of the stated scope; I am not raising them.


Status of prior findings

  • F2 addressed: readContext now bounds GetInfo, ExitStatus, ExitSummary, and GetExitPlan alongside Balance/List/Status and the three scalar helpers in convenience.go. Resolved.
  • F3 addressed: docs/wavewalletdk_mobile.md:57 now says values above five minutes are rejected, matching decodeReceiveRequest's error return rather than implying a clamp. Resolved.
  • F4 addressed: receiveUncertainErrorPrefix (sdk/wavewalletdk/mobile/wallet.go:31) gives the host a stable, documented marker for the uncertain-outcome case, and %w keeps the cause inspectable. Resolved.
  • F6 addressed: Fixed at a41cc55f. receiveError tests callCtx.Err() rather than only the request deadline, so a Stop that cancels an in-flight Receive carries the same reconcile-before-retry marker as a timeout; TestReceiveErrorMarksLifecycleCancellation pins the parent-cancellation branch specifically. Resolved.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Comment thread swapwallet/service.go
return resp, nil
}

creditCtx, cancel := context.WithTimeout(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F7 (Minor) — Credit timeout is indistinguishable from zero credits · swapwallet/service.go:619

A credit lookup that exceeds the new 2-second bound returns resp, nil with CreditAvailableSat/CreditReservedSat left at zero and nothing logged, so a slow-but-healthy credit endpoint reads identically to a wallet holding no credits — where before this change the same endpoint at 3s latency returned the real figures. Log the elided enrichment via s.deps.resolveLog() on that branch, as replayEvents does for a skipped row, so an operator can tell degradation from a genuine zero; I cannot see rpc/wavewalletrpc's proto in the provided context, so whether the response shape could also carry an explicit "credit unknown" signal is unverified here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 502fa87d.

The bounded credit lookup still degrades to the authoritative local balance, but the fallback now emits Credit balance enrichment skipped with the original error. The regression test verifies both the valid local response and the operator-visible warning.

Log the error when optional credit enrichment is omitted from an
otherwise valid local balance response.

This lets operators distinguish a degraded remote read from a genuine
zero-credit wallet without failing the local refresh.
@bhandras

Copy link
Copy Markdown
Member Author

Gateway re-review context for exact head 502fa87d58183d8bfe3d35a240598ef7fea6c982:

  • F2, F3, F4, and F6 remain addressed.
  • F1 and F5 remain intentionally dismissed for the previously documented reachability and ABI reasons.
  • F7 is fixed in 502fa87d: an unavailable or timed-out optional credit lookup still returns the authoritative local balance, and now logs Credit balance enrichment skipped with the original error. The focused regression test verifies the degraded response and warning.
  • The exact head passes focused swapwallet tests, changed-code lint, repository formatting, docs sync, commit-message lint, module tidy, and diff checks.

Please re-check only concrete, reachable defects introduced or materially worsened by this PR. Treat pre-existing debt and speculative future API drift as follow-ups or not worth changing. Stop when the intended invariants are proven and no material defect remains.

@bhandras

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gateway review

No findings.


Status of prior findings

  • F2 addressed: Unchanged at this head: readContext bounds GetInfo, Balance, List, ExitStatus, ExitSummary, GetExitPlan, Status, and the scalar helpers in convenience.go. Resolved.
  • F3 addressed: docs/wavewalletdk_mobile.md:57 states values above five minutes are rejected, matching decodeReceiveRequest's error return at sdk/wavewalletdk/mobile/wallet.go:79. Resolved.
  • F4 addressed: receiveError returns receiveUncertainErrorPrefix wrapping the original cause whenever the call context is done, and the prefix is documented as stable binding ABI. Resolved.
  • F6 addressed: Lifecycle cancellation takes the same branch as a binding-owned deadline — receiveError keys on callCtx.Err() != nil, which is set by both Stop and the timeout — so a Receive interrupted by Stop carries the same reconcile-before-retry marker. TestReceiveErrorMarksLifecycleCancellation pins it. Resolved.
  • F7 addressed: Fixed at swapwallet/service.go:625: the enrichment-failure branch logs Credit balance enrichment skipped with the error via s.deps.resolveLog().WarnS, using the parent ctx rather than the expired creditCtx, before returning the authoritative local balance. Resolved.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Release the Postgres fixture semaphore after Docker startup and store
migrations finish instead of retaining it until test cleanup.

This preserves bounded initialization while preventing parallel tests
from exhausting every slot and blocking the remaining test barrier.
@bhandras

Copy link
Copy Markdown
Member Author

Gateway re-review context for exact head 7da20ddb1577e6b2eeea433d791608b9680d3eee:

  • F2, F3, F4, F6, and F7 remain addressed. F1 and F5 remain intentionally dismissed for the previously documented ABI and reachability reasons.
  • The prior exact head passed 19 CI checks. Its Postgres unit job failed three times with the same pre-existing fixture deadlock: eight unrelated waved tests blocked in db.acquireTestPgFixtureSlot until the 30-minute package timeout. Recent main runs show the same failure.
  • 7da20ddb applies the smallest owning-boundary fix. The four-slot semaphore still bounds Docker startup and store migrations, but each helper releases its idempotent slot when initialization returns instead of retaining it through the entire test lifetime. Teardown remains the failure-path fallback.
  • The regression test proves the common helper returns with no held initialization slot and still closes its store before container teardown.
  • On the exact local head, the full waved Postgres package now passes in 41 seconds and the full db Postgres package passes in 113 seconds. Changed-code lint, repository formatting, docs sync, commit-message lint, module tidy, and diff checks also pass.

Please re-check the fixture change for a concrete resource leak, unbounded initialization path, or cleanup regression. Stop when the initialization bound and release invariant are proven and no material defect remains.

@bhandras

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gateway review — 1 finding

🔴 0 Blocker · 🟠 0 Major · 🟡 1 Minor · 🔵 0 Nit

Summary

The prior findings are all closed: every repeatable mobile read now derives from readContext (F2), the ABI doc says over-cap TimeoutSeconds is rejected rather than clamped (F3), receiveError marks both deadline and lifecycle cancellation with receiveUncertainErrorPrefix while preserving the cause via %w (F4, F6), and the credit-enrichment fallback logs Credit balance enrichment skipped with the original error (F7). F1 and F5 stay dismissed.

The only new code in this head is the Postgres fixture-slot change. The hold-and-wait it targets is genuinely removed: all three fixture-creating helpers now release an idempotent slot at return, after NewPostgresStore (and ExecuteMigrations), so no test holds a slot while waiting on another test that needs one. TearDown remains a correct failure-path fallback, and NewTestPgFixture's existing success guard still releases on a require.NoError Goexit. The coarse caller index shows NewTestPgFixture referenced from three files — its definition plus the two updated helper files — so no call site looks to have been missed.

One consequence of the narrowed boundary is worth a decision rather than a fix.


Status of prior findings

  • F2 addressed: readContext now wraps GetInfo, Balance, List, ExitStatus, ExitSummary, GetExitPlan, Status (sdk/wavewalletdk/mobile/wallet.go) and the three scalar helpers in convenience.go. Resolved.
  • F3 addressed: docs/wavewalletdk_mobile.md now reads "values above five minutes are rejected", matching decodeReceiveRequest's error return. Resolved.
  • F4 addressed: receiveError at sdk/wavewalletdk/mobile/wallet.go:50 prefixes cancellation with the stable receiveUncertainErrorPrefix and wraps the cause with %w; the prefix is documented as binding ABI and covered by TestReceiveErrorMarksUncertainTimeout. Resolved.
  • F6 addressed: The guard is callCtx.Err() != nil, which is also true when the daemon-lifetime parent is cancelled, so a Stop racing an in-flight Receive carries the same reconcile-before-retry marker. TestReceiveErrorMarksLifecycleCancellation pins that branch specifically. Resolved.
  • F7 addressed: swapwallet/service.go now emits WarnS(ctx, "Credit balance enrichment skipped", err) before returning the local snapshot, and TestServiceBalanceDegradesWhenCreditsAreUnavailable asserts both the degraded response and the warning text including the underlying error. Resolved.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Comment thread db/postgres_fixture.go
// runs. The db package marks most tests parallel, and unbounded docker startup
// can starve CI runners enough that stores observe partially initialized
// schemas.
// acquireTestPgFixtureSlot bounds concurrent Postgres fixture initialization.

@lightninglabs-gateway lightninglabs-gateway Bot Aug 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚫 Dismissed by @bhandras

🟡 F8 (Minor) — Live Postgres containers are no longer bounded · db/postgres_fixture.go:127

The semaphore previously bounded live fixtures at four, because the slot was held until TearDown; it now bounds only the initialization window, so the number of simultaneously running Postgres containers is capped by -parallel (GOMAXPROCS by default) rather than by testPgFixtureParallelism. On an 8- or 16-CPU runner that is 2–4× the containers PR #772 was sized for, and the starvation it was added to prevent — "stores observe partially initialized schemas" — is memory/IO pressure from concurrently running containers as much as from concurrent startup. The rewritten comment and the db/AGENTS.md invariant both describe the new, narrower guarantee accurately, so this is a resource-envelope decision rather than a mismatch between code and docs; the local timings in the PR description show the dev host absorbs it, not that a constrained CI runner does. If you want the old ceiling back without reintroducing hold-and-wait, a second, larger semaphore acquired for the container's lifetime (sized independently of the init bound) keeps the two concerns separate. Accepting the change as-is is also defensible — the CI signal on this head is what settles it.

@bhandras

Copy link
Copy Markdown
Member Author

F8 decision for exact head 7da20ddb1577e6b2eeea433d791608b9680d3eee:

  • Classification: not worth changing.
  • Trigger: more than four initialized Postgres fixtures can coexist when package parallelism exceeds four.
  • Consequence: possible CI runner memory or I/O pressure. No correctness or migration invariant is lost after store initialization completes.
  • Existing guard: four slots still serialize the resource-sensitive container startup and schema migration boundary. Release is idempotent, and teardown remains the failure-path fallback.
  • Exact-head evidence: all 20 required checks passed. This includes Postgres unit tests, Postgres system tests, race tests, SQLite tests, lint, static checks, and all cross-builds.
  • Local evidence: go test -tags=test_postgres ./waved -count=1 -timeout=30m passed in 40.830s and go test -tags=test_postgres ./db -count=1 -timeout=30m passed in 113.465s.
  • Fix risk: a second lifetime semaphore adds another coordination boundary and can recreate hold-and-wait starvation. The now-green CI run is the deciding resource-envelope evidence Gateway requested.

@bhandras

Copy link
Copy Markdown
Member Author

/gateway dismiss F8

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F8 (minor) by @bhandrasno reason given

All findings on this PR are addressed or dismissed.

@bhandras

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway

Copy link
Copy Markdown

No new commits since the last gateway review (HEAD is still 7da20dd). Findings still stand.

@bhandras
bhandras merged commit da8903b into main Aug 26, 2026
29 checks passed
@bhandras
bhandras deleted the codex/mobile-invoice-timeout branch August 26, 2026 11:50
@bhandras bhandras added the backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch label Aug 26, 2026
@github-actions

Copy link
Copy Markdown

Successfully created backport PR for v0.1.x-branch:

bhandras added a commit that referenced this pull request Aug 26, 2026
…ranch

[v0.1.x-branch] Backport #1131: mobile: prevent wallet stalls on external I/O
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants