Compare commits

..
3 changed files with 82 additions and 19 deletions
+45 -19
View File
@@ -156,6 +156,10 @@ enum Command {
Close, Close,
} }
enum ChildCommand {
Signal(libc::c_int),
}
/// Why the handshake did not produce a session id. /// Why the handshake did not produce a session id.
enum ReadyError { enum ReadyError {
/// The adapter answered with an error. /// The adapter answered with an error.
@@ -180,6 +184,7 @@ pub struct AcpSession {
session_id: String, session_id: String,
pid: u32, pid: u32,
commands: mpsc::UnboundedSender<Command>, commands: mpsc::UnboundedSender<Command>,
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>, updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
exit: watch::Receiver<Option<ExitStatus>>, exit: watch::Receiver<Option<ExitStatus>>,
} }
@@ -221,9 +226,6 @@ impl AcpSession {
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait /// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped. /// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) { pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
if self.has_exited() {
return;
}
self.cancel(); self.cancel();
if tokio::time::timeout(cancel_grace, self.wait()) if tokio::time::timeout(cancel_grace, self.wait())
.await .await
@@ -245,16 +247,14 @@ impl AcpSession {
} }
fn signal(&self, sig: libc::c_int) { fn signal(&self, sig: libc::c_int) {
signal_pid(self.pid, sig); let _ = self.child_commands.send(ChildCommand::Signal(sig));
} }
} }
impl Drop for AcpSession { impl Drop for AcpSession {
fn drop(&mut self) { fn drop(&mut self) {
self.signal(libc::SIGTERM);
let _ = self.commands.send(Command::Close); let _ = self.commands.send(Command::Close);
if !self.has_exited() {
self.signal(libc::SIGTERM);
}
} }
} }
@@ -301,11 +301,8 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone())); tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
let (exit_tx, exit_rx) = watch::channel(None); let (exit_tx, exit_rx) = watch::channel(None);
tokio::spawn(async move { let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
let status = child.wait().await.ok(); tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(status);
});
let (updates_tx, updates_rx) = mpsc::unbounded_channel(); let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (commands_tx, commands_rx) = mpsc::unbounded_channel(); let (commands_tx, commands_rx) = mpsc::unbounded_channel();
@@ -327,6 +324,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
session_id, session_id,
pid, pid,
commands: commands_tx, commands: commands_tx,
child_commands: child_commands_tx,
updates: Mutex::new(Some(updates_rx)), updates: Mutex::new(Some(updates_rx)),
exit: exit_rx, exit: exit_rx,
}); });
@@ -356,12 +354,12 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
}; };
// No half-started adapter survives a failed spawn. // No half-started adapter survives a failed spawn.
let _ = commands_tx.send(Command::Close); let _ = commands_tx.send(Command::Close);
signal_pid(pid, libc::SIGTERM); let _ = child_commands_tx.send(ChildCommand::Signal(libc::SIGTERM));
if tokio::time::timeout(TERM_GRACE, wait_exit(&exit_rx)) if tokio::time::timeout(TERM_GRACE, wait_exit(&exit_rx))
.await .await
.is_err() .is_err()
{ {
signal_pid(pid, libc::SIGKILL); let _ = child_commands_tx.send(ChildCommand::Signal(libc::SIGKILL));
wait_exit(&exit_rx).await; wait_exit(&exit_rx).await;
} }
Err(failure) Err(failure)
@@ -373,11 +371,39 @@ async fn wait_exit(exit: &watch::Receiver<Option<ExitStatus>>) -> Option<ExitSta
*status *status
} }
fn signal_pid(pid: u32, sig: libc::c_int) { async fn supervise_child(
// ESRCH just means the child is already gone. mut child: tokio::process::Child,
// SAFETY: plain syscall on a pid we spawned and have not reaped. pid: u32,
unsafe { mut commands: mpsc::UnboundedReceiver<ChildCommand>,
libc::kill(pid as libc::pid_t, sig); exit_tx: watch::Sender<Option<ExitStatus>>,
) {
loop {
tokio::select! {
status = child.wait() => {
let status = status.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(status);
return;
}
command = commands.recv() => match command {
Some(ChildCommand::Signal(sig)) => {
// This task alone owns `child`. It cannot reap the child
// between receiving this command and sending the signal,
// so its PID cannot be reused in that window.
unsafe { libc::kill(pid as libc::pid_t, sig) };
}
None => {
// The session vanished without a command receiver. This
// task still owns the unreaped child, so this cannot hit
// a reused PID.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
let status = child.wait().await.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(status);
return;
}
},
}
} }
} }
@@ -20,6 +20,7 @@ Environment knobs, all optional:
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new` FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
so the SIGTERM path is exercised) so the SIGTERM path is exercised)
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false` FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk` FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
@@ -135,3 +136,7 @@ for raw in sys.stdin:
elif rid is not None: elif rid is not None:
send({"jsonrpc": "2.0", "id": rid, send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32601, "message": f"method not found: {method}"}}) "error": {"code": -32601, "message": f"method not found: {method}"}})
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
while True:
signal.pause()
+32
View File
@@ -69,6 +69,24 @@ impl Fake {
} }
panic!("log never contained {needle:?}:\n{}", self.log()); panic!("log never contained {needle:?}:\n{}", self.log());
} }
fn pid(&self) -> u32 {
self.log()
.lines()
.find_map(|line| line.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|pid| pid.parse().ok())
.expect("pid in log")
}
async fn wait_for_exit(&self) {
let pid = self.pid();
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} still alive");
}
} }
fn pid_alive(pid: u32) -> bool { fn pid_alive(pid: u32) -> bool {
@@ -274,6 +292,20 @@ async fn child_exit_before_handshake_reports_exited_early() {
} }
} }
#[tokio::test]
async fn drop_terminates_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_IGNORE_EOF".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
drop(session);
fake.wait_for_log("signal SIGTERM").await;
fake.wait_for_exit().await;
}
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String { fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
match &n.update { match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content { SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {