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
1,684 changes: 10 additions & 1,674 deletions src/app.rs

Large diffs are not rendered by default.

1,656 changes: 1,656 additions & 0 deletions src/app_tests.rs

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub enum Wake {
/// has already fed the parser), so the loop should tick to ship it.
Output,
/// The command source ended: the client's socket hit EOF. Distinct from a
/// `Shutdown` command: the jobs keep running, only this connection is done.
/// `Shutdown` command: the tasks keep running, only this connection is done.
Hangup,
}

Expand All @@ -52,9 +52,9 @@ pub type Waker = Arc<Mutex<Option<Sender<Wake>>>>;

/// Why the loop returned.
pub enum LoopExit {
/// A `Shutdown` command: jobs killed, the caller stops the core.
/// A `Shutdown` command: tasks killed, the caller stops the core.
Shutdown,
/// The client is gone (socket EOF, or a write failed). Keep the jobs; the
/// The client is gone (socket EOF, or a write failed). Keep the tasks; the
/// daemon loops back to accept the next client.
ClientGone,
}
Expand Down Expand Up @@ -91,7 +91,7 @@ fn ready_to_tick(dirty: bool, since_last_tick: Duration) -> bool {
/// `stop` is an external stop request (the daemon's signal flag): checked once
/// per wake/timeout, so a raised flag ends the loop within one `FALLBACK` even
/// when nothing else is happening. It shuts down exactly like a `Shutdown`
/// command: jobs killed, `LoopExit::Shutdown` returned.
/// command: tasks killed, `LoopExit::Shutdown` returned.
pub fn run_loop(
sup: &mut Supervisor,
wake_rx: &Receiver<Wake>,
Expand Down Expand Up @@ -259,7 +259,7 @@ mod tests {
core.join().unwrap();
}

/// A raised stop flag ends the loop as `Shutdown` (killing the jobs) without
/// A raised stop flag ends the loop as `Shutdown` (killing the tasks) without
/// any command arriving: the path a signalled daemon takes.
#[test]
fn stop_flag_ends_loop_with_shutdown() {
Expand Down
32 changes: 16 additions & 16 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
//! per-user Unix socket, and serves a client at a time: a hello handshake
//! (protocol version + the client's launch context), then framed `Command`s in,
//! framed `Event`s back. It runs the shared event-driven `core::run_loop`. The
//! supervisor **outlives each client connection**: `q` disconnects, the jobs
//! supervisor **outlives each client connection**: `q` disconnects, the tasks
//! keep running, and the next `fleetcom` reattaches.
//!
//! It also autostarts a detached daemon when no socket is available.
//!
//! The fleet's lifetime is bounded by the daemon's. The daemon holds every
//! task's PTY master, so daemon death of any kind closes them, and the kernel
//! hangs up each task's controlling terminal: SIGHUP to its foreground process
//! group, which (job control being off under `$SHELL -c`) is the whole job.
//! A normal shutdown sends SIGTERM to each job group, then SIGKILL after a
//! group, which (job control being off under `$SHELL -c`) is the whole task.
//! A normal shutdown sends SIGTERM to each task group, then SIGKILL after a
//! grace period, and removes the socket and lock. A crash or SIGKILL only
//! closes the PTYs; HUP-immune jobs can survive without a supervisor.
//! closes the PTYs; HUP-immune tasks can survive without a supervisor.

use std::{
fs,
Expand Down Expand Up @@ -330,7 +330,7 @@ fn spawn_daemon() -> io::Result<()> {
Ok(())
}

/// `fleetcom --kill`: stop the daemon and every job it owns. Signal path, not
/// `fleetcom --kill`: stop the daemon and every task it owns. Signal path, not
/// socket: the daemon serves one client at a time, so a `Shutdown` *frame*
/// would sit in the accept backlog until an attached client detached.
/// `--kill` must work while someone else is attached. The pid comes from the
Expand Down Expand Up @@ -375,9 +375,9 @@ pub fn run_kill() -> io::Result<()> {
Err(e) => return Err(io::Error::other(e)),
}

// The daemon notices the flag within ~200 ms, then tears down its jobs.
// The daemon notices the flag within ~200 ms, then tears down its tasks.
// Its exit releases the flock, so acquiring it is the completion signal:
// jobs dead, socket removed. 10 s covers the teardown with slack.
// tasks dead, socket removed. 10 s covers the teardown with slack.
for _ in 0..200 {
match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
Ok(_held) => return Ok(()),
Expand All @@ -392,7 +392,7 @@ pub fn run_kill() -> io::Result<()> {
}

/// Send `Shutdown` when the lock file has no usable pid. Complete the handshake
/// first, then wait for the daemon to close the socket after stopping its jobs.
/// first, then wait for the daemon to close the socket after stopping its tasks.
fn kill_via_socket() -> io::Result<()> {
let path = socket_path();
match UnixStream::connect(&path) {
Expand All @@ -416,7 +416,7 @@ fn kill_via_socket() -> io::Result<()> {

/// The daemon entry point (`fleetcom --daemon`). Binds the socket and serves clients
/// until an explicit shutdown. The supervisor is created once and persists across
/// reconnects: jobs outlive any single client.
/// reconnects: tasks outlive any single client.
pub fn run_daemon() -> io::Result<()> {
let dir = runtime_dir();
ensure_runtime_dir(&dir)?; // private 0700 directory
Expand Down Expand Up @@ -455,11 +455,11 @@ pub fn run_daemon() -> io::Result<()> {
// Use one scrollback depth for every task owned by this daemon.
let mut sup = Supervisor::new(24, 80, supervisor::resolve_scrollback());

// A signalled daemon shuts down *cleanly*: TERM each job's group with a
// A signalled daemon shuts down *cleanly*: TERM each task's group with a
// KILL after the grace, remove the socket. Dying without that cleanup
// would still kill the fleet (closing the PTY masters hangs up every
// job's terminal; see the module docs), but rudely: no TERM, no grace,
// and HUP-immune jobs would leak unowned. The flag is checked in the idle
// task's terminal; see the module docs), but rudely: no TERM, no grace,
// and HUP-immune tasks would leak unowned. The flag is checked in the idle
// branch below and inside `run_loop` while a client is being served; both
// observe it within ~200 ms.
let term = Arc::new(AtomicBool::new(false));
Expand All @@ -468,15 +468,15 @@ pub fn run_daemon() -> io::Result<()> {
// as shutdown like the rest.
crate::install_signal_handlers(Arc::clone(&term))?;

// Non-blocking accept lets the daemon reap exited jobs while idle:
// Non-blocking accept lets the daemon reap exited tasks while idle:
// between clients it would otherwise block in accept() and never call
// poll_exit, so a job that finished after `q` would linger as a zombie until
// poll_exit, so a task that finished after `q` would linger as a zombie until
// a reconnect.
listener.set_nonblocking(true)?;
const IDLE_REAP: Duration = Duration::from_millis(100);
loop {
if term.load(Ordering::Relaxed) {
// Kill the jobs now, not via drop at the end of `main`: explicit at
// Kill the tasks now, not via drop at the end of `main`: explicit at
// the one place the loop decides to stop.
sup.apply(Command::Shutdown);
break;
Expand Down Expand Up @@ -532,7 +532,7 @@ fn transient_accept_error(e: &io::Error) -> bool {

#[derive(PartialEq)]
enum ServeOutcome {
/// Client left; daemon keeps running and the jobs survive.
/// Client left; daemon keeps running and the tasks survive.
Disconnected,
/// Client asked to kill everything and stop the daemon.
Shutdown,
Expand Down
20 changes: 2 additions & 18 deletions src/harness/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ mod tests {
use super::super::testutil::ID;
use super::super::{CAPTURE_ENV, NOTIFY_CHAIN_ENV};
use super::*;
use crate::testutil::temp;
use crate::testutil::{install_fake_notifier, temp, write_executable};

fn mode(p: &Path) -> u32 {
fs::metadata(p).unwrap().permissions().mode() & 0o777
Expand Down Expand Up @@ -365,21 +365,6 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}

/// Install a fake notifier at `path` that records its argv, one token
/// per line, into `record`.
fn install_fake_notifier(path: &Path, record: &Path) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
path,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n",
record.display()
),
)
.unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
}

/// A configured chain runs after capture and receives its original argv
/// followed by the payload. Spaces within an argument remain intact.
#[test]
Expand Down Expand Up @@ -419,8 +404,7 @@ mod tests {
let assets = CaptureAssets::install(&root, std::process::id()).unwrap();
let cap = assets.paths_for(4, 0).capture_file;
let notifier = root.join("failing");
fs::write(&notifier, "#!/bin/sh\nexit 1\n").unwrap();
fs::set_permissions(&notifier, fs::Permissions::from_mode(0o700)).unwrap();
write_executable(&notifier, "exit 1");

let payload = r#"{"type":"agent-turn-complete","turn-id":"t4"}"#;
let out = Command::new(&assets.codex_notify)
Expand Down
22 changes: 7 additions & 15 deletions src/harness/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ fn slug(cwd: &Path) -> Option<String> {
mod tests {
use std::{fs, path::PathBuf};

use super::super::testutil::{ID, OTHER, paths};
use super::super::testutil::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths};
use super::*;
use crate::testutil::{corpus_emulator, temp};
use crate::testutil::temp;

/// Claude-specific opaque shapes: flags, `--continue`/`-c`, subcommands,
/// the short/`=` resume spellings, and `--session-id`. The syntax shared
Expand All @@ -112,14 +112,7 @@ mod tests {
format!("claude --session-id {ID}"),
])
.collect();
for cmd in opaque {
assert_eq!(Claude.detect(&cmd), None, "{cmd:?} must be opaque");
assert_eq!(
Claude.resume_command(&cmd, ID),
cmd,
"an opaque command must never be rewritten"
);
}
assert_all_opaque(&Claude, ID, &opaque);
}

#[test]
Expand Down Expand Up @@ -232,11 +225,10 @@ mod tests {
/// The scraper recovers the exit-hint ID from the corpus terminal bytes.
#[test]
fn corpus_scrape_recovers_the_exit_hint_id() {
let mut emu = corpus_emulator();
emu.process(include_bytes!("../../tests/corpus/claude_resume.bin"));
assert_eq!(
Claude.scrape_exit(&emu.text_with_history()).as_deref(),
Some("c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d")
assert_corpus_scrape(
&Claude,
include_bytes!("../../tests/corpus/claude_resume.bin"),
"c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d",
);
}
}
22 changes: 7 additions & 15 deletions src/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,9 @@ pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) {
mod tests {
use std::path::PathBuf;

use super::super::testutil::{OTHER, paths};
use super::super::testutil::{OTHER, assert_all_opaque, assert_corpus_scrape, paths};
use super::*;
use crate::testutil::{corpus_emulator, temp, v7_at, write_rollout};
use crate::testutil::{temp, v7_at, write_rollout};

/// Codex's own launch and resume commands carry v7 IDs; the shared v4
/// fixture stays valid for detection, which is version-agnostic.
Expand Down Expand Up @@ -404,14 +404,7 @@ mod tests {
format!("codex --resume {ID}"),
])
.collect();
for cmd in opaque {
assert_eq!(Codex.detect(&cmd), None, "{cmd:?} must be opaque");
assert_eq!(
Codex.resume_command(&cmd, ID),
cmd,
"an opaque command must never be rewritten"
);
}
assert_all_opaque(&Codex, ID, &opaque);
}

/// Scratch home without a `config.toml`.
Expand Down Expand Up @@ -808,11 +801,10 @@ mod tests {
/// terminal emulation removes the styling.
#[test]
fn corpus_scrape_recovers_the_exit_hint_id() {
let mut emu = corpus_emulator();
emu.process(include_bytes!("../../tests/corpus/codex_resume.bin"));
assert_eq!(
Codex.scrape_exit(&emu.text_with_history()).as_deref(),
Some("019f5453-de22-7240-b2e5-0d32692aa6d9")
assert_corpus_scrape(
&Codex,
include_bytes!("../../tests/corpus/codex_resume.bin"),
"019f5453-de22-7240-b2e5-0d32692aa6d9",
);
}
}
22 changes: 7 additions & 15 deletions src/harness/grok.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ mod tests {
use std::fs;

use super::super::is_uuid;
use super::super::testutil::{ID, OTHER, paths};
use super::super::testutil::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths};
use super::*;
use crate::testutil::{corpus_emulator, temp};
use crate::testutil::temp;

/// Grok-specific opaque shapes: flags, the `-r`/`-s`/`=` spellings the
/// tool prints but detection refuses, and subcommands. The syntax shared
Expand All @@ -111,14 +111,7 @@ mod tests {
format!("grok --resume {ID} --debug"),
])
.collect();
for cmd in opaque {
assert_eq!(Grok.detect(&cmd), None, "{cmd:?} must be opaque");
assert_eq!(
Grok.resume_command(&cmd, ID),
cmd,
"an opaque command must never be rewritten"
);
}
assert_all_opaque(&Grok, ID, &opaque);
}

/// A bare launch gains only the pinned ID because Grok exposes no live
Expand Down Expand Up @@ -226,11 +219,10 @@ mod tests {
/// The scraper recovers the exit-hint ID from the corpus terminal bytes.
#[test]
fn corpus_scrape_recovers_the_exit_hint_id() {
let mut emu = corpus_emulator();
emu.process(include_bytes!("../../tests/corpus/grok_resume.bin"));
assert_eq!(
Grok.scrape_exit(&emu.text_with_history()).as_deref(),
Some("17ac97af-8cfc-46a7-9599-8cea45a687a6")
assert_corpus_scrape(
&Grok,
include_bytes!("../../tests/corpus/grok_resume.bin"),
"17ac97af-8cfc-46a7-9599-8cea45a687a6",
);
}
}
Loading