Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
120 changes: 88 additions & 32 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -682,8 +682,8 @@ pub struct AppState {
spv_block: SpvBlockReconciler,
/// Data-migration banner + cold-start `FinishUnwire` dispatch reconciler.
migration: MigrationReconciler,
/// Async shutdown receiver. `Some` while a graceful shutdown is in progress;
/// the viewport is closed once the receiver resolves.
/// Async shutdown receiver. `Some` until a graceful shutdown reaches a
/// terminal state; the viewport is closed once the receiver resolves.
shutdown_receiver: Option<tokio::sync::oneshot::Receiver<()>>,
/// Timestamp when the async shutdown was initiated, used as a hard deadline
/// to force-close the viewport if the shutdown task stalls.
Expand Down Expand Up @@ -1827,6 +1827,60 @@ impl AppState {
);
}
}

fn collect_created_context(contexts: &mut Vec<Arc<AppContext>>, task_result: TaskResult) {
if let TaskResult::Success { result, .. } = task_result
&& let BackendTaskSuccessResult::NetworkContextCreated { context, .. } = *result
{
contexts.push(context);
}
Comment on lines +2155 to +2161

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium: the degraded shutdown branches skip forget_all_secrets, quietly breaking this PR's own CHANGELOG promise

Independently flagged by two of my reviewers (security + QA), and it holds up on inspection. finish_shutdown_after_tasks runs the wallet-teardown future — the only caller of forget_all_secrets() on the exit path — solely on TaskShutdownOutcome::Complete. On BackendTasksTimedOut and None/TaskManagerFailed it returns early and clears nothing.

That branch is reachable in practice, not just in theory: graceful_shutdown_budget() is 20s (2× SHUTDOWN_TIMEOUT), while backend network calls routinely run up to NETWORK_REQUEST_TIMEOUT = 90s. A user closing the app while a slow token/contract/network task is still in flight lands here — and SecretAccess::forget_all (src/wallet_backend/secret_access.rs:656-663) is a synchronous, I/O-free RwLock::write().clear() with no dependency on any backend task finishing, so there's no good reason to gate it behind the tracked-join phase. Both start_async_shutdown and run_blocking_shutdown_fallback share this function, so both paths inherit the gap — and backend_task_timeout_skips_wallet_teardown asserts teardown_calls == 0 for exactly this outcome, enshrining it.

Meanwhile CHANGELOG.md (### Fixed) promises, unconditionally, that closing the app "finishes wallet activity and clears in-memory secrets." The code doesn't, on these branches. The Zeroizing drop backstop still fires at process teardown, so this is a widened plaintext-in-memory window rather than a permanent leak — hence Medium, not High — but for a wallet it's worth closing.

Recommendation: split the teardown. Run forget_all_secrets() on every collected context unconditionally (safe to run alongside a stuck/aborted task — it only clears the session cache; any in-flight op holds its own op-scoped Zeroizing copy), and keep the coordinator-joining backend.shutdown() gated on Complete. The deliberate choice to skip shutdown() on the degraded branch (avoiding teardown of resources still in use) is fine to keep — only the cheap secret clear needs decoupling. If the skip is genuinely intended, reword the CHANGELOG so it stops promising unconditional clearing. The sibling headless path shutdown_app_context_wallet_backend (src/mcp/server.rs:16-33) has the same omission and deserves the same fix.

🤖 Co-authored by Claudius the Magnificent AI Agent — automated grumpy-review (security + project + QA trio)

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.

Re-verified against current HEAD (26cb9d6d + local merge of v1.0-dev + a CI compile fix, not yet pushed): still valid, not addressed.

finish_shutdown_after_tasks (src/app.rs) still routes forget_all_secrets() exclusively through finish_wallet_shutdown, which only runs on TaskShutdownOutcome::Complete. The BackendTasksTimedOut and None (TaskManagerFailed) branches return early with no secret clearing — confirmed by the backend_task_timeout_skips_wallet_teardown test itself, which asserts teardown_calls == 0 for that outcome. The commit that landed since your review (26cb9d6d, "close remaining barrier gaps") fixed the join-error-callback and MCP network-switch barrier gaps but didn't touch this one.

Leaving unresolved — the recommended split (unconditional forget_all_secrets() on every collected context, backend.shutdown() still gated on Complete) hasn't been implemented yet. Out of scope for this pass (CI-fix + comment triage only); tracking as a follow-up.

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.

Fixed in 7603be6c.

finish_shutdown_after_tasks now calls forget_all_secrets() on every collected wallet backend unconditionally, before branching on the outcome — only the full coordinator backend.shutdown() join stays gated on Complete. Applies to both start_async_shutdown and run_blocking_shutdown_fallback. New regression tests (backend_task_timeout_forgets_secrets_without_wallet_teardown, task_manager_failure_forgets_secrets_without_wallet_teardown) assert secrets are cleared with zero coordinator-shutdown calls on both degraded outcomes.

}
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

fn start_async_shutdown(&mut self) -> tokio::sync::oneshot::Receiver<()> {
self.subtasks.cancellation_token.cancel();
let mut contexts = self.network_contexts.values().cloned().collect::<Vec<_>>();
let subtasks = Arc::clone(&self.subtasks);
let (_empty_sender, empty_receiver) = tokiompsc::channel(1);
let mut task_result_receiver =
std::mem::replace(&mut self.task_result_receiver, empty_receiver);
Comment thread
lklimek marked this conversation as resolved.
Outdated
let (tx, rx) = tokio::sync::oneshot::channel();

tokio::spawn(async move {
let mut task_shutdown = subtasks.shutdown_async();
let task_shutdown_completed = loop {
tokio::select! {
result = &mut task_shutdown => break result.is_ok(),
task_result = task_result_receiver.recv() => {
let Some(task_result) = task_result else {
break task_shutdown.await.is_ok();
};
Self::collect_created_context(&mut contexts, task_result);
}
}
};
Comment thread
lklimek marked this conversation as resolved.

while let Ok(task_result) = task_result_receiver.try_recv() {
Self::collect_created_context(&mut contexts, task_result);
}

let wallet_backends = contexts
.iter()
.filter_map(|context| context.wallet_backend().ok())
.collect::<Vec<_>>();
futures::future::join_all(
wallet_backends
.iter()
.map(|wallet_backend| wallet_backend.shutdown()),
)
.await;
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

if task_shutdown_completed {
let _ = tx.send(());
}
});

rx
}
}

impl App for AppState {
Expand All @@ -1837,37 +1891,41 @@ impl App for AppState {
// When the user closes the window we cancel the native close, show a banner,
// and start an async shutdown. Once all tasks have finished (or timed out)
// we issue Close ourselves.
if let Some(rx) = &mut self.shutdown_receiver {
if self.shutdown_started.is_some() {
// Shutdown already in progress — check if it's done.
let should_close = match rx.try_recv() {
Ok(()) => {
tracing::debug!("Async shutdown finished, closing viewport");
true
}
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
// Sender dropped without sending — shutdown task likely panicked.
tracing::warn!("Shutdown channel closed unexpectedly (possible panic)");
true
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
// Still waiting — check hard deadline to prevent infinite loop.
if let Some(started) = self.shutdown_started {
let grace = crate::utils::tasks::SHUTDOWN_TIMEOUT
+ std::time::Duration::from_secs(5);
if started.elapsed() > grace {
tracing::warn!(
"Shutdown hard deadline exceeded, force-closing viewport"
);
true
let should_close = match self.shutdown_receiver.as_mut() {
Some(rx) => match rx.try_recv() {
Ok(()) => {
tracing::debug!("Async shutdown finished, closing viewport");
true
}
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
// Sender dropped without sending — shutdown task likely panicked.
tracing::warn!("Shutdown channel closed unexpectedly (possible panic)");
true
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
// Still waiting — check hard deadline to prevent infinite loop.
if let Some(started) = self.shutdown_started {
let grace = crate::utils::tasks::SHUTDOWN_TIMEOUT
+ std::time::Duration::from_secs(5);
if started.elapsed() > grace {
tracing::warn!(
"Shutdown hard deadline exceeded, force-closing viewport"
);
Comment thread
claude[bot] marked this conversation as resolved.
true
} else {
false
}
} else {
false
}
} else {
false
}
}
},
None => true,
};
if should_close {
self.shutdown_receiver.take();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else {
ctx.request_repaint();
Expand All @@ -1887,7 +1945,7 @@ impl App for AppState {
MessageType::Warning,
);
tracing::debug!("Close requested, starting async shutdown");
self.shutdown_receiver = Some(self.subtasks.shutdown_async());
self.shutdown_receiver = Some(self.start_async_shutdown());
self.shutdown_started = Some(std::time::Instant::now());
ctx.request_repaint();
return;
Expand Down Expand Up @@ -2431,11 +2489,9 @@ impl App for AppState {
// observers (TouchBar, etc.) while views are still alive.
crate::platform::order_out_all_windows();

// If shutdown_receiver is Some, the async shutdown was already initiated
// in update(). Skip the blocking fallback to avoid double-shutdown.
// The blocking path only runs when the window was force-closed without
// going through update() (e.g., OS-level kill, alt-F4 on some platforms).
if self.shutdown_receiver.is_some() {
// The start marker persists after the completion receiver is consumed.
// Only force-closes that bypass update() need the blocking fallback.
if self.shutdown_started.is_some() {
tracing::debug!("on_exit: async shutdown was initiated, skipping blocking fallback");
return;
}
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
Expand Down
7 changes: 6 additions & 1 deletion src/backend_task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ impl AppContext {
// context, which fast-failed with `WalletBackendNotYetWired` and
// reported `spv_started=false`. Wiring first removes that race so
// `spv_started` reflects whether sync actually began.
let spv_started = if start_spv {
let spv_started = if start_spv && !self.subtasks.cancellation_token.is_cancelled() {
match new_ctx
.ensure_wallet_backend_and_start_spv(sender.clone())
.await
Expand All @@ -916,6 +916,11 @@ impl AppContext {
} else {
false
};
if self.subtasks.cancellation_token.is_cancelled()
&& let Ok(backend) = new_ctx.wallet_backend()
{
backend.shutdown().await;
}
Ok(BackendTaskSuccessResult::NetworkContextCreated {
network,
context: new_ctx,
Expand Down
Loading