Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,47 @@ public class PlatformWalletManager: ObservableObject {
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 {

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']

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']

PlatformWalletManager._leakedContextLock.lock()
if let h = persistenceHandler {
PlatformWalletManager._leakedContext.append(h)
}
if let h = eventHandler {
PlatformWalletManager._leakedContext.append(h)
}
PlatformWalletManager._leakedContextLock.unlock()
}
}
}
Comment on lines 155 to 184

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']

Comment on lines 155 to 184

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']


/// Lock guarding `_leakedContext`. Both are `nonisolated` so the
/// (implicitly nonisolated) `deinit` can append to the leak slot
/// without an isolation hop.
nonisolated static let _leakedContextLock = NSLock()
/// Process-global retention list for persister / event-handler
/// objects we cannot release at `deinit` because a lingering Rust
/// coordinator thread (`errorShutdownIncomplete` from `destroy`)
/// may still callback through their `Unmanaged.passUnretained`
/// context pointer. `AnyObject` keeps the existential opaque so
/// `PlatformWalletPersistenceHandler` / `PlatformWalletEventHandler`
/// don't need to be `Sendable` to be retained here.
nonisolated(unsafe) static var _leakedContext: [AnyObject] = []

// MARK: - Configuration

/// Configure the manager with an SDK and an optional SwiftData
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@ public enum PlatformWalletResultCode: Int32, Sendable {
/// outcome. Do NOT auto-retry — a retry would rebuild the bundle and
/// could double-execute if the original landed.
case errorShieldedSpendUnconfirmed = 18
/// A destroy/stop/clear completed but a background coordinator did not
/// exit cleanly (timed out or ended non-cleanly). The host should defer
/// freeing its callback context — a lingering coordinator may still fire
/// one final callback through it — and, on the clear path, must NOT
/// commit its own persistence wipe (the Rust store was left intact).
/// A destroy / stop / clear completed but one or more background
/// coordinator threads did not exit cleanly before the FFI's 30 s
/// join deadline. The host MUST keep its callback context (the
/// persister + event-handler Swift objects whose
/// `Unmanaged.passUnretained` opaque pointers were handed to Rust)
/// alive past `platform_wallet_manager_destroy` — a lingering
/// coordinator thread still holds an `Arc<FFIPersister>` /
/// `Arc<FFIEventHandler>` and may fire one final callback through
/// that context. On the clear path, the host must also NOT commit
/// its own persistence wipe — the Rust store was left intact so it
/// can be retried once the pass settles. The manager IS torn down;
/// do NOT retry `destroy`.
case errorShutdownIncomplete = 19
case notFound = 98
case errorUnknown = 99
Expand Down Expand Up @@ -185,11 +192,16 @@ public enum PlatformWalletError: LocalizedError {
/// notes reserved wallet-side (a shield reserves nothing) until the
/// next sync reconciles the outcome. Do NOT auto-retry.
case shieldedSpendUnconfirmed(String)
/// A destroy / stop / clear completed but a background coordinator did
/// not exit cleanly. The host should defer freeing its callback context
/// (a lingering coordinator may still fire one final callback) and, on
/// the clear path, must NOT commit its own persistence wipe — the Rust
/// store was left intact so it can be retried once the pass settles.
/// A destroy / stop / clear completed but a background coordinator
/// OS thread did not exit cleanly — `platform_wallet_manager_destroy`
/// returned without proving every coordinator had exited. The manager
/// is torn down, but the host must not free the Swift-side persister /
/// event-handler context until that thread has had a chance to wind
/// down — `PlatformWalletManager.deinit` retains those handlers in a
/// process-global leak slot on this code so a final, lingering Rust
/// callback finds valid memory. On the clear path, the host must also
/// NOT commit its own persistence wipe — the Rust store was left
/// intact so it can be retried once the pass settles.
case shutdownIncomplete(String)
case notFound(String)
case unknown(String)
Expand Down
Loading