Skip to content

fix(swift-sdk): handle ErrorShutdownIncomplete on PR #3954#3957

Closed
thepastaclaw wants to merge 3 commits into
dashpay:feat/platform-wallet-shutdown-joinfrom
thepastaclaw:fix/3954-cr-shutdown-incomplete
Closed

fix(swift-sdk): handle ErrorShutdownIncomplete on PR #3954#3957
thepastaclaw wants to merge 3 commits into
dashpay:feat/platform-wallet-shutdown-joinfrom
thepastaclaw:fix/3954-cr-shutdown-incomplete

Conversation

@thepastaclaw

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Fixes the two CodeRabbit findings on #3954 head 2bd9501a0e:

  1. Inline (packages/rs-platform-wallet-ffi/src/error.rs 128–135) — the Swift mirror PlatformWalletResultCode enum was missing the new errorShutdownIncomplete = 19 variant, so init(ffi:) fell through to errorUnknown for PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE. Swift callers had no way to distinguish a lifecycle-specific shutdown-incomplete from a generic unknown failure.
  2. Outside-diff (packages/rs-platform-wallet-ffi/src/manager.rs 366–385, critical) — the Rust FFI added ErrorShutdownIncomplete so the host could keep its callback context alive when a coordinator OS thread did not exit before the 30 s join deadline. But the Swift deinit at PlatformWalletManager.swift:158 calls platform_wallet_manager_destroy(handle).discard() and unconditionally proceeds to release persistenceHandler / eventHandler — whose Unmanaged.passUnretained(...).toOpaque() pointers a lingering coordinator's Arc<FFIPersister> / Arc<FFIEventHandler> still holds. Releasing them would dangle that pointer; the next callback would be a use-after-free — exactly the hazard the new result code was added to prevent.

What was done?

  • PlatformWalletResultCode.errorShutdownIncomplete = 19 and the matching init(ffi:) arm, plus a PlatformWalletError.shutdownIncomplete(String) payload so the error sums round-trip cleanly.
  • In PlatformWalletManager.deinit, capture the destroy result. On errorShutdownIncomplete, move the handler references into a process-global retention slot (_leakedContext guarded by _leakedContextLock) so any final coordinator callback resolves to live Swift memory. The clean path is unchanged.

The two changes are split into separate commits so the enum mirror can land independently of the host-side safety fix if needed.

How Has This Been Tested?

  • cargo check -p platform-wallet-ffi — clean.
  • swiftc -parse -swift-version 6 on both modified Swift files — parses clean.
  • Full Swift package build is not feasible in this temp clone: DashSDKFFI.xcframework is not checked in (built by packages/swift-sdk/build_ios.sh from Rust artifacts), and the only pre-built xcframework available on this machine predates the platform-wallet FFI. The PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE symbol added in this PR follows cbindgen's ErrorShutdownIncompletePLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE rule used by every other variant in the file.

Breaking Changes

None — errorShutdownIncomplete only fires when the new Rust ErrorShutdownIncomplete result code is returned, which is itself new in PR #3954.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have not provided a default value for specific properties without consultation

🤖 Generated with Claude Code

thepastaclaw and others added 2 commits June 23, 2026 10:25
…_INCOMPLETE

The Rust FFI gained `ErrorShutdownIncomplete = 19` so callers can know
that `platform_wallet_manager_destroy` returned without proving every
background coordinator thread exited. The Swift mirror was missing the
case, so `init(ffi:)` fell through to `errorUnknown` and Swift callers
had no way to tell the lifecycle-specific shutdown-incomplete from a
generic unknown failure.

Add `errorShutdownIncomplete = 19` to `PlatformWalletResultCode`, map
`PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE` in
`init(ffi:)`, and add a matching `PlatformWalletError.shutdownIncomplete`
variant so the error sums round-trip cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`platform_wallet_manager_destroy` now returns `errorShutdownIncomplete`
when one or more background coordinator OS threads did not exit before
the 30 s join deadline. A lingering coordinator still holds an
`Arc<FFIPersister>` / `Arc<FFIEventHandler>` whose context pointer is
`Unmanaged.passUnretained(handler).toOpaque()` for the manager's
`persistenceHandler` / `eventHandler` Swift objects — releasing those at
`deinit` would dangle that pointer and the next callback would be a
use-after-free, which is exactly the hazard the new result code was
introduced to prevent.

Capture the destroy result in `deinit` and, on `errorShutdownIncomplete`,
move the handler references into a process-global retention slot so any
final coordinator callback still resolves to live Swift memory. The
clean path is unchanged: a `success` destroy releases the handlers as
usual. The leak is bounded by the (rare) number of non-clean shutdowns
and only retains two small objects per occurrence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d1f68335-ded3-4e64-9972-c06f89136577

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@thepastaclaw

thepastaclaw commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

✅ Review complete (commit e16bfaa)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review

Small, focused fix that correctly mirrors the new Rust ErrorShutdownIncomplete result code into Swift and prevents a use-after-free of the Unmanaged.passUnretained callback context when platform_wallet_manager_destroy cannot prove every coordinator thread exited. The fix is correct as written. Two non-blocking suggestions: harden the retention branch against future destroy() return codes, and add a regression test that locks in the enum mapping and the deinit retention path.

🟡 2 suggestion(s)

🤖 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.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:173: Retain on any non-success code, not just `.errorShutdownIncomplete`
  The retention branch matches exactly on `.errorShutdownIncomplete`, but the Rust contract on the destroy boundary is broader: only a `Success` return guarantees no coordinator thread is still holding `Arc<FFIPersister>` / `Arc<FFIEventHandler>` whose context is `Unmanaged.passUnretained(handler).toOpaque()`. Today destroy only returns `Success` or `ErrorShutdownIncomplete`, so the current check works. However, `PlatformWalletResultCode.init(ffi:)` at `PlatformWalletResult.swift:100-101` maps unknown raw values to `.errorUnknown`, so if Rust ever grows another unclean-shutdown variant (e.g. a panicked-thread code) and Swift isn't regenerated in lockstep, the deinit will silently free the handler objects while a coordinator thread may still call back through them — re-opening the UAF. Inverting the check is the same one-line change but stays correct for any future destroy() return code.
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:153-184: No test exercises the `errorShutdownIncomplete` mapping or the deinit retention path
  Two regressions this PR fixes are entirely uncovered: (a) the Swift mirror drift where `PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE` previously fell through to `.errorUnknown`, and (b) the deinit retention that keeps `persistenceHandler` / `eventHandler` alive when destroy reports `ErrorShutdownIncomplete`. A small `@testable` unit test that constructs the FFI code and asserts the mapping to `.errorShutdownIncomplete` + `PlatformWalletError.shutdownIncomplete` locks in (a). A test that drives destroy into the incomplete branch (e.g. via an FFI shim that always returns the incomplete code) and probes the handler via a `weak` reference that should remain non-nil after the manager goes out of scope locks in (b). Because the production failure mode requires the 30 s coordinator-join deadline to expire, this is the only realistic way to keep the fix from silently regressing. The PR checklist already acknowledges this gap.

let destroyResult = PlatformWalletResult(
platform_wallet_manager_destroy(handle)
)
if destroyResult.code == .errorShutdownIncomplete {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: Retain on any non-success code, not just .errorShutdownIncomplete

The retention branch matches exactly on .errorShutdownIncomplete, but the Rust contract on the destroy boundary is broader: only a Success return guarantees no coordinator thread is still holding Arc<FFIPersister> / Arc<FFIEventHandler> whose context is Unmanaged.passUnretained(handler).toOpaque(). Today destroy only returns Success or ErrorShutdownIncomplete, so the current check works. However, PlatformWalletResultCode.init(ffi:) at PlatformWalletResult.swift:100-101 maps unknown raw values to .errorUnknown, so if Rust ever grows another unclean-shutdown variant (e.g. a panicked-thread code) and Swift isn't regenerated in lockstep, the deinit will silently free the handler objects while a coordinator thread may still call back through them — re-opening the UAF. Inverting the check is the same one-line change but stays correct for any future destroy() return code.

Suggested change
if destroyResult.code == .errorShutdownIncomplete {
if destroyResult.code != .success {

source: ['claude']

Comment on lines 155 to 184
if handle != NULL_HANDLE {
platform_wallet_manager_platform_address_sync_stop(handle).discard()
platform_wallet_manager_shielded_sync_stop(handle).discard()
platform_wallet_manager_destroy(handle).discard()
// `destroy` joins every coordinator OS thread with a 30 s
// deadline. On `errorShutdownIncomplete` one or more
// coordinators are still alive and still hold the
// `Arc<FFIPersister>` / `Arc<FFIEventHandler>` whose context
// pointer is `Unmanaged.passUnretained(handler).toOpaque()`
// for the Swift handler objects below — freeing those Swift
// objects now would dangle that pointer and the next
// callback would be a use-after-free. Stash them in a
// process-global leak slot so any final callback still
// sees valid memory. We accept the bounded leak (two small
// objects per non-clean shutdown); a clean shutdown returns
// success and the handlers are released as usual.
let destroyResult = PlatformWalletResult(
platform_wallet_manager_destroy(handle)
)
if destroyResult.code == .errorShutdownIncomplete {
PlatformWalletManager._leakedContextLock.lock()
if let h = persistenceHandler {
PlatformWalletManager._leakedContext.append(h)
}
if let h = eventHandler {
PlatformWalletManager._leakedContext.append(h)
}
PlatformWalletManager._leakedContextLock.unlock()
}
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: No test exercises the errorShutdownIncomplete mapping or the deinit retention path

Two regressions this PR fixes are entirely uncovered: (a) the Swift mirror drift where PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE previously fell through to .errorUnknown, and (b) the deinit retention that keeps persistenceHandler / eventHandler alive when destroy reports ErrorShutdownIncomplete. A small @testable unit test that constructs the FFI code and asserts the mapping to .errorShutdownIncomplete + PlatformWalletError.shutdownIncomplete locks in (a). A test that drives destroy into the incomplete branch (e.g. via an FFI shim that always returns the incomplete code) and probes the handler via a weak reference that should remain non-nil after the manager goes out of scope locks in (b). Because the production failure mode requires the 30 s coordinator-join deadline to expire, this is the only realistic way to keep the fix from silently regressing. The PR checklist already acknowledges this gap.

source: ['claude', 'codex']

…rm-wallet-shutdown-join

Combined the errorShutdownIncomplete / shutdownIncomplete doc comments from
both sides: the Swift retain-context guidance (Unmanaged.passUnretained
opaque pointers handed to Rust must stay alive past destroy; PlatformWalletManager.deinit
parks them in a process-global leak slot; do NOT retry destroy) and the
broader destroy/stop/clear scope plus the clear-path rule (host must NOT
commit its own persistence wipe -- Rust store is left intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review

Cumulative review. The Swift mirror + deinit-retention fixes from prior commits remain correctly applied, and the new symmetric ErrorShutdownIncomplete contract on shielded clear/stop is well-implemented on the Rust/FFI side. One blocking issue: ShieldedService.clearLocalState (the example app's Clear button) catches the new shutdownIncomplete error, logs it, and proceeds to wipe SwiftData rows — directly violating the new FFI contract that documents 'the host must check this before wiping its own persistence'. The two prior suggestions (broader retention predicate, missing Swift coverage) are still applicable. Out of 10 budget.

🔴 1 blocking | 🟡 2 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift:604-624: Clear flow wipes SwiftData rows even when Rust reports ErrorShutdownIncomplete
  This PR introduces a new symmetric contract on `platform_wallet_manager_shielded_clear`: when the in-flight pass fails to drain cleanly the call returns `ErrorShutdownIncomplete` and *intentionally leaves the Rust commitment-tree store intact*. The FFI doc (rs-platform-wallet-ffi/src/shielded_sync.rs:460-465) spells out the host obligation explicitly: "The host **must** check this before wiping its own persistence: a silent failure would leave the shared tree populated while the host drops its rows, and the next cold resync would gate-skip every re-downloaded position against the stale tree size."

  The Swift `clearShielded()` wrapper now throws `PlatformWalletError.shutdownIncomplete` on this code (PlatformWalletManagerShieldedSync.swift:309, PlatformWalletResult.swift:252). But `ShieldedService.clearLocalState` at lines 604-611 catches *every* error from `clearShielded()`, logs it, and falls through to the `modelContext.delete(model: PersistentShielded*)` block at lines 619-624 — exactly the wipe the new contract was introduced to prevent. The stale comment at lines 602-603 ("Best-effort — failure logs but doesn't abort the wipe") is now incorrect.

  On the `shutdownIncomplete` branch the function must abort the wipe and surface the error to the user; other clear failures may keep the existing best-effort behavior.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:173: Retention predicate should be `!= .success`, not exact match on `.errorShutdownIncomplete`
  The retention slot exists because any non-`Success` return from `platform_wallet_manager_destroy` means a coordinator thread may still dereference the `Unmanaged.passUnretained(handler).toOpaque()` context pointer for `persistenceHandler` / `eventHandler`. Today the Rust `destroy` (manager.rs:351-388) returns only `ok()` or `ErrorShutdownIncomplete`, so the exact match works — but any future failure code added on this boundary (e.g. a propagated `InvalidHandle` from a double-destroy race) silently falls through and releases the handlers while a coordinator still holds the pointer. Widening to `!= .success` costs at most a bounded leak on currently-unreachable paths and removes the forward-compat cliff. Carry-over from prior review; the latest delta does not change line 173.
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:153-184: No Swift coverage for the errorShutdownIncomplete mirror or the deinit retention path
  Two cross-boundary regressions this PR fixes remain entirely uncovered on the Swift side: (a) `PlatformWalletResultCode.init(ffi:)` mapping `PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE → .errorShutdownIncomplete` (PlatformWalletResult.swift:98-99) — exactly the mirror drift that previously fell through to `.errorUnknown`; and (b) the deinit retention branch at PlatformWalletManager.swift:173-182 that places `persistenceHandler` / `eventHandler` into `_leakedContext` when destroy reports incomplete shutdown. The new Rust test `shielded_shutdown_incomplete_maps_to_dedicated_code` covers the Rust→FFI mapping but does not protect the Swift enum mirror or the deinit branch from drifting again. A small Swift test that asserts the enum mapping and (with a stubbed handle whose destroy returns each code) drives a `PlatformWalletManager` through configure→tear-down and asserts the `_leakedContext` count delta would close the loop. Carry-over from prior review.

let destroyResult = PlatformWalletResult(
platform_wallet_manager_destroy(handle)
)
if destroyResult.code == .errorShutdownIncomplete {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: Retention predicate should be != .success, not exact match on .errorShutdownIncomplete

The retention slot exists because any non-Success return from platform_wallet_manager_destroy means a coordinator thread may still dereference the Unmanaged.passUnretained(handler).toOpaque() context pointer for persistenceHandler / eventHandler. Today the Rust destroy (manager.rs:351-388) returns only ok() or ErrorShutdownIncomplete, so the exact match works — but any future failure code added on this boundary (e.g. a propagated InvalidHandle from a double-destroy race) silently falls through and releases the handlers while a coordinator still holds the pointer. Widening to != .success costs at most a bounded leak on currently-unreachable paths and removes the forward-compat cliff. Carry-over from prior review; the latest delta does not change line 173.

Suggested change
if destroyResult.code == .errorShutdownIncomplete {
if destroyResult.code != .success {

source: ['claude', 'codex']

Comment on lines 155 to 184
if handle != NULL_HANDLE {
platform_wallet_manager_platform_address_sync_stop(handle).discard()
platform_wallet_manager_shielded_sync_stop(handle).discard()
platform_wallet_manager_destroy(handle).discard()
// `destroy` joins every coordinator OS thread with a 30 s
// deadline. On `errorShutdownIncomplete` one or more
// coordinators are still alive and still hold the
// `Arc<FFIPersister>` / `Arc<FFIEventHandler>` whose context
// pointer is `Unmanaged.passUnretained(handler).toOpaque()`
// for the Swift handler objects below — freeing those Swift
// objects now would dangle that pointer and the next
// callback would be a use-after-free. Stash them in a
// process-global leak slot so any final callback still
// sees valid memory. We accept the bounded leak (two small
// objects per non-clean shutdown); a clean shutdown returns
// success and the handlers are released as usual.
let destroyResult = PlatformWalletResult(
platform_wallet_manager_destroy(handle)
)
if destroyResult.code == .errorShutdownIncomplete {
PlatformWalletManager._leakedContextLock.lock()
if let h = persistenceHandler {
PlatformWalletManager._leakedContext.append(h)
}
if let h = eventHandler {
PlatformWalletManager._leakedContext.append(h)
}
PlatformWalletManager._leakedContextLock.unlock()
}
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: No Swift coverage for the errorShutdownIncomplete mirror or the deinit retention path

Two cross-boundary regressions this PR fixes remain entirely uncovered on the Swift side: (a) PlatformWalletResultCode.init(ffi:) mapping PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE → .errorShutdownIncomplete (PlatformWalletResult.swift:98-99) — exactly the mirror drift that previously fell through to .errorUnknown; and (b) the deinit retention branch at PlatformWalletManager.swift:173-182 that places persistenceHandler / eventHandler into _leakedContext when destroy reports incomplete shutdown. The new Rust test shielded_shutdown_incomplete_maps_to_dedicated_code covers the Rust→FFI mapping but does not protect the Swift enum mirror or the deinit branch from drifting again. A small Swift test that asserts the enum mapping and (with a stubbed handle whose destroy returns each code) drives a PlatformWalletManager through configure→tear-down and asserts the _leakedContext count delta would close the loop. Carry-over from prior review.

source: ['claude', 'codex']

@thepastaclaw

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #3954. The current #3954 head already includes the Swift errorShutdownIncomplete mirror plus PlatformWalletManager.deinit handling (discardReturningCode() / retained handlers on shutdown-incomplete), so keeping this helper PR open only leaves a conflicting duplicate.

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.

1 participant