mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 11:39:41 +03:00
718 lines
24 KiB
Rust
718 lines
24 KiB
Rust
#![cfg(unix)]
|
|
|
|
use std::io::{Read, Write};
|
|
use std::os::unix::net::{UnixListener, UnixStream};
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::Barrier;
|
|
use std::thread;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use base64::Engine;
|
|
use opencode_pty::daemon::Registration;
|
|
use opencode_pty::protocol::{
|
|
AttachmentRole, Envelope, Request, Response, read_frame, write_frame,
|
|
};
|
|
use opencode_pty::service::{ProcessInfo, ProcessMode};
|
|
|
|
struct Daemon {
|
|
child: Child,
|
|
root: PathBuf,
|
|
directory: PathBuf,
|
|
registration: Registration,
|
|
}
|
|
|
|
impl Daemon {
|
|
fn start() -> Self {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"opencode-pty-ownership-{:032x}",
|
|
rand::random::<u128>()
|
|
));
|
|
let directory = root.join("test");
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_opencode-pty"))
|
|
.args(["daemon", "--name", "test", "--runtime-dir"])
|
|
.arg(&root)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
let registration = loop {
|
|
if let Ok(data) = std::fs::read(directory.join("service.json")) {
|
|
break serde_json::from_slice::<Registration>(&data).unwrap();
|
|
}
|
|
assert!(
|
|
child.try_wait().unwrap().is_none(),
|
|
"daemon exited at startup"
|
|
);
|
|
if Instant::now() >= deadline {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
panic!("daemon did not register");
|
|
}
|
|
thread::sleep(Duration::from_millis(10));
|
|
};
|
|
assert_eq!(registration.protocol, 8);
|
|
Self {
|
|
child,
|
|
root,
|
|
directory,
|
|
registration,
|
|
}
|
|
}
|
|
|
|
fn connect(&self) -> UnixStream {
|
|
let stream = UnixStream::connect(&self.registration.socket).unwrap();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
|
.unwrap();
|
|
stream
|
|
.set_write_timeout(Some(Duration::from_secs(3)))
|
|
.unwrap();
|
|
stream
|
|
}
|
|
|
|
fn send(&self, stream: &mut UnixStream, request: Request) -> Response {
|
|
write_frame(
|
|
&mut *stream,
|
|
&Envelope {
|
|
token: self.registration.token.clone(),
|
|
request,
|
|
},
|
|
)
|
|
.unwrap();
|
|
read_frame(stream).unwrap()
|
|
}
|
|
|
|
fn request(&self, request: Request) -> Response {
|
|
self.send(&mut self.connect(), request)
|
|
}
|
|
|
|
fn own(&self, ticket: Option<String>) -> (UnixStream, Response) {
|
|
let mut stream = self.connect();
|
|
let response = self.send(
|
|
&mut stream,
|
|
Request::Own {
|
|
instance_id: self.registration.instance_id.clone(),
|
|
ticket,
|
|
},
|
|
);
|
|
(stream, response)
|
|
}
|
|
|
|
fn terminal(&self, command: &str) -> ProcessInfo {
|
|
match self.request(Request::CreatePty {
|
|
program: "/bin/sh".into(),
|
|
args: vec!["-c".into(), command.into()],
|
|
cwd: self.directory.clone(),
|
|
env: Default::default(),
|
|
group_id: "ownership".into(),
|
|
title: "ownership".into(),
|
|
cols: 80,
|
|
rows: 24,
|
|
}) {
|
|
Response::Created { process } => process,
|
|
response => panic!("create: {response:?}"),
|
|
}
|
|
}
|
|
|
|
fn wait(&mut self) {
|
|
self.exit();
|
|
assert!(!self.registration.socket.exists());
|
|
}
|
|
|
|
fn exit(&mut self) {
|
|
let deadline = Instant::now() + Duration::from_secs(7);
|
|
loop {
|
|
if let Some(status) = self.child.try_wait().unwrap() {
|
|
assert!(status.success(), "daemon exit: {status}");
|
|
assert!(
|
|
!self.directory.exists(),
|
|
"runtime directory was not removed"
|
|
);
|
|
return;
|
|
}
|
|
assert!(Instant::now() < deadline, "daemon did not stop");
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for Daemon {
|
|
fn drop(&mut self) {
|
|
if self.child.try_wait().ok().flatten().is_none() {
|
|
if let Ok(mut stream) = UnixStream::connect(&self.registration.socket) {
|
|
let _ = stream.set_write_timeout(Some(Duration::from_secs(1)));
|
|
let _ = write_frame(
|
|
&mut stream,
|
|
&Envelope {
|
|
token: self.registration.token.clone(),
|
|
request: Request::Shutdown,
|
|
},
|
|
);
|
|
}
|
|
let deadline = Instant::now() + Duration::from_secs(6);
|
|
while self.child.try_wait().ok().flatten().is_none() && Instant::now() < deadline {
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
let _ = std::fs::remove_dir_all(&self.root);
|
|
}
|
|
}
|
|
|
|
fn assert_terminal_stopped(terminal: &ProcessInfo) {
|
|
// SAFETY: signal zero only checks whether this child PID still exists.
|
|
assert_eq!(unsafe { libc::kill(terminal.pid.unwrap() as i32, 0) }, -1);
|
|
assert_eq!(
|
|
std::io::Error::last_os_error().raw_os_error(),
|
|
Some(libc::ESRCH)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn owner_loss_stops_terminals_despite_blocked_clients() {
|
|
let mut daemon = Daemon::start();
|
|
let mut unauthenticated = daemon.connect();
|
|
write_frame(
|
|
&mut unauthenticated,
|
|
&Envelope {
|
|
token: "wrong-token".into(),
|
|
request: Request::Own {
|
|
instance_id: daemon.registration.instance_id.clone(),
|
|
ticket: None,
|
|
},
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
matches!(read_frame(&mut unauthenticated).unwrap(), Response::Error { message } if message == "authentication failed")
|
|
);
|
|
assert!(matches!(
|
|
daemon.request(Request::Own {
|
|
instance_id: "wrong-instance".into(),
|
|
ticket: None,
|
|
}),
|
|
Response::Error { .. }
|
|
));
|
|
let (owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
assert!(matches!(daemon.own(None).1, Response::Error { .. }));
|
|
|
|
let terminal = daemon.terminal("while :; do printf '\\rowner-output'; done");
|
|
let mut subscription = daemon.connect();
|
|
assert!(matches!(
|
|
daemon.send(
|
|
&mut subscription,
|
|
Request::Subscribe {
|
|
id: terminal.id,
|
|
offset: 0,
|
|
attachment_id: "blocked-controller".into(),
|
|
role: AttachmentRole::Controller,
|
|
takeover: false,
|
|
}
|
|
),
|
|
Response::Attached { .. }
|
|
));
|
|
let mut partial = daemon.connect();
|
|
partial.write_all(&[0, 0]).unwrap();
|
|
thread::sleep(Duration::from_millis(200));
|
|
drop(owner);
|
|
daemon.wait();
|
|
assert_terminal_stopped(&terminal);
|
|
}
|
|
|
|
#[test]
|
|
fn subscription_delivers_final_exit_frame_before_closing() {
|
|
use opencode_pty::protocol::{SubscriptionEvent, read_subscription_event};
|
|
|
|
let mut daemon = Daemon::start();
|
|
let (owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
let terminal = daemon.terminal("read line; printf final-output");
|
|
let mut subscription = daemon.connect();
|
|
assert!(matches!(
|
|
daemon.send(
|
|
&mut subscription,
|
|
Request::Subscribe {
|
|
id: terminal.id,
|
|
offset: 0,
|
|
attachment_id: "final-observer".into(),
|
|
role: AttachmentRole::Observer,
|
|
takeover: false,
|
|
}
|
|
),
|
|
Response::Attached { .. }
|
|
));
|
|
assert!(matches!(
|
|
daemon.request(Request::Write {
|
|
id: terminal.id,
|
|
attachment_id: None,
|
|
data_base64: base64::engine::general_purpose::STANDARD.encode(b"finish\n"),
|
|
}),
|
|
Response::Ok
|
|
));
|
|
let mut output = Vec::new();
|
|
loop {
|
|
match read_subscription_event(&mut subscription).unwrap() {
|
|
SubscriptionEvent::Output { bytes, .. } => output.extend(bytes),
|
|
SubscriptionEvent::Response(response) => match *response {
|
|
Response::Exited {
|
|
exit_code,
|
|
final_offset,
|
|
..
|
|
} => {
|
|
assert_eq!(exit_code, Some(0));
|
|
assert_eq!(final_offset, output.len() as u64);
|
|
break;
|
|
}
|
|
Response::Error { message } => panic!("subscription error: {message}"),
|
|
_ => {}
|
|
},
|
|
}
|
|
}
|
|
assert!(String::from_utf8_lossy(&output).contains("final-output"));
|
|
drop(subscription);
|
|
drop(owner);
|
|
daemon.wait();
|
|
}
|
|
|
|
#[test]
|
|
fn shutdown_ends_an_idle_subscription() {
|
|
let mut daemon = Daemon::start();
|
|
let (mut owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
let terminal = daemon.terminal("exec cat");
|
|
let mut subscription = daemon.connect();
|
|
assert!(matches!(
|
|
daemon.send(
|
|
&mut subscription,
|
|
Request::Subscribe {
|
|
id: terminal.id,
|
|
offset: 0,
|
|
attachment_id: "idle-observer".into(),
|
|
role: AttachmentRole::Observer,
|
|
takeover: false,
|
|
}
|
|
),
|
|
Response::Attached { .. }
|
|
));
|
|
// The subscription is blocked with no output; shutdown must still end it.
|
|
assert!(matches!(
|
|
daemon.send(&mut owner, Request::Shutdown),
|
|
Response::Ok
|
|
));
|
|
// The 3s read timeout fails this read if the handler never closes the stream.
|
|
subscription.read_to_end(&mut Vec::new()).unwrap();
|
|
daemon.wait();
|
|
assert_terminal_stopped(&terminal);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_is_served() {
|
|
use opencode_pty::protocol::{SubscriptionEvent, read_subscription_event};
|
|
|
|
let mut daemon = Daemon::start();
|
|
assert!(daemon.registration.features.is_empty());
|
|
assert!(matches!(
|
|
daemon.request(Request::Ping),
|
|
Response::Pong { protocol: 8, features, .. } if features.is_empty()
|
|
));
|
|
let (owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
let terminal = daemon.terminal("exec cat");
|
|
let mut subscription = daemon.connect();
|
|
assert!(matches!(
|
|
daemon.send(
|
|
&mut subscription,
|
|
Request::Subscribe {
|
|
id: terminal.id,
|
|
offset: 0,
|
|
attachment_id: "rename-observer".into(),
|
|
role: AttachmentRole::Observer,
|
|
takeover: false,
|
|
}
|
|
),
|
|
Response::Attached { .. }
|
|
));
|
|
assert!(matches!(
|
|
daemon.request(Request::Rename {
|
|
id: terminal.id,
|
|
title: Some("renamed".into()),
|
|
}),
|
|
Response::Ok
|
|
));
|
|
loop {
|
|
if let SubscriptionEvent::Response(response) =
|
|
read_subscription_event(&mut subscription).unwrap()
|
|
&& let Response::TitleChanged { title } = *response
|
|
{
|
|
assert_eq!(title, "renamed");
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
matches!(daemon.request(Request::List { mode: ProcessMode::Pty }), Response::Processes { processes }
|
|
if processes.len() == 1 && processes[0].pty.as_ref().unwrap().title == "renamed"
|
|
&& processes[0].pty.as_ref().unwrap().user_title.as_deref() == Some("renamed"))
|
|
);
|
|
assert!(matches!(
|
|
daemon.request(Request::Rename {
|
|
id: terminal.id.wrapping_add(1),
|
|
title: None,
|
|
}),
|
|
Response::Error { .. }
|
|
));
|
|
drop(subscription);
|
|
drop(owner);
|
|
daemon.wait();
|
|
}
|
|
|
|
#[test]
|
|
fn handoff_preserves_daemon_and_terminal_and_shutdown_overrides_it() {
|
|
let mut daemon = Daemon::start();
|
|
let (mut owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
let terminal = daemon.terminal("exec cat");
|
|
assert!(matches!(
|
|
daemon.request(Request::PrepareHandoff),
|
|
Response::Error { .. }
|
|
));
|
|
let before = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64;
|
|
let Response::Handoff { ticket, expires_at } = daemon.send(&mut owner, Request::PrepareHandoff)
|
|
else {
|
|
panic!("handoff response");
|
|
};
|
|
assert!((before + 120_000..=before + 121_000).contains(&expires_at));
|
|
assert!(
|
|
matches!(daemon.send(&mut owner, Request::PrepareHandoff), Response::Handoff {
|
|
ticket: repeated, expires_at: deadline,
|
|
} if repeated == ticket && deadline == expires_at)
|
|
);
|
|
assert!(matches!(
|
|
daemon.own(Some("wrong-ticket".into())).1,
|
|
Response::Error { .. }
|
|
));
|
|
// The old owner stays connected until the successor has acquired the daemon.
|
|
let (mut successor, response) = daemon.own(Some(ticket.clone()));
|
|
assert!(matches!(response, Response::Owned));
|
|
assert!(matches!(daemon.own(Some(ticket)).1, Response::Error { .. }));
|
|
drop(owner);
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
assert!(
|
|
matches!(daemon.request(Request::Ping), Response::Pong { instance_id, pid, .. }
|
|
if instance_id == daemon.registration.instance_id && pid == daemon.child.id())
|
|
);
|
|
assert!(
|
|
matches!(daemon.request(Request::List { mode: ProcessMode::Pty }), Response::Processes { processes: terminals }
|
|
if terminals.len() == 1 && terminals[0].id == terminal.id && terminals[0].pid == terminal.pid)
|
|
);
|
|
assert!(matches!(
|
|
daemon.request(Request::Write {
|
|
id: terminal.id,
|
|
attachment_id: None,
|
|
data_base64: base64::engine::general_purpose::STANDARD.encode(b"after-handoff\n"),
|
|
}),
|
|
Response::Ok
|
|
));
|
|
loop {
|
|
if matches!(daemon.request(Request::Snapshot { id: terminal.id }), Response::Snapshot { text, .. }
|
|
if text.contains("after-handoff"))
|
|
{
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"terminal stopped responding after handoff"
|
|
);
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
assert!(matches!(
|
|
daemon.send(&mut successor, Request::PrepareHandoff),
|
|
Response::Handoff { .. }
|
|
));
|
|
assert!(matches!(daemon.request(Request::Shutdown), Response::Ok));
|
|
daemon.wait();
|
|
assert_terminal_stopped(&terminal);
|
|
}
|
|
|
|
#[test]
|
|
fn superseded_owner_cannot_prepare_handoff_or_shutdown() {
|
|
for request in [Request::PrepareHandoff, Request::Shutdown] {
|
|
let mut daemon = Daemon::start();
|
|
let (mut owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
let Response::Handoff { ticket, .. } = daemon.send(&mut owner, Request::PrepareHandoff)
|
|
else {
|
|
panic!("handoff response");
|
|
};
|
|
let (mut successor, response) = daemon.own(Some(ticket));
|
|
assert!(matches!(response, Response::Owned));
|
|
assert!(matches!(
|
|
daemon.send(&mut owner, request),
|
|
Response::Error { message } if message == "owner connection has been superseded"
|
|
));
|
|
// EOF confirms the stale handler has run its disconnect cleanup.
|
|
assert_eq!(owner.read(&mut [0]).unwrap(), 0);
|
|
assert!(matches!(
|
|
daemon.request(Request::Ping),
|
|
Response::Pong { .. }
|
|
));
|
|
assert!(matches!(
|
|
daemon.send(&mut successor, Request::PrepareHandoff),
|
|
Response::Handoff { .. }
|
|
));
|
|
assert!(matches!(
|
|
daemon.send(&mut successor, Request::Shutdown),
|
|
Response::Ok
|
|
));
|
|
daemon.wait();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn handoff_ticket_has_only_one_winner() {
|
|
let mut daemon = Daemon::start();
|
|
let (mut owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
let Response::Handoff { ticket, .. } = daemon.send(&mut owner, Request::PrepareHandoff) else {
|
|
panic!("handoff response");
|
|
};
|
|
let barrier = Barrier::new(2);
|
|
let results = thread::scope(|scope| {
|
|
let claim = || {
|
|
barrier.wait();
|
|
daemon.own(Some(ticket.clone()))
|
|
};
|
|
let first = scope.spawn(claim);
|
|
let second = scope.spawn(claim);
|
|
[first.join().unwrap(), second.join().unwrap()]
|
|
});
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.filter(|(_, response)| matches!(response, Response::Owned))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.filter(|(_, response)| matches!(response, Response::Error { .. }))
|
|
.count(),
|
|
1
|
|
);
|
|
// Losing the new owner must stop the daemon even if the old socket is still open.
|
|
drop(results);
|
|
daemon.wait();
|
|
assert_eq!(owner.read(&mut [0]).unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn unclaimed_daemon_times_out() {
|
|
let mut daemon = Daemon::start();
|
|
let _partial = daemon.connect();
|
|
daemon.wait();
|
|
}
|
|
|
|
#[test]
|
|
fn shutdown_keeps_a_replaced_socket() {
|
|
let mut daemon = Daemon::start();
|
|
let (mut owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
// A successor bound the same path; the exiting daemon must not unlink it.
|
|
std::fs::remove_file(&daemon.registration.socket).unwrap();
|
|
let replacement = UnixListener::bind(&daemon.registration.socket).unwrap();
|
|
assert!(matches!(
|
|
daemon.send(&mut owner, Request::Shutdown),
|
|
Response::Ok
|
|
));
|
|
daemon.exit();
|
|
assert!(daemon.registration.socket.exists());
|
|
drop(replacement);
|
|
std::fs::remove_file(&daemon.registration.socket).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn stop_signals_stop_terminals_and_clean_up() {
|
|
for signal in [libc::SIGTERM, libc::SIGINT] {
|
|
let mut daemon = Daemon::start();
|
|
let (_owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
let terminal = daemon.terminal("exec sleep 1000");
|
|
|
|
// The daemon blocks these signals on its own threads; its children must not
|
|
// inherit that mask, so a terminal's process still dies from the signal.
|
|
let probe = daemon.terminal("exec sleep 1000");
|
|
// SAFETY: the probe PID belongs to this test's live terminal.
|
|
assert_eq!(unsafe { libc::kill(probe.pid.unwrap() as i32, signal) }, 0);
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
loop {
|
|
let Response::Processes {
|
|
processes: terminals,
|
|
} = daemon.request(Request::List {
|
|
mode: ProcessMode::Pty,
|
|
})
|
|
else {
|
|
panic!("list failed");
|
|
};
|
|
let exited = terminals.iter().any(|value| {
|
|
value.id == probe.id
|
|
&& !matches!(
|
|
value.lifecycle,
|
|
opencode_pty::service::TerminalLifecycle::Running
|
|
)
|
|
});
|
|
if exited {
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "probe ignored signal {signal}");
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
|
|
// SAFETY: the PID belongs to the daemon this test spawned.
|
|
assert_eq!(unsafe { libc::kill(daemon.child.id() as i32, signal) }, 0);
|
|
daemon.wait();
|
|
assert_terminal_stopped(&terminal);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pipe_processes_survive_handoff_and_stay_out_of_pty_lists() {
|
|
let mut daemon = Daemon::start();
|
|
let (owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
let terminal = daemon.terminal("exec cat");
|
|
let output_file = daemon.root.join("pipe.out");
|
|
let Response::Created { process } = daemon.request(Request::CreatePipe {
|
|
program: "/bin/sh".into(),
|
|
args: vec![
|
|
"-c".into(),
|
|
"echo before; sleep 1; echo after; exit 4".into(),
|
|
],
|
|
cwd: daemon.directory.clone(),
|
|
env: Default::default(),
|
|
group_id: "ownership".into(),
|
|
output_path: output_file.clone(),
|
|
metadata: Default::default(),
|
|
}) else {
|
|
panic!("create pipe");
|
|
};
|
|
assert_eq!(process.mode, ProcessMode::Pipe);
|
|
let ids = |mode| match daemon.request(Request::List { mode }) {
|
|
Response::Processes { processes } => processes
|
|
.into_iter()
|
|
.map(|process| process.id)
|
|
.collect::<Vec<_>>(),
|
|
response => panic!("list: {response:?}"),
|
|
};
|
|
assert_eq!(ids(ProcessMode::Pty), vec![terminal.id]);
|
|
assert_eq!(ids(ProcessMode::Pipe), vec![process.id]);
|
|
assert!(matches!(
|
|
daemon.request(Request::Snapshot { id: process.id }),
|
|
Response::Error { message } if message.contains("pipe process")
|
|
));
|
|
|
|
// Restart handoff: the old owner goes away while the process runs.
|
|
let mut old = owner;
|
|
let Response::Handoff { ticket, .. } = daemon.send(&mut old, Request::PrepareHandoff) else {
|
|
panic!("handoff");
|
|
};
|
|
let (successor, response) = daemon.own(Some(ticket));
|
|
assert!(matches!(response, Response::Owned));
|
|
drop(old);
|
|
|
|
let mut wait = daemon.connect();
|
|
wait.set_read_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
let Response::Exited {
|
|
exit_code,
|
|
output_lines,
|
|
final_offset,
|
|
..
|
|
} = daemon.send(&mut wait, Request::Wait { id: process.id })
|
|
else {
|
|
panic!("wait");
|
|
};
|
|
assert_eq!(exit_code, Some(4));
|
|
assert_eq!((output_lines, final_offset), (Some(2), 13));
|
|
assert_eq!(
|
|
std::fs::read_to_string(&output_file).unwrap(),
|
|
"before\nafter\n"
|
|
);
|
|
assert!(matches!(
|
|
daemon.request(Request::Terminate { id: process.id }),
|
|
Response::Ok
|
|
));
|
|
drop(wait);
|
|
drop(successor);
|
|
daemon.wait();
|
|
assert_terminal_stopped(&terminal);
|
|
}
|
|
|
|
#[test]
|
|
fn pipe_processes_do_not_inherit_the_daemons_blocked_signals() {
|
|
use opencode_pty::service::TerminalLifecycle;
|
|
|
|
let mut daemon = Daemon::start();
|
|
let (_owner, response) = daemon.own(None);
|
|
assert!(matches!(response, Response::Owned));
|
|
for (signal, name) in [(libc::SIGTERM, "SIGTERM"), (libc::SIGINT, "SIGINT")] {
|
|
let Response::Created { process } = daemon.request(Request::CreatePipe {
|
|
program: "/bin/sh".into(),
|
|
args: vec!["-c".into(), "exec sleep 1000".into()],
|
|
cwd: daemon.directory.clone(),
|
|
env: Default::default(),
|
|
group_id: "ownership".into(),
|
|
output_path: daemon.root.join(format!("{name}.out")),
|
|
metadata: Default::default(),
|
|
}) else {
|
|
panic!("create pipe");
|
|
};
|
|
// SAFETY: the PID belongs to this test's pipe process.
|
|
assert_eq!(
|
|
unsafe { libc::kill(process.pid.unwrap() as i32, signal) },
|
|
0
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
let Response::Processes {
|
|
processes: terminals,
|
|
} = daemon.request(Request::List {
|
|
mode: ProcessMode::Pipe,
|
|
})
|
|
else {
|
|
panic!("list failed");
|
|
};
|
|
let lifecycle = terminals
|
|
.into_iter()
|
|
.find(|value| value.id == process.id)
|
|
.unwrap()
|
|
.lifecycle;
|
|
if lifecycle != TerminalLifecycle::Running {
|
|
assert_eq!(
|
|
lifecycle,
|
|
TerminalLifecycle::Exited {
|
|
exit_code: None,
|
|
signal: Some(name.into()),
|
|
}
|
|
);
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "pipe process ignored {name}");
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
assert!(matches!(
|
|
daemon.request(Request::Terminate { id: process.id }),
|
|
Response::Ok
|
|
));
|
|
}
|
|
assert!(matches!(daemon.request(Request::Shutdown), Response::Ok));
|
|
daemon.wait();
|
|
}
|