notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit d5ae0ae2c0b51c9c1063a38ca5f1e9fbaa049223
parent 308193e2794fb5b05317a0949637bd946c1c938f
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 25 Jan 2026 23:07:13 -0800

dave: fix Claude subprocess cleanup on session delete

Store the tokio JoinHandle in ChatSession and abort it when the session
is dropped. This ensures the Claude subprocess is properly terminated
when a user deletes a session, preventing orphaned processes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 9++++++---
Mcrates/notedeck_dave/src/backend/openai.rs | 9++++++---
Mcrates/notedeck_dave/src/backend/traits.rs | 8++++++--
Mcrates/notedeck_dave/src/lib.rs | 3++-
Mcrates/notedeck_dave/src/session.rs | 12++++++++++++
5 files changed, 32 insertions(+), 9 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -84,7 +84,10 @@ impl AiBackend for ClaudeBackend { _user_id: String, _session_id: String, // TODO: Currently unused - --continue resumes last conversation globally ctx: egui::Context, - ) -> mpsc::Receiver<DaveApiResponse> { + ) -> ( + mpsc::Receiver<DaveApiResponse>, + Option<tokio::task::JoinHandle<()>>, + ) { let (tx, rx) = mpsc::channel(); let _api_key = self.api_key.clone(); @@ -102,7 +105,7 @@ impl AiBackend for ClaudeBackend { .count() == 1; - tokio::spawn(async move { + let handle = tokio::spawn(async move { // For first message, send full prompt; for continuation, just the latest message let prompt = if is_first_message { Self::messages_to_prompt(&messages) @@ -270,6 +273,6 @@ impl AiBackend for ClaudeBackend { tracing::debug!("Claude stream closed"); }); - rx + (rx, Some(handle)) } } diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs @@ -33,7 +33,10 @@ impl AiBackend for OpenAiBackend { user_id: String, _session_id: String, ctx: egui::Context, - ) -> mpsc::Receiver<DaveApiResponse> { + ) -> ( + mpsc::Receiver<DaveApiResponse>, + Option<tokio::task::JoinHandle<()>>, + ) { let (tx, rx) = mpsc::channel(); let api_messages: Vec<ChatCompletionRequestMessage> = { @@ -47,7 +50,7 @@ impl AiBackend for OpenAiBackend { let client = self.client.clone(); let tool_list: Vec<_> = tools.values().map(|t| t.to_api()).collect(); - tokio::spawn(async move { + let handle = tokio::spawn(async move { let mut token_stream = match client .chat() .create_stream(CreateChatCompletionRequest { @@ -156,6 +159,6 @@ impl AiBackend for OpenAiBackend { tracing::debug!("stream closed"); }); - rx + (rx, Some(handle)) } } diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs @@ -15,7 +15,8 @@ pub enum BackendType { pub trait AiBackend: Send + Sync { /// Stream a request to the AI backend /// - /// Returns a receiver that will receive tokens and tool calls as they arrive + /// Returns a receiver that will receive tokens and tool calls as they arrive, + /// plus an optional JoinHandle to the spawned task for cleanup on session deletion. fn stream_request( &self, messages: Vec<crate::Message>, @@ -24,5 +25,8 @@ pub trait AiBackend: Send + Sync { user_id: String, session_id: String, ctx: egui::Context, - ) -> mpsc::Receiver<DaveApiResponse>; + ) -> ( + mpsc::Receiver<DaveApiResponse>, + Option<tokio::task::JoinHandle<()>>, + ); } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -371,10 +371,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let ctx = ctx.clone(); // Use backend to stream request - let rx = self + let (rx, task_handle) = self .backend .stream_request(messages, tools, model_name, user_id, session_id, ctx); session.incoming_tokens = Some(rx); + session.task_handle = task_handle; } } diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -17,6 +17,17 @@ pub struct ChatSession { pub incoming_tokens: Option<Receiver<DaveApiResponse>>, /// Pending permission requests waiting for user response pub pending_permissions: HashMap<Uuid, oneshot::Sender<PermissionResponse>>, + /// Handle to the background task processing this session's AI requests. + /// Aborted on drop to clean up the subprocess. + pub task_handle: Option<tokio::task::JoinHandle<()>>, +} + +impl Drop for ChatSession { + fn drop(&mut self) { + if let Some(handle) = self.task_handle.take() { + handle.abort(); + } + } } impl ChatSession { @@ -28,6 +39,7 @@ impl ChatSession { input: String::new(), incoming_tokens: None, pending_permissions: HashMap::new(), + task_handle: None, } }