use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::process;
pub const GATEWAY: &str = "gateway";
pub const LOG_DIR_ENV: &str = "TURNOUT_LOG_DIR";
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Entry {
pub work: Work,
pub pid: u32,
pub birth: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<i32>,
pub started: u64,
#[serde(default)]
pub detached: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ready: Option<Ready>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ended: Option<Ended>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signals: Vec<crate::signal::Signal>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stopping: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Work {
Command { app: String, command: String },
Gateway {
ports: BTreeMap<u16, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
front_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
control: Option<Control>,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Control {
pub port: u16,
pub token: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Ready {
pub at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Ended {
pub at: u64,
pub code: Option<i32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub never_ran: bool,
}
impl Ended {
pub fn exit(&self) -> Exit {
match (self.never_ran, self.code) {
(true, _) => Exit::NeverRan,
(false, Some(code)) => Exit::Code(code),
(false, None) => Exit::Killed,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Exit {
Code(i32),
Killed,
NeverRan,
}
impl Exit {
pub fn code(self) -> Option<i32> {
match self {
Exit::Code(code) => Some(code),
Exit::Killed | Exit::NeverRan => None,
}
}
pub fn describe(self) -> String {
match self {
Exit::Code(code) => format!("exit {code}"),
Exit::Killed => "killed".to_string(),
Exit::NeverRan => "did not start".to_string(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Status {
Running,
Exited(Exit),
Gone,
}
impl Entry {
pub fn own(work: Work, detached: bool, log: Option<PathBuf>) -> Self {
Self {
work,
pid: std::process::id(),
birth: process::own_birth(),
group: process::own_group(),
started: now(),
detached,
log,
ready: None,
ended: None,
signals: Vec::new(),
stopping: false,
}
}
pub fn key(&self) -> String {
match &self.work {
Work::Command { app, command } => command_key(app, command),
Work::Gateway { .. } => GATEWAY.to_string(),
}
}
pub fn status(&self) -> Status {
if let Some(ended) = &self.ended {
return Status::Exited(ended.exit());
}
if process::is_alive(self.pid, self.birth) {
Status::Running
} else {
Status::Gone
}
}
pub fn is_running(&self) -> bool {
self.status() == Status::Running
}
pub fn app(&self) -> Option<&str> {
match &self.work {
Work::Command { app, .. } => Some(app),
Work::Gateway { .. } => None,
}
}
pub fn title(&self) -> String {
match &self.work {
Work::Command { app, command } => format!("{app} {command}"),
Work::Gateway { .. } => GATEWAY.to_string(),
}
}
}
pub fn command_key(app: &str, command: &str) -> String {
format!("{}.{}", escape(app), escape(command))
}
fn escape(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for byte in name.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
if out.is_empty() {
return "%".to_string();
}
let lower = out.to_ascii_lowercase();
let reserved = matches!(lower.as_str(), "con" | "prn" | "aux" | "nul")
|| (lower.len() == 4 && (lower.starts_with("com") || lower.starts_with("lpt")) && lower.as_bytes()[3].is_ascii_digit());
if reserved {
out = format!("%{:02X}{}", out.as_bytes()[0], &out[1..]);
}
out
}
pub fn jobs_dir() -> Result<PathBuf> {
Ok(crate::paths::data_dir()?.join("jobs"))
}
pub fn logs_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os(LOG_DIR_ENV).filter(|dir| !dir.is_empty()) {
return Ok(PathBuf::from(dir));
}
Ok(crate::paths::data_dir()?.join("logs"))
}
pub fn log_path(key: &str) -> Result<PathBuf> {
Ok(logs_dir()?.join(format!("{key}.log")))
}
pub fn failed_log(log: &Path) -> PathBuf {
let name = log.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
log.parent().unwrap_or(Path::new("")).join("failed").join(name)
}
fn record_path(dir: &Path, key: &str) -> PathBuf {
dir.join(format!("{key}.json"))
}
pub fn load(key: &str) -> Result<Option<Entry>> {
load_in(&jobs_dir()?, key)
}
pub(crate) fn load_in(dir: &Path, key: &str) -> Result<Option<Entry>> {
let path = record_path(dir, key);
match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str(&text) {
Ok(entry) => Ok(Some(entry)),
Err(err) => {
crate::progress::warn(&format!("ignoring the unreadable job record {}: {err}", path.display()));
Ok(None)
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("cannot read {}", path.display())),
}
}
pub fn list() -> Result<Vec<Entry>> {
let dir = jobs_dir()?;
let reader = match std::fs::read_dir(&dir) {
Ok(reader) => reader,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err).with_context(|| format!("cannot read {}", dir.display())),
};
let mut keys: Vec<String> = reader
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().to_str().and_then(|name| name.strip_suffix(".json")).map(str::to_string))
.collect();
keys.sort();
let mut entries = Vec::new();
for key in keys {
if let Some(entry) = load_in(&dir, &key)? {
entries.push(entry);
}
}
Ok(entries)
}
pub fn save(entry: &Entry) -> Result<()> {
save_in(&jobs_dir()?, entry)
}
pub(crate) fn save_in(dir: &Path, entry: &Entry) -> Result<()> {
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
let key = entry.key();
let path = record_path(dir, &key);
let aside = dir.join(format!("{key}.json.{}.tmp", std::process::id()));
std::fs::write(&aside, serde_json::to_string_pretty(entry)?).with_context(|| format!("cannot write {}", aside.display()))?;
std::fs::rename(&aside, &path).with_context(|| format!("cannot write {}", path.display()))
}
const SLOT_PATIENCE: std::time::Duration = std::time::Duration::from_secs(10);
pub(crate) struct Slot {
_file: std::fs::File,
}
pub(crate) fn lock_in(dir: &Path, key: &str) -> Result<Slot> {
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
let path = dir.join(format!("{key}.lock"));
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.with_context(|| format!("cannot open {}", path.display()))?;
let started = std::time::Instant::now();
loop {
match file.try_lock() {
Ok(()) => return Ok(Slot { _file: file }),
Err(std::fs::TryLockError::WouldBlock) if started.elapsed() < SLOT_PATIENCE => std::thread::sleep(std::time::Duration::from_millis(10)),
Err(std::fs::TryLockError::WouldBlock) => anyhow::bail!(
"the record of {key} stayed locked by another turnout for {}s ({})",
SLOT_PATIENCE.as_secs(),
path.display()
),
Err(std::fs::TryLockError::Error(err)) => return Err(err).with_context(|| format!("cannot lock {}", path.display())),
}
}
}
pub fn remove(key: &str) -> Result<()> {
remove_in(&jobs_dir()?, key)
}
fn remove_in(dir: &Path, key: &str) -> Result<()> {
let path = record_path(dir, key);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| format!("cannot remove {}", path.display())),
}
}
pub fn remove_if(key: &str, pid: u32, birth: u64) -> Result<bool> {
remove_if_in(&jobs_dir()?, key, pid, birth)
}
fn remove_if_in(dir: &Path, key: &str, pid: u32, birth: u64) -> Result<bool> {
let _slot = lock_in(dir, key)?;
match load_in(dir, key)? {
Some(entry) if entry.pid == pid && entry.birth == birth => remove_in(dir, key).map(|()| true),
_ => Ok(false),
}
}
pub fn mark_stopping(key: &str, pid: u32, birth: u64) -> Result<()> {
mark_stopping_in(&jobs_dir()?, key, pid, birth)
}
fn mark_stopping_in(dir: &Path, key: &str, pid: u32, birth: u64) -> Result<()> {
let _slot = lock_in(dir, key)?;
match load_in(dir, key)? {
Some(mut entry) if entry.pid == pid && entry.birth == birth => {
entry.stopping = true;
save_in(dir, &entry)
}
_ => Ok(()),
}
}
pub fn being_stopped(key: &str) -> bool {
jobs_dir().is_ok_and(|dir| being_stopped_in(&dir, key, std::process::id(), process::own_birth()))
}
fn being_stopped_in(dir: &Path, key: &str, pid: u32, birth: u64) -> bool {
match load_in(dir, key) {
Ok(Some(entry)) if entry.pid == pid && entry.birth == birth => entry.stopping,
Ok(_) => true,
Err(_) => false,
}
}
pub fn update_own(key: &str, change: impl FnOnce(&mut Entry)) -> Result<()> {
let dir = jobs_dir()?;
let _slot = lock_in(&dir, key)?;
let Some(mut entry) = load_in(&dir, key)? else { return Ok(()) };
if entry.pid != std::process::id() || entry.birth != process::own_birth() {
return Ok(());
}
change(&mut entry);
save_in(&dir, &entry)
}
pub fn remove_own(key: &str) -> Result<()> {
remove_if(key, std::process::id(), process::own_birth()).map(|_| ())
}
pub fn gateway() -> Result<Option<crate::model::Gateway>> {
Ok(load(GATEWAY)?.filter(Entry::is_running).and_then(as_gateway))
}
pub fn as_gateway(entry: Entry) -> Option<crate::model::Gateway> {
match entry.work {
Work::Gateway { ports, front_port, control } => Some(crate::model::Gateway {
pid: entry.pid,
started: entry.started,
ports,
front_port,
control,
signals: entry.signals,
}),
Work::Command { .. } => None,
}
}
pub fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_file_names_and_never_collide() {
assert_eq!(command_key("myapp", "dev"), "myapp.dev");
assert_eq!(command_key("myapp", "test:e2e"), "myapp.test%3Ae2e");
assert_ne!(command_key("myapp", "test:e2e"), command_key("myapp", "test-e2e"));
assert_ne!(command_key("a", "b-c"), command_key("a-b", "c"));
assert_eq!(command_key("myapp", "../etc/passwd"), "myapp...%2Fetc%2Fpasswd");
assert_eq!(command_key("myapp", "a b"), "myapp.a%20b");
assert_eq!(command_key("myapp", ""), "myapp.%");
assert_eq!(escape("nul"), "%6Eul");
assert_eq!(escape("COM1"), "%43OM1");
assert_eq!(escape("com"), "com");
assert_eq!(escape("console"), "console");
assert!(!command_key("gateway", "x").eq(GATEWAY));
}
#[test]
fn records_read_back_as_written() {
let gateway = Work::Gateway {
ports: BTreeMap::from([(7100, "web".to_string()), (7101, "api".to_string())]),
front_port: Some(80),
control: Some(Control {
port: 51234,
token: "0f".repeat(32),
}),
};
let command = Work::Command {
app: "myapp".into(),
command: "test:e2e".into(),
};
for work in [gateway, command] {
let mut entry = Entry::own(work.clone(), true, Some(PathBuf::from("x.log")));
entry.ready = Some(Ready {
at: 5,
url: Some("http://a.localhost".into()),
});
entry.ended = Some(Ended {
at: 9,
code: None,
never_ran: false,
});
entry.signals = vec![crate::signal::Signal {
kind: crate::signal::Kind::StandDown,
app: "web".into(),
server: "prod".into(),
status: None,
since: 7,
}];
let text = serde_json::to_string(&entry).unwrap();
let back: Entry = serde_json::from_str(&text).unwrap_or_else(|err| panic!("{err}: {text}"));
assert_eq!(back.work, work);
assert_eq!((back.pid, back.birth, back.detached), (entry.pid, entry.birth, true));
assert_eq!(back.ready.unwrap().url.as_deref(), Some("http://a.localhost"));
assert_eq!(back.ended.unwrap().code, None);
assert_eq!(back.signals, entry.signals);
}
}
#[test]
fn a_record_is_removed_only_while_it_is_the_one_judged() {
let dir = tempfile::tempdir().unwrap();
let work = Work::Command {
app: "myapp".into(),
command: "dev".into(),
};
let judged = Entry::own(work, true, None);
save_in(dir.path(), &judged).unwrap();
let mut newer = judged.clone();
newer.pid = judged.pid.wrapping_add(1);
newer.birth = judged.birth.wrapping_add(1);
save_in(dir.path(), &newer).unwrap();
let key = judged.key();
assert!(!remove_if_in(dir.path(), &key, judged.pid, judged.birth).unwrap());
let left = load_in(dir.path(), &key).unwrap().expect("the new run's record was removed");
assert_eq!((left.pid, left.birth), (newer.pid, newer.birth));
assert!(!remove_if_in(dir.path(), &key, newer.pid, judged.birth).unwrap());
assert!(load_in(dir.path(), &key).unwrap().is_some());
assert!(remove_if_in(dir.path(), &key, newer.pid, newer.birth).unwrap());
assert!(load_in(dir.path(), &key).unwrap().is_none());
assert!(!remove_if_in(dir.path(), &key, newer.pid, newer.birth).unwrap());
}
#[test]
fn a_stop_is_known_to_the_job_before_it_ends() {
let dir = tempfile::tempdir().unwrap();
let work = Work::Command {
app: "myapp".into(),
command: "serve".into(),
};
let own = Entry::own(work, true, None);
let key = own.key();
save_in(dir.path(), &own).unwrap();
assert!(
!being_stopped_in(dir.path(), &key, own.pid, own.birth),
"a job nobody stops is not being stopped"
);
mark_stopping_in(dir.path(), &key, own.pid.wrapping_add(1), own.birth).unwrap();
assert!(!being_stopped_in(dir.path(), &key, own.pid, own.birth));
mark_stopping_in(dir.path(), &key, own.pid, own.birth).unwrap();
assert!(being_stopped_in(dir.path(), &key, own.pid, own.birth));
remove_in(dir.path(), &key).unwrap();
assert!(being_stopped_in(dir.path(), &key, own.pid, own.birth), "a record stop took away");
mark_stopping_in(dir.path(), &key, own.pid, own.birth).unwrap();
assert!(load_in(dir.path(), &key).unwrap().is_none());
}
#[test]
fn a_record_knows_whether_its_process_is_the_one_it_names() {
let work = Work::Command {
app: "myapp".into(),
command: "dev".into(),
};
let mut entry = Entry::own(work, false, None);
assert_eq!(entry.status(), Status::Running);
entry.birth = entry.birth.wrapping_add(1);
assert_eq!(entry.status(), Status::Gone);
entry.ended = Some(Ended {
at: now(),
code: Some(3),
never_ran: false,
});
assert_eq!(entry.status(), Status::Exited(Exit::Code(3)));
entry.ended = Some(Ended {
at: now(),
code: None,
never_ran: false,
});
assert_eq!(entry.status(), Status::Exited(Exit::Killed));
entry.ended = Some(Ended {
at: now(),
code: None,
never_ran: true,
});
assert_eq!(entry.status(), Status::Exited(Exit::NeverRan));
assert_eq!(Exit::NeverRan.describe(), "did not start");
let old: Ended = serde_json::from_str(r#"{"at": 1, "code": null}"#).unwrap();
assert_eq!(old.exit(), Exit::Killed);
let ran = serde_json::to_string(&Ended {
at: 1,
code: Some(0),
never_ran: false,
})
.unwrap();
assert!(!ran.contains("never_ran"), "{ran}");
}
}