Skip to content

fix(platform-wallet): spv client deadlocking when sending a tx - #3730

Merged
QuantumExplorer merged 1 commit into
v3.1-devfrom
fix/spv-tx-boardcast
May 23, 2026
Merged

fix(platform-wallet): spv client deadlocking when sending a tx#3730
QuantumExplorer merged 1 commit into
v3.1-devfrom
fix/spv-tx-boardcast

Conversation

@ZocoLini

@ZocoLini ZocoLini commented May 22, 2026

Copy link
Copy Markdown
Collaborator

I misplaced two lines of code in PR #3729 blocking the spa client when sending a tx, this fixes that

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

  • Bug Fixes
    • SPV client now remains available after broadcasting a transaction, allowing continued use without interruption.
    • Runtime shutdown now performs improved cleanup and returns final sync status reliably.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

SPV runtime lifecycle management is reorganized: broadcast_transaction no longer clears the stored client after a broadcast call, while run() now explicitly drops the read lock and takes ownership of the client via a write lock at the end of the sync loop before returning its result.

Changes

SPV Runtime Client Lifecycle

Layer / File(s) Summary
Deferred client cleanup from broadcast to run
packages/rs-platform-wallet/src/spv/runtime.rs
broadcast_transaction removes its post-broadcast write-lock take() and returns the mapped broadcast outcome without clearing the stored client; run() captures client.run() result, drops the read guard, acquires a write lock, take()s/clears self.client, then returns the earlier-captured result.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • dashpay/platform#3729: Both PRs modify packages/rs-platform-wallet/src/spv/runtime.rs’s SpvRuntime lifecycle—especially SpvRuntime::run.

Suggested reviewers

  • QuantumExplorer

Poem

🐰
I hopped through locks and held my breath,
A broadcast left the client’s death—undone,
run() kept the final tidy sweep,
A gentle take before return was won,
Hooray for orderly shutdowns and sun!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the main fix: resolving an SPV client deadlock issue when sending a transaction, which directly aligns with the changeset's objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/spv-tx-boardcast

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added this to the v3.1.0 milestone May 22, 2026
@ZocoLini
ZocoLini marked this pull request as ready for review May 22, 2026 21:10
@ZocoLini
ZocoLini requested a review from QuantumExplorer as a code owner May 22, 2026 21:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rs-platform-wallet/src/spv/runtime.rs (1)

138-149: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Fix SPV shutdown + cleanup in SpvRuntime::run (packages/rs-platform-wallet/src/spv/runtime.rs)

  • run() holds self.client.read().await (client_guard) across client.run().await (lines 138–146); stop() needs self.client.write().await (line 157) to take() and call c.stop(), so shutdown can hang on the documented “until calling stop” path.
  • If client.run().await returns Err, the ? at line 146 skips the take() cleanup (lines 148–150), leaving self.client populated and the runtime stuck “started”.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/spv/runtime.rs` around lines 138 - 149,
SpvRuntime::run holds the read lock (self.client.read().await -> client_guard)
across the await to client.run().await which blocks stop() because stop() needs
the write lock to take() and stop the client; also on error the early return
skips the cleanup that should take and stop the client. Fix by releasing the
read guard before awaiting client.run() (drop client_guard or restructure so you
clone needed handle), and ensure cleanup always runs on both Ok and Err paths by
using a match/let result = client.run().await; then always acquire
self.client.write().await, take() the client and call c.stop() (or call stop()
inside a finally-like block) so the runtime is properly cleared even if
client.run().await errors; reference SpvRuntime::run,
client_guard/self.client.read(), client.run().await, self.client.write().await,
take(), and stop().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet/src/spv/runtime.rs`:
- Around line 143-152: SpvRuntime::run() currently leaves self.client set if
client.run().await errors, causing SpvAlreadyRunning on subsequent starts;
modify the error path so that before returning the PlatformWalletError (from
client.run().await), you acquire the write lock on self.client (via
self.client.write().await) and call take() to clear it, then return the error
(e.g., in the map_err closure or a match/if let Err branch). Ensure the existing
successful-path drop(client_guard) + write-lock + take() remains as-is and that
the clearing logic references the same self.client.write().await and take() used
elsewhere.

---

Outside diff comments:
In `@packages/rs-platform-wallet/src/spv/runtime.rs`:
- Around line 138-149: SpvRuntime::run holds the read lock
(self.client.read().await -> client_guard) across the await to
client.run().await which blocks stop() because stop() needs the write lock to
take() and stop the client; also on error the early return skips the cleanup
that should take and stop the client. Fix by releasing the read guard before
awaiting client.run() (drop client_guard or restructure so you clone needed
handle), and ensure cleanup always runs on both Ok and Err paths by using a
match/let result = client.run().await; then always acquire
self.client.write().await, take() the client and call c.stop() (or call stop()
inside a finally-like block) so the runtime is properly cleared even if
client.run().await errors; reference SpvRuntime::run,
client_guard/self.client.read(), client.run().await, self.client.write().await,
take(), and stop().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ccf69bca-614e-416f-a9e9-5bc8d4383ad9

📥 Commits

Reviewing files that changed from the base of the PR and between db8fb08 and 389443f.

📒 Files selected for processing (1)
  • packages/rs-platform-wallet/src/spv/runtime.rs

Comment thread packages/rs-platform-wallet/src/spv/runtime.rs Outdated
@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.16%. Comparing base (db8fb08) to head (f7e8381).

Additional details and impacted files
@@            Coverage Diff             @@
##           v3.1-dev    #3730    +/-   ##
==========================================
  Coverage     87.15%   87.16%            
==========================================
  Files          2606     2607     +1     
  Lines        319221   319420   +199     
==========================================
+ Hits         278216   278412   +196     
- Misses        41005    41008     +3     
Components Coverage Δ
dpp 87.67% <ø> (-0.01%) ⬇️
drive 85.95% <ø> (ø)
drive-abci 89.60% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.17% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.16% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ZocoLini
ZocoLini force-pushed the fix/spv-tx-boardcast branch from 389443f to f7e8381 Compare May 22, 2026 21:21
@github-actions

github-actions Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "bdbf020b86dcb1aa9ad55ccdf7414e876534d92bd58b3640447f37973ec5843a"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

The PR correctly fixes the broadcast self-deadlock that PR #3729 introduced by moving the client.take() cleanup out of the broadcast/start hot path and into run(). However, the cleanup is gated behind a ? so any error from DashSpvClient::run() leaks the client and permanently wedges the runtime in SpvAlreadyRunning state. The broader lock design (read guard held across the entire run() await) also means stop() cannot preempt run() without deadlocking, perpetuating the class of issue this PR is trying to fix.

🔴 1 blocking | 🟡 2 suggestion(s)

3 additional finding(s)

blocking: `self.client` is never cleared when `client.run()` returns an error

packages/rs-platform-wallet/src/spv/runtime.rs (line 143)

The ? on client.run().await (line 146) short-circuits before the cleanup block at lines 148-150 runs. If DashSpvClient::run() returns Err, the function exits early, self.client stays Some(_), and is_started() keeps returning true even though the inner SPV loop has terminated. From that point on every call to start() deterministically returns SpvAlreadyRunning via the pre-check at lines 50-53, making the runtime non-recoverable from any transient SPV failure until the process is restarted. The failure is especially silent through spawn_in_background() (line 169), which logs and drops the error.

Capture the result first, drop the read guard, then take the write lock and clear unconditionally before propagating the original error.

        let run_result = client
            .run()
            .await
            .map_err(|e| PlatformWalletError::SpvError(e.to_string()));

        drop(client_guard);
        let mut client = self.client.write().await;
        let _ = client.take();

        run_result
suggestion: Read guard held across `client.run().await` makes `stop()` impossible to invoke concurrently

packages/rs-platform-wallet/src/spv/runtime.rs (line 138)

client_guard (a tokio::sync::RwLock read guard on self.client) is held for the full duration of client.run().await. stop() (lines 156-164) starts with self.client.write().await; tokio's RwLock does not let a writer cut in front of active readers and a pending writer blocks new readers. Consequences while run() is alive:

  1. stop() cannot acquire the write lock and will hang until run() returns of its own accord — defeating the purpose of having an external stop().
  2. The pending writer in stop() will then also block every read-side accessor (broadcast_transaction, sync_progress, update_config, clear_storage, tip_block_time, get_quorum_public_key), reproducing the broader self-deadlock class of bug this PR is supposed to fix.

Expose a shutdown signal (cancellation token or oneshot) that stop() can fire without taking the write lock, or restructure ownership so client.run() is driven via a clone/Arc and the guard is dropped before awaiting the loop.

suggestion: No regression test exercises the broadcast-after-start lifecycle

packages/rs-platform-wallet/src/spv/runtime.rs (line 132)

The original defect (broadcast_transaction returning SpvNotRunning immediately after a successful start() because the client was taken out) is reproducible with a small async test that starts the runtime, spawns run(), and awaits broadcast_transaction. Adding such a test would lock in the fix and would also catch a regression of the error-path cleanup bug flagged above. The existing tests/spv_sync.rs is #[ignore]d and does not cover this state machine.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

- [BLOCKING] In `packages/rs-platform-wallet/src/spv/runtime.rs`:143-152: `self.client` is never cleared when `client.run()` returns an error
  The `?` on `client.run().await` (line 146) short-circuits before the cleanup block at lines 148-150 runs. If `DashSpvClient::run()` returns `Err`, the function exits early, `self.client` stays `Some(_)`, and `is_started()` keeps returning true even though the inner SPV loop has terminated. From that point on every call to `start()` deterministically returns `SpvAlreadyRunning` via the pre-check at lines 50-53, making the runtime non-recoverable from any transient SPV failure until the process is restarted. The failure is especially silent through `spawn_in_background()` (line 169), which logs and drops the error.

Capture the result first, drop the read guard, then take the write lock and clear unconditionally before propagating the original error.
- [SUGGESTION] In `packages/rs-platform-wallet/src/spv/runtime.rs`:138-153: Read guard held across `client.run().await` makes `stop()` impossible to invoke concurrently
  `client_guard` (a `tokio::sync::RwLock` read guard on `self.client`) is held for the full duration of `client.run().await`. `stop()` (lines 156-164) starts with `self.client.write().await`; tokio's `RwLock` does not let a writer cut in front of active readers and a pending writer blocks new readers. Consequences while `run()` is alive:

1. `stop()` cannot acquire the write lock and will hang until `run()` returns of its own accord — defeating the purpose of having an external `stop()`.
2. The pending writer in `stop()` will then also block every read-side accessor (`broadcast_transaction`, `sync_progress`, `update_config`, `clear_storage`, `tip_block_time`, `get_quorum_public_key`), reproducing the broader self-deadlock class of bug this PR is supposed to fix.

Expose a shutdown signal (cancellation token or oneshot) that `stop()` can fire without taking the write lock, or restructure ownership so `client.run()` is driven via a clone/`Arc` and the guard is dropped before awaiting the loop.
- [SUGGESTION] In `packages/rs-platform-wallet/src/spv/runtime.rs`:132-153: No regression test exercises the broadcast-after-start lifecycle
  The original defect (`broadcast_transaction` returning `SpvNotRunning` immediately after a successful `start()` because the client was taken out) is reproducible with a small async test that starts the runtime, spawns `run()`, and awaits `broadcast_transaction`. Adding such a test would lock in the fix and would also catch a regression of the error-path cleanup bug flagged above. The existing `tests/spv_sync.rs` is `#[ignore]`d and does not cover this state machine.

Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

The blocking error-path cleanup bug from the prior review (prior-1) is FIXED at f7e8381: run() now captures the result of client.run().await, drops the read guard, takes the write lock to clear self.client, and then returns the captured result. Two prior suggestions remain STILL VALID and are carried forward: the read guard held across the entire inner client.run().await blocks stop() from preempting run() (and pending writers also block all read-side accessors), and no regression test exercises the start/run/broadcast/stop lifecycle that originally regressed. No new latest-delta-only issues identified.

🟡 2 suggestion(s)

2 additional finding(s)

suggestion: Read guard held across `client.run().await` still prevents `stop()` from preempting `run()` (carried forward)

packages/rs-platform-wallet/src/spv/runtime.rs (line 138)

Carried forward from the prior review at 389443f and STILL VALID at f7e8381. client_guard (a tokio::sync::RwLock read guard on self.client) is acquired at line 138 and kept alive for the full duration of client.run().await at lines 143-146. stop() (lines 156-164) begins with self.client.write().await; tokio's RwLock does not let a writer cut in front of an active reader, and once stop()'s write waiter is queued it also blocks every new reader.

Consequences while run() is alive:

  1. stop() cannot acquire the write lock and will hang until run() returns of its own accord — defeating the documented contract at line 132 that run() continues until stop() is called, and making external shutdown impossible.
  2. The pending writer in stop() will additionally block every read-side accessor (broadcast_transaction, sync_progress, update_config, clear_storage, tip_block_time, get_quorum_public_key), reproducing the same self-deadlock class of bug this PR is trying to eliminate.

The incremental delta in this PR correctly fixes the error-path cleanup, but the underlying lock geometry that produced the original wedge (a read guard held across an indefinite await) is unchanged. Preferred shapes: expose a shutdown signal (cancellation token or oneshot) that stop() can fire without taking the write lock; drive client.run() via a clone/Arc so the guard can be dropped before awaiting the loop; or Option::take() the client out of the slot before awaiting and restore it on completion.

suggestion: No regression test exercises the broadcast-after-start lifecycle or the new error-path cleanup (carried forward)

packages/rs-platform-wallet/src/spv/runtime.rs (line 132)

Carried forward from the prior review at 389443f and STILL VALID at f7e8381. The original defect from PR #3729 (broadcast_transaction returning SpvNotRunning immediately after a successful start() because the client had been taken out of the slot) is reproducible with a small async test that drives SpvRuntime::run() in a spawned task and then awaits broadcast_transaction. The same harness, with an injected failure on client.run(), would also lock in the newly-added cleanup-on-error behavior at lines 148-150 and catch any future regression that reintroduces a ? short-circuit before cleanup.

Verified against the worktree at f7e8381: packages/rs-platform-wallet/tests/ contains spv_sync.rs, contact_workflow_tests.rs, and thread_safety.rs; none reference SpvRuntime, broadcast_transaction, or spawn_in_background, and the existing tests/spv_sync.rs cases remain #[ignore]d and depend on live testnet connectivity. A focused async test with a controllable/fake client (or a thin test seam around the runtime) would give deterministic CI coverage for this state machine.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

- [SUGGESTION] In `packages/rs-platform-wallet/src/spv/runtime.rs`:138-153: Read guard held across `client.run().await` still prevents `stop()` from preempting `run()` (carried forward)
  Carried forward from the prior review at 389443ff and STILL VALID at f7e8381c. `client_guard` (a `tokio::sync::RwLock` read guard on `self.client`) is acquired at line 138 and kept alive for the full duration of `client.run().await` at lines 143-146. `stop()` (lines 156-164) begins with `self.client.write().await`; tokio's `RwLock` does not let a writer cut in front of an active reader, and once `stop()`'s write waiter is queued it also blocks every new reader.

Consequences while `run()` is alive:

1. `stop()` cannot acquire the write lock and will hang until `run()` returns of its own accord — defeating the documented contract at line 132 that `run()` continues until `stop()` is called, and making external shutdown impossible.
2. The pending writer in `stop()` will additionally block every read-side accessor (`broadcast_transaction`, `sync_progress`, `update_config`, `clear_storage`, `tip_block_time`, `get_quorum_public_key`), reproducing the same self-deadlock class of bug this PR is trying to eliminate.

The incremental delta in this PR correctly fixes the error-path cleanup, but the underlying lock geometry that produced the original wedge (a read guard held across an indefinite await) is unchanged. Preferred shapes: expose a shutdown signal (cancellation token or oneshot) that `stop()` can fire without taking the write lock; drive `client.run()` via a clone/`Arc` so the guard can be dropped before awaiting the loop; or `Option::take()` the client out of the slot before awaiting and restore it on completion.
- [SUGGESTION] In `packages/rs-platform-wallet/src/spv/runtime.rs`:132-153: No regression test exercises the broadcast-after-start lifecycle or the new error-path cleanup (carried forward)
  Carried forward from the prior review at 389443ff and STILL VALID at f7e8381c. The original defect from PR #3729 (`broadcast_transaction` returning `SpvNotRunning` immediately after a successful `start()` because the client had been taken out of the slot) is reproducible with a small async test that drives `SpvRuntime::run()` in a spawned task and then awaits `broadcast_transaction`. The same harness, with an injected failure on `client.run()`, would also lock in the newly-added cleanup-on-error behavior at lines 148-150 and catch any future regression that reintroduces a `?` short-circuit before cleanup.

Verified against the worktree at f7e8381c: `packages/rs-platform-wallet/tests/` contains `spv_sync.rs`, `contact_workflow_tests.rs`, and `thread_safety.rs`; none reference `SpvRuntime`, `broadcast_transaction`, or `spawn_in_background`, and the existing `tests/spv_sync.rs` cases remain `#[ignore]`d and depend on live testnet connectivity. A focused async test with a controllable/fake client (or a thin test seam around the runtime) would give deterministic CI coverage for this state machine.

Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.

@QuantumExplorer QuantumExplorer changed the title fix: fix spv client deadlocking himself when sending a tx fix: spv client deadlocking himself when sending a tx May 23, 2026
@QuantumExplorer QuantumExplorer changed the title fix: spv client deadlocking himself when sending a tx fix(platform-wallet): spv client deadlocking himself when sending a tx May 23, 2026
@QuantumExplorer QuantumExplorer changed the title fix(platform-wallet): spv client deadlocking himself when sending a tx fix(platform-wallet): spv client deadlocking when sending a tx May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants