557 lines
19 KiB
Rust
557 lines
19 KiB
Rust
// m5_5_acceptance.rs --- Acceptance suite for M5.5 (daemon + attach).
|
|
|
|
//! End-to-end acceptance tests for T M5.5 (daemon mode +
|
|
//! local-socket transport).
|
|
//!
|
|
//! Each test spawns the real `pmacs` binary as a subprocess, drives
|
|
//! it via the published protocol crate, and tears it down on
|
|
//! completion. Tempdir-scoped sockets keep tests isolated under
|
|
//! parallel `cargo test`.
|
|
//!
|
|
//! Tests in this file (mapped to T M5.5 acceptance criteria):
|
|
//!
|
|
//! 1. [`daemon_starts_socket_and_lockfile_appear_with_correct_modes`]
|
|
//! 2. [`attach_send_key_receive_cell_response`]
|
|
//! 3. [`clean_detach_then_reattach`]
|
|
//! 4. [`ungraceful_disconnect_then_reattach`]
|
|
//! 5. [`second_daemon_same_socket_fails_clearly`]
|
|
//! 6. [`sigterm_daemon_sends_goodbye_and_cleans_up`]
|
|
//! 7. [`sigkill_daemon_leaves_stale_files_next_start_recovers`]
|
|
//! 8. [`version_mismatch_clean_disconnect`]
|
|
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use nix::sys::signal::{Signal, kill};
|
|
use nix::unistd::Pid;
|
|
use tempfile::TempDir;
|
|
|
|
use pmacs::cell::CellSize;
|
|
use pmacs::protocol::{
|
|
AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key,
|
|
KeyEvent, Modifiers, PROTOCOL_VERSION,
|
|
};
|
|
use pmacs::transport::{read_message, write_message};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A foreground daemon spawned in the test, with cleanup on Drop.
|
|
struct TestDaemon {
|
|
/// Tempdir holding the socket and lockfile; auto-cleaned on Drop.
|
|
_tempdir: TempDir,
|
|
socket_path: PathBuf,
|
|
process: Child,
|
|
}
|
|
|
|
impl TestDaemon {
|
|
fn spawn() -> Self {
|
|
let tempdir = TempDir::new().expect("tempdir");
|
|
// tempfile::TempDir creates 0755-mode directories; the daemon
|
|
// requires a 0700-or-stricter parent for the socket. Tighten
|
|
// the tempdir before spawning.
|
|
fs::set_permissions(tempdir.path(), fs::Permissions::from_mode(0o700))
|
|
.expect("chmod tempdir 0700");
|
|
let socket_path = tempdir.path().join("pmacs.sock");
|
|
let process = spawn_daemon_process(&socket_path);
|
|
wait_for_socket_or_exit(&socket_path, &process, Duration::from_secs(10))
|
|
.expect("daemon socket appeared");
|
|
Self {
|
|
_tempdir: tempdir,
|
|
socket_path,
|
|
process,
|
|
}
|
|
}
|
|
|
|
fn pid(&self) -> u32 {
|
|
self.process.id()
|
|
}
|
|
|
|
fn connect(&self) -> UnixStream {
|
|
UnixStream::connect(&self.socket_path).expect("connect")
|
|
}
|
|
|
|
fn is_alive(&mut self) -> bool {
|
|
self.process.try_wait().ok().flatten().is_none()
|
|
}
|
|
|
|
fn lockfile_path(&self) -> PathBuf {
|
|
let mut s = self.socket_path.as_os_str().to_os_string();
|
|
s.push(".lock");
|
|
PathBuf::from(s)
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDaemon {
|
|
fn drop(&mut self) {
|
|
let _ = self.process.kill();
|
|
let _ = self.process.wait();
|
|
}
|
|
}
|
|
|
|
fn spawn_daemon_process(socket_path: &Path) -> Child {
|
|
// Isolate user config so a test daemon doesn't read $HOME/.pmacs/init.lua.
|
|
// Both HOME and XDG_CONFIG_HOME point at the parent of the socket
|
|
// (which is empty at this point), so config::load_user_config is a
|
|
// no-op.
|
|
let isolated_home = socket_path.parent().expect("socket has parent");
|
|
Command::new(env!("CARGO_BIN_EXE_pmacs"))
|
|
.args(["--daemon", "--socket"])
|
|
.arg(socket_path)
|
|
.env("HOME", isolated_home)
|
|
.env("XDG_CONFIG_HOME", isolated_home)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn pmacs --daemon")
|
|
}
|
|
|
|
fn wait_for_socket_or_exit(
|
|
socket: &Path,
|
|
_process: &Child,
|
|
deadline: Duration,
|
|
) -> Result<(), String> {
|
|
// We probe by attempting to *connect*, not by file-exists. A stale
|
|
// socket file from a previous (crashed) daemon can satisfy
|
|
// `exists` without anyone actually listening — only a successful
|
|
// connect proves the new daemon has run through `bind` and
|
|
// `listen`. ECONNREFUSED on a stale socket retries until the new
|
|
// daemon takes over.
|
|
let start = Instant::now();
|
|
while start.elapsed() < deadline {
|
|
if let Ok(mut stream) = UnixStream::connect(socket) {
|
|
// Read the Hello so the daemon's `send_message` succeeds
|
|
// and doesn't log a "send Hello failed" warning to its
|
|
// stderr (which our test runner inherits). After reading
|
|
// we drop without sending AttachRequest; the daemon
|
|
// observes the disconnect and falls back to accept.
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_millis(500)))
|
|
.ok();
|
|
let _ = read_message::<Hello>(&mut stream);
|
|
return Ok(());
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
Err(format!(
|
|
"daemon did not start listening on {} within {deadline:?}",
|
|
socket.display()
|
|
))
|
|
}
|
|
|
|
fn build_default_caps() -> FrontendCapabilities {
|
|
FrontendCapabilities {
|
|
synchronized_output: true,
|
|
unicode_smp: true,
|
|
true_color: true,
|
|
mouse: true,
|
|
bracketed_paste: true,
|
|
terminal_kind: Some("test".into()),
|
|
}
|
|
}
|
|
|
|
/// Read the daemon's `Hello`, send our `AttachRequest`, return the Hello.
|
|
fn do_handshake(stream: &mut UnixStream) -> Hello {
|
|
let hello: Hello = read_message(stream).expect("read Hello");
|
|
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
|
|
let req = AttachRequest {
|
|
protocol_version: PROTOCOL_VERSION,
|
|
frontend_capabilities: build_default_caps(),
|
|
initial_size: CellSize::new(24, 80),
|
|
};
|
|
write_message(stream, &req).expect("write AttachRequest");
|
|
hello
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn daemon_starts_socket_and_lockfile_appear_with_correct_modes() {
|
|
let daemon = TestDaemon::spawn();
|
|
|
|
// Socket: owner-only. The kernel applies umask 0077 to a base
|
|
// 0777 for socket files (the lockfile is created via O_CREAT
|
|
// with explicit mode and lands at 0600). The "x" bit on the
|
|
// socket has no semantic meaning, so we assert the security
|
|
// property — no group/other bits — rather than an exact 0o600.
|
|
let socket_meta = fs::metadata(&daemon.socket_path).expect("stat socket");
|
|
let socket_mode = socket_meta.permissions().mode() & 0o7777;
|
|
assert_eq!(
|
|
socket_mode & 0o077,
|
|
0,
|
|
"socket mode {socket_mode:#o} should be owner-only"
|
|
);
|
|
|
|
// Lockfile: mode 0600.
|
|
let lockfile_path = daemon.lockfile_path();
|
|
let lock_meta = fs::metadata(&lockfile_path).expect("stat lockfile");
|
|
let lock_mode = lock_meta.permissions().mode() & 0o7777;
|
|
assert_eq!(
|
|
lock_mode, 0o600,
|
|
"lockfile mode should be 0600, got {lock_mode:#o}"
|
|
);
|
|
|
|
// Parent dir: at most 0700 (no group/other bits).
|
|
let parent_meta = fs::metadata(daemon.socket_path.parent().unwrap()).expect("stat parent");
|
|
let parent_mode = parent_meta.permissions().mode() & 0o7777;
|
|
assert_eq!(
|
|
parent_mode & 0o077,
|
|
0,
|
|
"parent dir mode {parent_mode:#o} should be owner-only"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 2
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn attach_send_key_receive_cell_response() {
|
|
let daemon = TestDaemon::spawn();
|
|
let mut stream = daemon.connect();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
|
|
let hello = do_handshake(&mut stream);
|
|
|
|
// After AttachRequest the daemon emits the initial full-grid sync.
|
|
let initial: InstanceMessage = read_message(&mut stream).expect("initial frame");
|
|
match initial {
|
|
InstanceMessage::CellDelta {
|
|
full_grid: true, ..
|
|
} => {}
|
|
other => panic!("expected initial full-grid CellDelta, got {other:?}"),
|
|
}
|
|
|
|
// Send a key event.
|
|
let key_event = FrontendEvent::Key(KeyEvent {
|
|
frontend_id: hello.assigned_frontend_id,
|
|
key: Key::Char('a'),
|
|
mods: Modifiers::NONE,
|
|
timestamp_ns: 0,
|
|
});
|
|
write_message(&mut stream, &key_event).expect("send key");
|
|
|
|
// The daemon should produce at least one render message in response
|
|
// (cell delta or cursor update). Read up to ~2s for any message.
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
let mut got_response = false;
|
|
while Instant::now() < deadline {
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_millis(200)))
|
|
.unwrap();
|
|
if let Ok(msg) = read_message::<InstanceMessage>(&mut stream) {
|
|
if matches!(
|
|
msg,
|
|
InstanceMessage::CellDelta { .. } | InstanceMessage::Cursor(_)
|
|
) {
|
|
got_response = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert!(got_response, "expected render response after key");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 3
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn clean_detach_then_reattach() {
|
|
let mut daemon = TestDaemon::spawn();
|
|
let pid_before = daemon.pid();
|
|
|
|
// Attach, read initial frame, send Detach, drop.
|
|
{
|
|
let mut stream = daemon.connect();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let hello = do_handshake(&mut stream);
|
|
let _: InstanceMessage = read_message(&mut stream).expect("initial frame");
|
|
write_message(
|
|
&mut stream,
|
|
&FrontendEvent::Detach(hello.assigned_frontend_id),
|
|
)
|
|
.expect("send Detach");
|
|
drop(stream);
|
|
}
|
|
|
|
// Give the daemon time to clear its attached slot.
|
|
thread::sleep(Duration::from_millis(300));
|
|
|
|
// Reattach should succeed.
|
|
{
|
|
let mut stream = daemon.connect();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let _hello = do_handshake(&mut stream);
|
|
let _: InstanceMessage = read_message(&mut stream).expect("initial frame");
|
|
}
|
|
|
|
assert_eq!(daemon.pid(), pid_before);
|
|
assert!(daemon.is_alive(), "daemon should still be running");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 4
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ungraceful_disconnect_then_reattach() {
|
|
let mut daemon = TestDaemon::spawn();
|
|
let pid_before = daemon.pid();
|
|
|
|
// Attach, do handshake, drop without Detach (simulates SIGKILL'd
|
|
// frontend).
|
|
{
|
|
let mut stream = daemon.connect();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let _ = do_handshake(&mut stream);
|
|
// Note: we don't even read the initial frame — close immediately.
|
|
drop(stream);
|
|
}
|
|
|
|
// Daemon needs longer to detect ungraceful close because the read
|
|
// path has no deterministic wakeup; the per-attach loop polls the
|
|
// channel with a frame-target timeout (~16 ms by default), so 500
|
|
// ms is safely above any realistic detection latency.
|
|
thread::sleep(Duration::from_millis(500));
|
|
|
|
// Reattach should succeed.
|
|
{
|
|
let mut stream = daemon.connect();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let _hello = do_handshake(&mut stream);
|
|
let _: InstanceMessage = read_message(&mut stream).expect("initial frame");
|
|
}
|
|
|
|
assert_eq!(daemon.pid(), pid_before);
|
|
assert!(daemon.is_alive(), "daemon should still be running");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 5
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn second_daemon_same_socket_fails_clearly() {
|
|
let mut daemon_a = TestDaemon::spawn();
|
|
|
|
// Spawn second daemon at the same socket; capture its stderr.
|
|
let isolated_home = daemon_a.socket_path.parent().unwrap();
|
|
let output = Command::new(env!("CARGO_BIN_EXE_pmacs"))
|
|
.args(["--daemon", "--socket"])
|
|
.arg(&daemon_a.socket_path)
|
|
.env("HOME", isolated_home)
|
|
.env("XDG_CONFIG_HOME", isolated_home)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.expect("spawn second daemon");
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"second daemon should exit non-zero"
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("daemon already running"),
|
|
"expected error message in stderr, got: {stderr}"
|
|
);
|
|
|
|
// First daemon unaffected: still alive, still accepts handshake.
|
|
assert!(daemon_a.is_alive(), "daemon A should still be running");
|
|
let mut stream = daemon_a.connect();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let _hello = do_handshake(&mut stream);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 6
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn sigterm_daemon_sends_goodbye_and_cleans_up() {
|
|
let mut daemon = TestDaemon::spawn();
|
|
let socket_path = daemon.socket_path.clone();
|
|
let lockfile_path = daemon.lockfile_path();
|
|
let pid = daemon.pid();
|
|
|
|
// Attach.
|
|
let mut stream = daemon.connect();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let _hello = do_handshake(&mut stream);
|
|
let _: InstanceMessage = read_message(&mut stream).expect("initial frame");
|
|
|
|
// Send SIGTERM to daemon.
|
|
kill(Pid::from_raw(i32::try_from(pid).unwrap()), Signal::SIGTERM).expect("kill SIGTERM");
|
|
|
|
// Read until we see Goodbye(ShuttingDown) or hit EOF.
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
|
.unwrap();
|
|
let mut got_goodbye = false;
|
|
loop {
|
|
match read_message::<InstanceMessage>(&mut stream) {
|
|
Ok(InstanceMessage::Goodbye(GoodbyeReason::ShuttingDown)) => {
|
|
got_goodbye = true;
|
|
break;
|
|
}
|
|
// Intermediate render frames are fine.
|
|
Ok(_) => {}
|
|
// EOF / error — daemon closed.
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
assert!(got_goodbye, "expected Goodbye(ShuttingDown) before EOF");
|
|
|
|
// Daemon should exit cleanly.
|
|
let status = daemon.process.wait().expect("wait");
|
|
assert!(
|
|
status.success(),
|
|
"daemon should exit 0 after SIGTERM, got {status}"
|
|
);
|
|
|
|
// Both files cleaned up.
|
|
assert!(
|
|
!socket_path.exists(),
|
|
"socket {socket_path:?} should be unlinked"
|
|
);
|
|
assert!(
|
|
!lockfile_path.exists(),
|
|
"lockfile {lockfile_path:?} should be unlinked"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 7
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn sigkill_daemon_leaves_stale_files_next_start_recovers() {
|
|
let tempdir = TempDir::new().expect("tempdir");
|
|
fs::set_permissions(tempdir.path(), fs::Permissions::from_mode(0o700))
|
|
.expect("chmod tempdir 0700");
|
|
let socket_path = tempdir.path().join("pmacs.sock");
|
|
|
|
// First daemon.
|
|
let mut daemon1 = spawn_daemon_process(&socket_path);
|
|
wait_for_socket_or_exit(&socket_path, &daemon1, Duration::from_secs(10))
|
|
.expect("daemon 1 socket appeared");
|
|
|
|
let mut lockfile_path = socket_path.as_os_str().to_os_string();
|
|
lockfile_path.push(".lock");
|
|
let lockfile_path = PathBuf::from(lockfile_path);
|
|
|
|
assert!(socket_path.exists(), "socket exists pre-SIGKILL");
|
|
assert!(lockfile_path.exists(), "lockfile exists pre-SIGKILL");
|
|
|
|
// SIGKILL the daemon.
|
|
kill(
|
|
Pid::from_raw(i32::try_from(daemon1.id()).unwrap()),
|
|
Signal::SIGKILL,
|
|
)
|
|
.expect("SIGKILL");
|
|
let _ = daemon1.wait();
|
|
|
|
// Stale files persist (kernel only released the flock + socket fd).
|
|
assert!(
|
|
socket_path.exists(),
|
|
"socket should persist on disk after SIGKILL"
|
|
);
|
|
assert!(
|
|
lockfile_path.exists(),
|
|
"lockfile should persist on disk after SIGKILL"
|
|
);
|
|
|
|
// Second daemon must successfully recover.
|
|
let mut daemon2 = spawn_daemon_process(&socket_path);
|
|
wait_for_socket_or_exit(&socket_path, &daemon2, Duration::from_secs(10))
|
|
.expect("daemon 2 socket appeared");
|
|
|
|
// Verify socket is fresh and owner-only (kernel applies umask
|
|
// 0077 to the implicit 0777 base for socket files → 0700; the
|
|
// "x" bit is meaningless on a socket; the security property is
|
|
// "no group/other access").
|
|
let mode = fs::metadata(&socket_path).unwrap().permissions().mode() & 0o7777;
|
|
assert_eq!(
|
|
mode & 0o077,
|
|
0,
|
|
"socket mode {mode:#o} should be owner-only"
|
|
);
|
|
|
|
// Lockfile pid was rewritten.
|
|
let pid_str = fs::read_to_string(&lockfile_path).unwrap();
|
|
let parsed: u32 = pid_str.trim().parse().expect("pid in lockfile");
|
|
assert_eq!(parsed, daemon2.id(), "lockfile pid should match daemon 2");
|
|
|
|
// Cleanup.
|
|
let _ = daemon2.kill();
|
|
let _ = daemon2.wait();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 8
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn version_mismatch_clean_disconnect() {
|
|
let mut daemon = TestDaemon::spawn();
|
|
|
|
let mut stream = daemon.connect();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
|
|
// Read Hello.
|
|
let hello: Hello = read_message(&mut stream).expect("Hello");
|
|
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
|
|
|
|
// Send AttachRequest with wrong protocol version.
|
|
let req = AttachRequest {
|
|
protocol_version: 999,
|
|
frontend_capabilities: build_default_caps(),
|
|
initial_size: CellSize::new(24, 80),
|
|
};
|
|
write_message(&mut stream, &req).expect("write");
|
|
|
|
// Expect Goodbye(VersionMismatch).
|
|
match read_message::<InstanceMessage>(&mut stream) {
|
|
Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => {
|
|
assert_eq!(server, PROTOCOL_VERSION);
|
|
assert_eq!(client, 999);
|
|
}
|
|
other => panic!("expected VersionMismatch Goodbye, got {other:?}"),
|
|
}
|
|
|
|
drop(stream);
|
|
|
|
// Daemon still alive and serving.
|
|
assert!(daemon.is_alive());
|
|
let mut stream2 = daemon.connect();
|
|
stream2
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let _hello = do_handshake(&mut stream2);
|
|
}
|