8000 feat(minimald): implement session destroy + RPC by twitchyliquid64 · Pull Request #462 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
19 changes: 19 additions & 0 deletions crates/minimald-rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,25 @@ impl OneshotSshRpc for RenameSession {
type Response = Errorable<RenameSessionResponse>;
}

/// An RPC to destroy an existing session.
pub struct DestroySession;

/// The request for a [`DestroySession`] RPC.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DestroySessionRequest {
pub id: SessionId,
}

/// The response for a [`DestroySession`] RPC.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DestroySessionResponse;

impl OneshotSshRpc for DestroySession {
const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "DestroySession");
type Request<'a> = DestroySessionRequest;
type Response = Errorable<DestroySessionResponse>;
}

// ---------------------------------------------------------------------------
// Session-creation flow (multi-round contribution composition).
//
Expand Down
74 changes: 69 additions & 5 deletions crates/minimald/src/rpc.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use minimald_rpc::{
CreateSession, CreateSessionResponse, Errorable, GetSessionRecord, GetSessionRecordRequest,
GetSessionRecordResponse, GetVersion, GetVersionResponse, ListSessions, ListSessionsEntry,
ListSessionsResponse, OneshotSshRpc, RPC_SUBSYSTEM_PREFIX, RenameSession,
RenameSessionResponse,
CreateSession, CreateSessionResponse, DestroySession, DestroySessionResponse, Errorable,
GetSessionRecord, GetSessionRecordRequest, GetSessionRecordResponse, GetVersion,
GetVersionResponse, ListSessions, ListSessionsEntry, ListSessionsResponse, OneshotSshRpc,
RPC_SUBSYSTEM_PREFIX, RenameSession, RenameSessionResponse,
};
use russh::{
Channel as RuChannel, ChannelId,
Expand Down Expand Up @@ -173,6 +173,23 @@ async fn serve_rename_session(s: ServerStateHandle, c: RuChannel<Msg>) {
}
}

async fn serve_destroy_session(s: ServerStateHandle, c: RuChannel<Msg>) {
let res = DestroySession
.handle_channel(c, async |req| {
let res = s.sessions_manager().await.destroy_session(req.id).await;
match res {
Ok(()) => Ok(Errorable::Ok(DestroySessionResponse)),
Err(e) => Ok(Errorable::Err {
error: e.to_string(),
}),
}
})
.await;
if let Err(e) = res {
tracing::warn!("RPC handler for {} failed: {}", DestroySession::NAME, e);
}
}

pub(crate) const STREAM_WORKSPACE_FILES: &str =
constcat::concat!(RPC_SUBSYSTEM_PREFIX, "WorkspaceFilesTarZst");

Expand Down Expand Up @@ -246,6 +263,7 @@ pub async fn handle_ssh_rpc(
| GetSessionRecord::NAME
| CreateSession::NAME
| RenameSession::NAME
| DestroySession::NAME
| STREAM_WORKSPACE_FILES => {
let mut conn_lock = c.lock().await;
let c_hnd = match conn_lock.take(id) {
Expand Down Expand Up @@ -276,6 +294,7 @@ pub async fn handle_ssh_rpc(
GetSessionRecord::NAME => spawn(serve_get_session_record(s, channel)),
CreateSession::NAME => spawn(serve_create_session(s, channel)),
RenameSession::NAME => spawn(serve_rename_session(s, channel)),
DestroySession::NAME => spawn(serve_destroy_session(s, channel)),
STREAM_WORKSPACE_FILES => spawn(serve_stream_workspace_files(s, config, channel)),
_ => unreachable!(),
};
Expand All @@ -285,7 +304,9 @@ pub async fn handle_ssh_rpc(

#[cfg(test)]
mod tests {
use minimald_rpc::{CreateSession, CreateSessionRequest, RenameSessionRequest};
use minimald_rpc::{
CreateSession, CreateSessionRequest, DestroySessionRequest, RenameSessionRequest,
};
use paths::HostAbsPath;
use sessions::SessionId;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
Expand Down Expand Up @@ -612,4 +633,47 @@ mod tests {
"expected an unknown-id error",
);
}

#[tokio::test]
async fn destroy_session_removes_it_from_get_and_list() {
let server = TestServer::new().await;
let mut client = server.connect().await;
let session_id = fresh_session(&mut client).await;

let resp = client
.call::<DestroySession>(&DestroySessionRequest { id: session_id })
.await;
assert_eq!(resp, Errorable::Ok(DestroySessionResponse));

// The record is gone: it no longer resolves by id...
let get_session = client
.call::<GetSessionRecord>(&GetSessionRecordRequest::Id(session_id))
.await;
assert!(get_session.record.is_none());

// ...and it's dropped from the listing.
let list_sessions = client.call::<ListSessions>(&()).await;
assert!(
list_sessions.sessions.is_empty(),
"destroyed session should not be listed, got {:?}",
list_sessions.sessions,
);
}

#[tokio::test]
async fn destroy_session_errors_for_unknown_id() {
let server = TestServer::new().await;
let mut client = server.connect().await;

let resp = client
.call::<DestroySession>(&DestroySessionRequest {
id: SessionId::nil(),
})
.await;

assert!(
matches!(resp, Errorable::Err { error } if error.contains("no session with ID")),
"expected an unknown-id error",
);
}
}
51 changes: 46 additions & 5 deletions crates/minimald/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use paths::DaemonAbsPath;
use russh::{Channel, server::Msg};
use sessions::{Record, store::SessionObject};
use std::fmt::{self};
use std::ops::ControlFlow;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;

/// An error that occurred when attaching to a running session/its-shell.
#[derive(Debug)]
Expand Down Expand Up @@ -49,6 +51,7 @@ enum SessionMessage {
),
GetHostAttrs(oneshot::Sender<Option<HostAttrs>>),
RefreshRecord(Record),
Destroy(oneshot::Sender<()>),
#[cfg(test)]
GetRecord(oneshot::Sender<Record>),
}
Expand All @@ -63,7 +66,12 @@ pub struct Session<S: SessionObject> {
minimal_cache_dir: DaemonAbsPath,
session: S,

host: Option<session_host::HostHandle>,
/// The running host, if minted, paired with the `JoinHandle` of its runtime
/// loop so teardown can be awaited on destroy.
host: Option<(
session_host::HostHandle,
JoinHandle<Result<i32, std::io::Error>>,
)>,
}

impl<S: SessionObject> Session<S> {
Expand Down Expand Up @@ -94,12 +102,17 @@ impl<S: SessionObject> Session<S> {
/// session.
async fn mainloop(mut self) {
while let Some(msg) = self.receiver.recv().await {
self.handle_message(msg).await;
if self.handle_message(msg).await.is_break() {
break;
}
}
}

/// Handles a specific message recieved by the session.
async fn handle_message(&mut self, msg: SessionMessage) {
///
/// Returns [`ControlFlow::Break`] when the session has been destroyed and
/// the actor loop should exit.
async fn handle_message(&mut self, msg: SessionMessage) -> ControlFlow<()> {
match msg {
SessionMessage::GetPaths(r) => {
let _ = r.send(self.paths());
Expand All @@ -116,17 +129,35 @@ impl<S: SessionObject> Session<S> {
SessionMessage::GetHostAttrs(r) => {
let _ = r.send(match self.host.as_ref() {
None => None,
Some(h) => h.get_attrs().await.ok(),
Some((h, _)) => h.get_attrs().await.ok(),
});
}
SessionMessage::RefreshRecord(r) => {
self.session.refresh_from_record(r);
}
SessionMessage::Destroy(r) => {
self.destroy().await;
let _ = r.send(());
return ControlFlow::Break(());
Comment on lines +138 to +141
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C4 'SessionMessage::Destroy|async fn destroy|task\.await|destroy_session' crates/minimald/src/session.rs crates/minimald/src/sessions.rs

Repository: gominimal/minimal

Length of output: 6873


🏁 Script executed:

cat crates/minimald/src/session.rs | head -170 | tail -40

Repository: gominimal/minimal

Length of output: 1479


🏁 Script executed:

find . -name "rust-coding-standards.md" -o -name "*coding*standards*" | head -20

Repository: gominimal/minimal

Length of output: 93


🏁 Script executed:

rg -n "SessionMessage::Destroy" crates/minimald/src/session.rs -A 10 -B 5

Repository: gominimal/minimal

Length of output: 1350


🏁 Script executed:

cat docs/rust-coding-standards.md

Repository: gominimal/minimal

Length of output: 4815


🏁 Script executed:

rg -n "destroy_session" crates/minimald/src/sessions.rs -A 15 | head -50

Repository: gominimal/minimal

Length of output: 1977


🏁 Script executed:

grep -n "store.delete\|destroy.*await" crates/minimald/src/sessions.rs | head -20

Repository: gominimal/minimal

Length of output: 264


Propagate host teardown failures before acknowledging destroy — violates error handling standards.

The Rust coding standards explicitly forbid silent error discards without justification ("Never swallow errors. No let _ = result; without a comment justifying it"). Lines 138–141 and 153–161 discard JoinHandle results, causing SessionMessage::Destroy to acknowledge success even if the host task panicked or mainloop() failed. This breaks the semantic contract: the manager (sessions.rs:223–225) deletes the session's disk tree immediately after destroy returns, so teardown failures must propagate before that deletion occurs.

Suggested shape
-    Destroy(oneshot::Sender<()>),
+    Destroy(oneshot::Sender<Result<(), std::io::Error>>),
             SessionMessage::Destroy(r) => {
-                self.destroy().await;
-                let _ = r.send(());
+                let result = self.destroy().await;
+                let _ = r.send(result);
                 return ControlFlow::Break(());
             }
-    async fn destroy(&mut self) {
+    async fn destroy(&mut self) -> Result<(), std::io::Error> {
         if let Some((host, task)) = self.host.take() {
             // Signal the process to die, then await the runtime loop so the
             // sandbox files backing its rootfs are released before the caller
             // removes the session's directory tree.
             let _ = host.kill().await;
-            let _ = task.await;
+            let _exit = task.await.map_err(std::io::Error::other)??;
         }
+        Ok(())
     }
-    pub(crate) async fn destroy(&self) {
+    pub(crate) async fn destroy(&self) -> Result<(), std::io::Error> {
         let (send, recv) = oneshot::channel();
         // Ignore send errors - the recv will also fail.
         let _ = self.0.send(SessionMessage::Destroy(send)).await;
         // If the actor died before acking, the session is gone all the same.
-        let _ = recv.await;
+        recv.await.unwrap_or(Ok(()))
     }

The manager caller should then propagate hnd.destroy().await? before store.delete(&k)?.

Also applies to lines 153–161 and 320–328.

🤖 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 `@crates/minimald/src/session.rs` around lines 138 - 141, The
SessionMessage::Destroy handler silently discards errors from both the
self.destroy().await operation and the r.send(()) call without justification,
violating Rust error handling standards. Since the manager deletes the session's
disk tree immediately after destroy returns, teardown failures must be
propagated before that deletion occurs. Replace the current pattern in the
Destroy handler to check and propagate errors from both self.destroy().await and
r.send(), ensuring that success is only acknowledged if both operations actually
succeed rather than silently discarding the results. Apply the same error
propagation fix to the similar patterns mentioned at lines 153-161 and 320-328.

}
#[cfg(test)]
SessionMessage::GetRecord(r) => {
let _ = r.send(self.session.record().clone());
}
}
ControlFlow::Continue(())
}

/// Tears down the running host, if any, waiting for the process to be reaped
/// and the sandbox guard to be dropped before returning.
async fn destroy(&mut self) {
if let Some((host, task)) = self.host.take() {
// Signal the process to die, then await the runtime loop so the
// sandbox files backing its rootfs are released before the caller
// removes the session's directory tree.
let _ = host.kill().await;
let _ = task.await;
}
}

async fn attach(
Expand All @@ -146,7 +177,7 @@ impl<S: SessionObject> Session<S> {
self.mint_session_host(session_hnd, conn_username, channel, sz)
.await
}
Some(h) => {
Some((h, _)) => {
match h.attach(channel, sz).await {
Ok(()) => Ok(()),
Err((channel, sz)) => {
Expand Down Expand Up @@ -286,6 +317,16 @@ impl SessionHandle {
.expect("corresponding session is dead");
}

/// Tears down the session: kills its host (if any), waits for teardown, and
/// stops the actor. The handle is dead once this returns.
pub(crate) async fn destroy(&self) {
let (send, recv) = oneshot::channel();
// Ignore send errors - the recv will also fail.
let _ = self.0.send(SessionMessage::Destroy(send)).await;
// If the actor died before acking, the session is gone all the same.
let _ = recv.await;
}

pub async fn attach(
&self,
conn_username: String,
Expand Down
Loading
0