Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items * Fix activity owner monitoring and Morph list interactions * Keep activity rows stable during updates and tighten their layout * Use subtle theme tint for activity cards * Tighten activity title and progress spacing * Use theme accent for active progress rings * Fit compact activities to measured titles and badge * Expose Live Activities display controls in Settings * Open activity terminals and streamline the Morph header * Give Morph continuous icons and prioritize workspace navigation * Show activity progress inside its ring * Inset activity progress rings and strengthen their stroke * Fix activity lifecycle, command output handling and list navigation * Reduce activity update work and harden publisher lifetimes * Set up Live Activities for the detected user shell during installation * Document Activities setup, daily use and troubleshooting * Preserve Morph idle width across empty activity updates * Fix dimming flicker, hide inactive music tab and consolidate Activities docs * Fix panel clearance across styles and match previews to theme settings * Prevent panel blur from repainting neighboring islands --------- Co-authored-by: Samuel Onoja <[email protected]>
This commit is contained in:
1 parent
7a7df0106c
commit
6b0d903ad0
107 files changed
+9340
-291
No files matched your search
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-activities = { path = "../crates/ferese-activities" }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
ferese-config = { path = "../crates/ferese-config" }
|
||||
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
mod output;
|
||||
mod progress;
|
||||
pub(super) mod setup;
|
||||
mod transport;
|
||||
use clap::{Subcommand, ValueEnum};
|
||||
use ferese_activities::{Data, Id, Source, State, Template, Value};
|
||||
use ferese_ipc::activities::{OwnerScope, Publish, Request, VERSION};
|
||||
use serde_json::json;
|
||||
use std::error::Error;
|
||||
use std::io::{self, BufRead, Read, Write};
|
||||
use std::os::unix::process::{CommandExt, ExitStatusExt};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Action {
|
||||
/// Enable shell activities and add the panel item if not already configured.
|
||||
Setup {
|
||||
#[arg(long)]
|
||||
config: std::path::PathBuf,
|
||||
},
|
||||
Start {
|
||||
local: String,
|
||||
#[arg(long, default_value = "ferese")]
|
||||
icon: String,
|
||||
#[arg(long)]
|
||||
title: String,
|
||||
#[arg(long)]
|
||||
progress: Option<f32>,
|
||||
#[arg(long)]
|
||||
message: Option<String>,
|
||||
#[arg(long)]
|
||||
source: Option<String>,
|
||||
/// Typed Progress, Timer, Media, or List payload as JSON.
|
||||
#[arg(long)]
|
||||
template: Option<String>,
|
||||
#[arg(long)]
|
||||
remaining_ms: Option<u64>,
|
||||
#[arg(long)]
|
||||
output: Option<String>,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
priority: u8,
|
||||
#[arg(long)]
|
||||
lease_ms: Option<u64>,
|
||||
},
|
||||
Update {
|
||||
local: String,
|
||||
#[arg(long)]
|
||||
progress: Option<f32>,
|
||||
#[arg(long)]
|
||||
message: Option<String>,
|
||||
#[arg(long)]
|
||||
remaining_ms: Option<u64>,
|
||||
#[arg(long)]
|
||||
lease_ms: Option<u64>,
|
||||
#[arg(long)]
|
||||
handle: Option<String>,
|
||||
},
|
||||
End {
|
||||
local: String,
|
||||
#[arg(long)]
|
||||
result: ResultKind,
|
||||
#[arg(long)]
|
||||
message: Option<String>,
|
||||
#[arg(long)]
|
||||
handle: Option<String>,
|
||||
},
|
||||
Cancel {
|
||||
local: String,
|
||||
#[arg(long)]
|
||||
handle: Option<String>,
|
||||
},
|
||||
/// Report an observed command to opt-in rules without executing it.
|
||||
Hook {
|
||||
local: String,
|
||||
#[arg(long)]
|
||||
command: String,
|
||||
#[arg(long)]
|
||||
cwd: String,
|
||||
},
|
||||
/// Show all current activity records.
|
||||
List,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum ResultKind {
|
||||
Ok,
|
||||
Fail,
|
||||
Cancelled,
|
||||
}
|
||||
fn handle(value: &Option<String>) -> Result<Option<Id>, Box<dyn Error>> {
|
||||
value
|
||||
.as_ref()
|
||||
.map(|value| serde_json::from_str(value).map_err(Into::into))
|
||||
.transpose()
|
||||
}
|
||||
fn value(
|
||||
progress: Option<f32>,
|
||||
message: &Option<String>,
|
||||
remaining_ms: Option<u64>,
|
||||
) -> Result<Option<Value>, Box<dyn Error>> {
|
||||
if usize::from(progress.is_some()) + usize::from(message.is_some()) + usize::from(remaining_ms.is_some()) > 1 {
|
||||
return Err("choose one activity value".into());
|
||||
}
|
||||
Ok(progress
|
||||
.map(Value::Progress)
|
||||
.or_else(|| message.clone().map(Value::Text))
|
||||
.or_else(|| {
|
||||
remaining_ms.map(|remaining_ms| Value::Countdown {
|
||||
remaining_ms,
|
||||
sampled_at: 0,
|
||||
})
|
||||
}))
|
||||
}
|
||||
fn publish(socket: &Path, owner: OwnerScope, publish: Publish) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
publish_with_timeout(socket, owner, publish, std::time::Duration::from_secs(2))
|
||||
}
|
||||
fn publish_with_timeout(
|
||||
socket: &Path,
|
||||
owner: OwnerScope,
|
||||
publish: Publish,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
let mut stream = transport::connect(socket, timeout)?;
|
||||
super::request(
|
||||
&mut stream,
|
||||
"activity-publish",
|
||||
json!(Request {
|
||||
version: VERSION,
|
||||
owner,
|
||||
publish
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn command(action: &Action, socket: &Path, json_output: bool) -> Result<(), Box<dyn Error>> {
|
||||
let request = match action {
|
||||
Action::Setup { .. } => unreachable!("setup is an offline command"),
|
||||
Action::Start {
|
||||
local,
|
||||
icon,
|
||||
title,
|
||||
progress,
|
||||
remaining_ms,
|
||||
message,
|
||||
source,
|
||||
template,
|
||||
output,
|
||||
priority,
|
||||
lease_ms,
|
||||
} => {
|
||||
let value = value(*progress, message, *remaining_ms)?.unwrap_or(Value::Indeterminate);
|
||||
let data = Data {
|
||||
source: if let Some(source) = source {
|
||||
serde_json::from_value(json!(source))?
|
||||
} else if remaining_ms.is_some() {
|
||||
Source::Timer
|
||||
} else {
|
||||
Source::Publisher
|
||||
},
|
||||
output: output.clone(),
|
||||
priority: *priority,
|
||||
icon: icon.clone(),
|
||||
title: title.clone(),
|
||||
template: if let Some(template) = template {
|
||||
if template.len() > 4_096 {
|
||||
return Err("template exceeds message budget".into());
|
||||
}
|
||||
serde_json::from_str(template)?
|
||||
} else if remaining_ms.is_some() {
|
||||
Template::Timer
|
||||
} else {
|
||||
Template::Progress
|
||||
},
|
||||
value,
|
||||
};
|
||||
Publish::Start {
|
||||
local: local.clone(),
|
||||
data,
|
||||
lease_ms: *lease_ms,
|
||||
}
|
||||
}
|
||||
Action::Update {
|
||||
local,
|
||||
progress,
|
||||
message,
|
||||
remaining_ms,
|
||||
lease_ms,
|
||||
handle: token,
|
||||
} => Publish::Update {
|
||||
local: local.clone(),
|
||||
handle: handle(token)?,
|
||||
value: value(*progress, message, *remaining_ms)?,
|
||||
lease_ms: *lease_ms,
|
||||
},
|
||||
Action::End {
|
||||
local,
|
||||
result,
|
||||
message,
|
||||
handle: token,
|
||||
} => Publish::End {
|
||||
local: local.clone(),
|
||||
handle: handle(token)?,
|
||||
result: match result {
|
||||
ResultKind::Ok => State::Done,
|
||||
ResultKind::Fail => State::Failed,
|
||||
ResultKind::Cancelled => State::Cancelled,
|
||||
},
|
||||
message: message.clone(),
|
||||
},
|
||||
Action::Cancel { local, handle: token } => Publish::Cancel {
|
||||
local: local.clone(),
|
||||
handle: handle(token)?,
|
||||
},
|
||||
Action::Hook { local, command, cwd } => Publish::Hook {
|
||||
local: local.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
},
|
||||
Action::List => {
|
||||
let mut stream = std::os::unix::net::UnixStream::connect(socket)?;
|
||||
return super::output::print(&super::request(&mut stream, "activity-get", json!({}))?, json_output);
|
||||
}
|
||||
};
|
||||
let result = publish(socket, OwnerScope::Parent, request)?;
|
||||
// Handles are machine-readable even when general queries use tabular output.
|
||||
super::output::print(&result, true)
|
||||
}
|
||||
|
||||
static CHILD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
|
||||
extern "C" fn terminate(signal: i32) {
|
||||
let pid = CHILD.load(std::sync::atomic::Ordering::Relaxed);
|
||||
// SAFETY: kill is async-signal-safe; the PID belongs to this wrapper's child.
|
||||
if pid > 0 {
|
||||
unsafe {
|
||||
libc::kill(pid, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
title: Option<&str>,
|
||||
args: &[String],
|
||||
socket: Option<&Path>,
|
||||
cargo_json: bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let Some(program) = args.first() else {
|
||||
return Err("run requires a command after --".into());
|
||||
};
|
||||
let local = format!("run-{}", std::process::id());
|
||||
let mut token = None;
|
||||
let mut configured_adapter = false;
|
||||
if let Some(socket) = socket {
|
||||
let metadata = args.join(" ");
|
||||
if metadata.len() <= 4_096 {
|
||||
let result = publish(
|
||||
socket,
|
||||
OwnerScope::Publisher,
|
||||
Publish::Run {
|
||||
local: local.clone(),
|
||||
title: title.map(str::to_owned),
|
||||
command: metadata,
|
||||
cwd: std::env::current_dir()
|
||||
.ok()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.filter(|cwd| cwd.len() <= 1_024)
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
if let Some(result) = result {
|
||||
configured_adapter = result["progress"] == "cargo-json";
|
||||
token = serde_json::from_value(result["handle"].clone()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
let adapter = (configured_adapter || cargo_json)
|
||||
&& program == "cargo"
|
||||
&& args
|
||||
.get(1)
|
||||
.is_some_and(|operation| matches!(operation.as_str(), "build" | "check" | "test" | "run" | "clippy"));
|
||||
let mut command = Command::new(program);
|
||||
command.args(&args[1..]);
|
||||
if adapter && !args.iter().any(|arg| arg.starts_with("--message-format")) {
|
||||
// Cargo options must precede the program arguments following `--`.
|
||||
let split = args[1..]
|
||||
.iter()
|
||||
.position(|arg| arg == "--")
|
||||
.map(|index| index + 1)
|
||||
.unwrap_or(args.len());
|
||||
command = Command::new(program);
|
||||
command
|
||||
.args(&args[1..split])
|
||||
.arg("--message-format=json-render-diagnostics")
|
||||
.args(&args[split..]);
|
||||
}
|
||||
command
|
||||
.stdin(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.stdout(if adapter { Stdio::piped() } else { Stdio::inherit() });
|
||||
// Terminal-generated signals reach both processes in the shell's job group.
|
||||
// The wrapper waits for the actual child result; the child resets inherited
|
||||
// dispositions before exec. SIGTSTP/SIGCONT retain normal job control.
|
||||
unsafe {
|
||||
libc::signal(libc::SIGINT, libc::SIG_IGN);
|
||||
libc::signal(libc::SIGQUIT, libc::SIG_IGN);
|
||||
libc::signal(libc::SIGTERM, terminate as *const () as usize);
|
||||
command.pre_exec(|| {
|
||||
libc::signal(libc::SIGINT, libc::SIG_DFL);
|
||||
libc::signal(libc::SIGQUIT, libc::SIG_DFL);
|
||||
libc::signal(libc::SIGTERM, libc::SIG_DFL);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
if let (Some(socket), Some(handle)) = (socket, token) {
|
||||
let _ = publish(
|
||||
socket,
|
||||
OwnerScope::Publisher,
|
||||
Publish::End {
|
||||
local,
|
||||
handle: Some(handle),
|
||||
result: State::Failed,
|
||||
message: Some("Could not start command".into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
CHILD.store(child.id() as i32, std::sync::atomic::Ordering::Relaxed);
|
||||
let publisher = socket
|
||||
.zip(token)
|
||||
.map(|(socket, handle)| progress::Publisher::new(socket.to_owned(), local, handle));
|
||||
let drain_deadline = output::Deadline::default();
|
||||
let reader = child.stdout.take().map(|stdout| {
|
||||
let deadline = drain_deadline.clone();
|
||||
let updates = publisher.as_ref().map(|publisher| publisher.updates.clone());
|
||||
let (done, finished) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::spawn(move || {
|
||||
let mut input = io::BufReader::new(output::Output { pipe: stdout, deadline });
|
||||
let mut stdout = io::stdout().lock();
|
||||
let mut units = 0_u64;
|
||||
let mut last = std::time::Instant::now();
|
||||
loop {
|
||||
let mut bytes = Vec::new();
|
||||
// Read bounded chunks, preserving arbitrary non-JSON output.
|
||||
let Ok(count) = input.by_ref().take(65_537).read_until(b'\n', &mut bytes) else {
|
||||
break;
|
||||
};
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
if bytes.len() <= 65_536
|
||||
&& bytes.first() == Some(&b'{')
|
||||
&& let Ok(message) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
{
|
||||
if message["reason"] == "compiler-message" {
|
||||
if let Some(rendered) = message["message"]["rendered"].as_str() {
|
||||
let _ = stdout.write_all(rendered.as_bytes());
|
||||
}
|
||||
} else if message["reason"] == "compiler-artifact" {
|
||||
units = units.saturating_add(1);
|
||||
} else if message["reason"] != "build-finished" && message["reason"] != "build-script-executed" {
|
||||
let _ = stdout.write_all(&bytes);
|
||||
}
|
||||
if last.elapsed() >= std::time::Duration::from_millis(200) {
|
||||
if let Some(updates) = &updates {
|
||||
updates.progress(units);
|
||||
}
|
||||
last = std::time::Instant::now();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if stdout.write_all(&bytes).and_then(|_| stdout.flush()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = stdout.flush();
|
||||
let _ = done.send(());
|
||||
});
|
||||
finished
|
||||
});
|
||||
let status = child.wait()?;
|
||||
CHILD.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
*drain_deadline.lock().unwrap() = Some(std::time::Instant::now() + std::time::Duration::from_millis(250));
|
||||
if let Some(reader) = reader {
|
||||
// Descendants may inherit the pipe after the direct child exits. Drain
|
||||
// buffered output briefly, but never wait for those descendants' EOF.
|
||||
let _ = reader.recv_timeout(std::time::Duration::from_millis(350));
|
||||
}
|
||||
if let Some(publisher) = publisher {
|
||||
let result = if matches!(status.signal(), Some(libc::SIGINT | libc::SIGTERM | libc::SIGQUIT)) {
|
||||
State::Cancelled
|
||||
} else if status.success() {
|
||||
State::Done
|
||||
} else {
|
||||
State::Failed
|
||||
};
|
||||
publisher.finish(result);
|
||||
}
|
||||
if let Some(signal) = status.signal() {
|
||||
// Preserve signal termination as well as the conventional shell status.
|
||||
unsafe {
|
||||
libc::signal(signal, libc::SIG_DFL);
|
||||
libc::raise(signal);
|
||||
}
|
||||
std::process::exit(128 + signal);
|
||||
}
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Drain a child's pipe without waiting for inherited descriptors indefinitely.
|
||||
use std::io::{self, Read};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::process::ChildStdout;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
pub(super) type Deadline = Arc<Mutex<Option<Instant>>>;
|
||||
pub(super) struct Output {
|
||||
pub pipe: ChildStdout,
|
||||
pub deadline: Deadline,
|
||||
}
|
||||
impl Read for Output {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
loop {
|
||||
if self
|
||||
.deadline
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
{
|
||||
// EOF lets BufReader return its final unterminated line too.
|
||||
return Ok(0);
|
||||
}
|
||||
let mut fd = libc::pollfd {
|
||||
fd: self.pipe.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// The pipe has exactly one reader. Once readable (or hung up), a
|
||||
// pipe read returns available bytes (or EOF) without filling bytes.
|
||||
let result = unsafe { libc::poll(&mut fd, 1, 20) };
|
||||
if result > 0 {
|
||||
return self.pipe.read(bytes);
|
||||
}
|
||||
if result < 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.kind() != io::ErrorKind::Interrupted {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! One replaceable progress update, independent of the child's stdout reader.
|
||||
use super::{Id, OwnerScope, Publish, State, Value, publish_with_timeout};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Condvar, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
units: Option<u64>,
|
||||
end: Option<State>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Updates(Arc<(Mutex<Pending>, Condvar)>);
|
||||
impl Updates {
|
||||
pub fn progress(&self, units: u64) {
|
||||
let (lock, wake) = &*self.0;
|
||||
let mut pending = lock.lock().unwrap();
|
||||
if !pending.closed {
|
||||
pending.units = Some(units);
|
||||
wake.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Publisher {
|
||||
pub updates: Updates,
|
||||
finished: mpsc::Receiver<()>,
|
||||
}
|
||||
impl Publisher {
|
||||
pub fn new(socket: PathBuf, local: String, handle: Id) -> Self {
|
||||
let updates = Updates::default();
|
||||
let worker = updates.clone();
|
||||
let (done, finished) = mpsc::sync_channel(1);
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
let (lock, wake) = &*worker.0;
|
||||
let mut pending = lock.lock().unwrap();
|
||||
while pending.units.is_none() && pending.end.is_none() {
|
||||
pending = wake.wait(pending).unwrap();
|
||||
}
|
||||
let end = pending.end.take();
|
||||
let units = pending.units.take();
|
||||
drop(pending); // Never hold the reader's lock during socket I/O.
|
||||
let request = if let Some(result) = end {
|
||||
Publish::End {
|
||||
local: local.clone(),
|
||||
handle: Some(handle.clone()),
|
||||
result,
|
||||
message: None,
|
||||
}
|
||||
} else {
|
||||
Publish::Update {
|
||||
local: local.clone(),
|
||||
handle: Some(handle.clone()),
|
||||
value: Some(Value::Text(format!("{} units compiled", units.unwrap()))),
|
||||
lease_ms: None,
|
||||
}
|
||||
};
|
||||
let timeout = if end.is_some() {
|
||||
Duration::from_secs(2)
|
||||
} else {
|
||||
Duration::from_millis(200)
|
||||
};
|
||||
let _ = publish_with_timeout(&socket, OwnerScope::Publisher, request, timeout);
|
||||
if end.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = done.send(());
|
||||
});
|
||||
Self { updates, finished }
|
||||
}
|
||||
|
||||
pub fn finish(self, result: State) {
|
||||
let (lock, wake) = &*self.updates.0;
|
||||
{
|
||||
let mut pending = lock.lock().unwrap();
|
||||
pending.closed = true;
|
||||
pending.units = None;
|
||||
pending.end = Some(result);
|
||||
wake.notify_one();
|
||||
}
|
||||
// Completion has a fixed budget even if the broker stops responding.
|
||||
let _ = self.finished.recv_timeout(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ferese_ipc::{Request, Response, read_frame, write_frame};
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
#[test]
|
||||
fn blocked_publication_coalesces_updates_and_completion_is_last() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("broker.sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let (seen, received) = mpsc::channel();
|
||||
let (release, released) = mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut first, _) = listener.accept().unwrap();
|
||||
first.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
|
||||
let request: Request = read_frame(&mut first).unwrap();
|
||||
seen.send(()).unwrap();
|
||||
released.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
// The progress timeout can already have elapsed on a loaded host.
|
||||
let _ = write_frame(&mut first, &Response::success(request.id, serde_json::json!({})));
|
||||
let (mut latest, _) = listener.accept().unwrap();
|
||||
let request: Request = read_frame(&mut latest).unwrap();
|
||||
let wire: ferese_ipc::activities::Request = serde_json::from_value(request.args).unwrap();
|
||||
assert!(
|
||||
matches!(wire.publish, Publish::Update { value: Some(Value::Text(ref text)), .. } if text == "1000 units compiled")
|
||||
);
|
||||
write_frame(&mut latest, &Response::success(request.id, serde_json::json!({}))).unwrap();
|
||||
seen.send(()).unwrap();
|
||||
let (mut end, _) = listener.accept().unwrap();
|
||||
let request: Request = read_frame(&mut end).unwrap();
|
||||
let wire: ferese_ipc::activities::Request = serde_json::from_value(request.args).unwrap();
|
||||
assert!(matches!(
|
||||
wire.publish,
|
||||
Publish::End {
|
||||
result: State::Done,
|
||||
..
|
||||
}
|
||||
));
|
||||
write_frame(&mut end, &Response::success(request.id, serde_json::json!({}))).unwrap();
|
||||
});
|
||||
let publisher = Publisher::new(
|
||||
socket,
|
||||
"test".into(),
|
||||
Id {
|
||||
owner: ferese_activities::Owner { pid: 1, birth: 1 },
|
||||
local: "test".into(),
|
||||
session: 1,
|
||||
generation: 1,
|
||||
},
|
||||
);
|
||||
publisher.updates.progress(1);
|
||||
received.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
for units in 2..=1000 {
|
||||
publisher.updates.progress(units);
|
||||
}
|
||||
release.send(()).unwrap();
|
||||
received.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
publisher.finish(State::Done);
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use ferese_config::{Document, file::ConfigFile, panel};
|
||||
use serde_json::json;
|
||||
use std::{error::Error, path::Path};
|
||||
|
||||
fn prepare(source: &str) -> Result<String, Box<dyn Error>> {
|
||||
let mut doc = Document::parse(source)?;
|
||||
if !ferese_config::activities::setup_shell_hook(&mut doc)? {
|
||||
return Ok(source.into());
|
||||
}
|
||||
if doc.get("panels").is_none() {
|
||||
doc.set("panels", serde_json::to_value(vec![panel::Panel::default()])?)?;
|
||||
}
|
||||
let panels: Vec<panel::Panel> = serde_json::from_value(doc.get("panels").unwrap().clone())?;
|
||||
panel::validate(&panels)?;
|
||||
for (index, bar) in panels.iter().enumerate() {
|
||||
let groups = [&bar.start, &bar.center, &bar.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.collect::<Vec<_>>();
|
||||
if groups
|
||||
.iter()
|
||||
.flat_map(|group| &group.items)
|
||||
.any(|item| item.kind == panel::ItemKind::Activities)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut id = "activities".to_string();
|
||||
while groups.iter().flat_map(|group| &group.items).any(|item| item.id.0 == id) {
|
||||
id.push('-');
|
||||
}
|
||||
let item = serde_json::to_value(panel::Item::new(&id, panel::ItemKind::Activities))?;
|
||||
// Append within an existing group, preserving its position and every other item.
|
||||
let zone = ["end", "center", "start"].into_iter().find(|zone| {
|
||||
doc.get(&format!("panels.{index}.{zone}.groups"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|groups| !groups.is_empty())
|
||||
});
|
||||
if let Some(zone) = zone {
|
||||
doc.add(
|
||||
&format!("panels.{index}.{zone}.groups.0.items"),
|
||||
item.as_object().unwrap().clone().into_iter().collect(),
|
||||
)?;
|
||||
} else {
|
||||
doc.add(
|
||||
&format!("panels.{index}.end.groups"),
|
||||
vec![("id".into(), json!("activities")), ("items".into(), json!([item]))],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let panels = serde_json::from_value::<Vec<panel::Panel>>(doc.get("panels").unwrap().clone())?;
|
||||
panel::validate(&panels)?;
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
pub fn configure(path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let file = ConfigFile::open(path)?;
|
||||
let desired = prepare(file.source())?;
|
||||
if desired == file.source() {
|
||||
return Ok(());
|
||||
}
|
||||
let backup = path.with_file_name(format!(
|
||||
"{}.before-activities-{}",
|
||||
path.file_name().unwrap().to_string_lossy(),
|
||||
ferese_ipc::activities::now_ms()
|
||||
));
|
||||
file.replace(&desired, Some(&backup), |_| Ok(()))?;
|
||||
println!("Configured Live Activities; previous config: {}", backup.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn setup_preserves_layout_comments_rules_and_explicit_disable() {
|
||||
let source =
|
||||
"// keep my layout\npanel main { style morph; end { group tools { item activities kind=clock; }; }; }\n";
|
||||
let next = prepare(source).unwrap();
|
||||
assert!(next.contains("// keep my layout"));
|
||||
let doc = Document::parse(&next).unwrap();
|
||||
assert_eq!(doc.get("panels.0.style"), Some(&json!("morph")));
|
||||
assert_eq!(doc.get("panels.0.end.groups.0.items.0.kind"), Some(&json!("clock")));
|
||||
assert_eq!(doc.get("panels.0.end.groups.0.items.1.id"), Some(&json!("activities-")));
|
||||
assert_eq!(prepare(&next).unwrap(), next);
|
||||
let disabled = "activities { sources { shell-hook #false; }; }\n";
|
||||
assert_eq!(prepare(disabled).unwrap(), disabled);
|
||||
let hidden = "panel main { end { group tools { item tasks kind=activities visible=#false; }; }; }\n";
|
||||
let hidden = Document::parse(&prepare(hidden).unwrap()).unwrap();
|
||||
assert_eq!(hidden.get("panels.0.end.groups.0.items.0.visible"), Some(&json!(false)));
|
||||
assert_eq!(
|
||||
hidden
|
||||
.get("panels.0.end.groups.0.items")
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(prepare("").is_ok());
|
||||
let partial = Document::parse(&prepare("activities { max-visible 1; }\n").unwrap()).unwrap();
|
||||
assert_eq!(partial.get("activities.max_visible"), Some(&json!(1)));
|
||||
assert_eq!(partial.get("activities.sources.shell_hook"), Some(&json!(true)));
|
||||
assert!(prepare("activities { unknown 1; }").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_is_backed_up_and_invalid_input_is_not_replaced() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
std::fs::write(&path, "// original\n").unwrap();
|
||||
configure(&path).unwrap();
|
||||
configure(&path).unwrap();
|
||||
let backups: Vec<_> = std::fs::read_dir(directory.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|file| file.file_name().to_string_lossy().contains(".before-activities-"))
|
||||
.collect();
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(std::fs::read_to_string(backups[0].path()).unwrap(), "// original\n");
|
||||
std::fs::write(&path, "activities { bad #true; }\n").unwrap();
|
||||
assert!(configure(&path).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "activities { bad #true; }\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! A complete exchange has one deadline, including a full Unix listen backlog.
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::{ffi::OsStrExt, net::UnixStream};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) struct Stream {
|
||||
socket: UnixStream,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
pub(super) fn connect(path: &Path, timeout: Duration) -> io::Result<Stream> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let path = path.as_os_str().as_bytes();
|
||||
// SAFETY: an all-zero sockaddr_un is valid to initialize before connect.
|
||||
let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
|
||||
if path.is_empty() || path.len() >= address.sun_path.len() || path.contains(&0) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"invalid activity socket path",
|
||||
));
|
||||
}
|
||||
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
|
||||
for (to, from) in address.sun_path.iter_mut().zip(path) {
|
||||
*to = *from as libc::c_char;
|
||||
}
|
||||
// SAFETY: socket creates a new descriptor; nonblocking connect cannot wait
|
||||
// indefinitely for room in a Unix listener's queue.
|
||||
let fd = unsafe {
|
||||
libc::socket(
|
||||
libc::AF_UNIX,
|
||||
libc::SOCK_STREAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: this function takes sole ownership of the descriptor just created.
|
||||
let socket = unsafe { UnixStream::from_raw_fd(fd) };
|
||||
let length = std::mem::offset_of!(libc::sockaddr_un, sun_path) + path.len() + 1;
|
||||
// SAFETY: address contains a checked, NUL-terminated pathname of length bytes.
|
||||
if unsafe {
|
||||
libc::connect(
|
||||
socket.as_raw_fd(),
|
||||
(&raw const address).cast(),
|
||||
length as libc::socklen_t,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
// Unix connect returns EAGAIN for a full backlog. Activity publishing is
|
||||
// best effort, so fail promptly instead of delaying the wrapped command.
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
socket.set_nonblocking(false)?;
|
||||
Ok(Stream { socket, deadline })
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn remaining(&self) -> io::Result<Duration> {
|
||||
self.deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.filter(|remaining| !remaining.is_zero())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "activity publication deadline expired"))
|
||||
}
|
||||
}
|
||||
impl Read for Stream {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
|
||||
self.socket.set_read_timeout(Some(self.remaining()?))?;
|
||||
self.socket.read(bytes)
|
||||
}
|
||||
}
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.socket.set_write_timeout(Some(self.remaining()?))?;
|
||||
self.socket.write(bytes)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.socket.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn trickled_response_expires_and_closes_the_socket_without_a_detached_reader() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("broker.sock");
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let (done, closed) = mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let _: ferese_ipc::Request = ferese_ipc::read_frame(&mut socket).unwrap();
|
||||
socket.write_all(&1000_u32.to_be_bytes()).unwrap();
|
||||
for _ in 0..50 {
|
||||
if socket.write_all(b" ").is_err() {
|
||||
done.send(()).unwrap();
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
{
|
||||
let mut stream = connect(&path, Duration::from_millis(100)).unwrap();
|
||||
assert!(super::super::super::request(&mut stream, "activity-get", serde_json::json!({})).is_err());
|
||||
}
|
||||
closed
|
||||
.recv_timeout(Duration::from_millis(400))
|
||||
.expect("timed-out socket remained open");
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_listener_backlog_does_not_block_connect() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("broker.sock");
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
// SAFETY: the listener owns this socket; reducing its backlog does not
|
||||
// affect the descriptor or the sockaddr used by subsequent clients.
|
||||
assert_eq!(unsafe { libc::listen(listener.as_raw_fd(), 0) }, 0);
|
||||
let _first = connect(&path, Duration::from_secs(1)).unwrap();
|
||||
let started = Instant::now();
|
||||
assert!(connect(&path, Duration::from_secs(1)).is_err());
|
||||
assert!(started.elapsed() < Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,20 @@ pub struct Cli {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Publish bounded activity data owned by the invoking shell.
|
||||
Activity {
|
||||
#[command(subcommand)]
|
||||
action: crate::activities::Action,
|
||||
},
|
||||
/// Run a command with activity publishing; preserve its actual exit status.
|
||||
Run {
|
||||
#[arg(long)]
|
||||
title: Option<String>,
|
||||
#[arg(long)]
|
||||
cargo_json: bool,
|
||||
#[arg(last = true, required = true)]
|
||||
command: Vec<String>,
|
||||
},
|
||||
/// Show the active window.
|
||||
#[command(alias = "get-focused-window")]
|
||||
FocusedWindow,
|
||||
@@ -214,6 +228,7 @@ fn value_name(value: &impl ValueEnum) -> String {
|
||||
impl Command {
|
||||
pub fn ipc(&self) -> Option<(&'static str, Value)> {
|
||||
let (command, args) = match self {
|
||||
Self::Activity { .. } | Self::Run { .. } => return None,
|
||||
Self::FocusedWindow => ("get-focused-window", json!({})),
|
||||
Self::Windows => ("get-windows", json!({})),
|
||||
Self::IdleInhibition => ("get-idle-inhibition", json!({})),
|
||||
|
||||
+19
-2
@@ -1,3 +1,4 @@
|
||||
mod activities;
|
||||
mod autostart;
|
||||
mod cli;
|
||||
mod output;
|
||||
@@ -18,6 +19,12 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
if matches!(cli.command, Command::Autostart) {
|
||||
return autostart::run();
|
||||
}
|
||||
if let Command::Activity {
|
||||
action: activities::Action::Setup { config },
|
||||
} = &cli.command
|
||||
{
|
||||
return activities::setup::configure(config);
|
||||
}
|
||||
for alias in env::args().skip(1).filter(|arg| {
|
||||
matches!(
|
||||
arg.as_str(),
|
||||
@@ -35,9 +42,19 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
alias.trim_start_matches("get-")
|
||||
);
|
||||
}
|
||||
if let Command::Run {
|
||||
title,
|
||||
command,
|
||||
cargo_json,
|
||||
} = &cli.command
|
||||
{
|
||||
let socket = ferese_ipc::socket::resolve(cli.socket.as_deref()).ok();
|
||||
return activities::run(title.as_deref(), command, socket.as_deref(), *cargo_json);
|
||||
}
|
||||
let socket = ferese_ipc::socket::resolve(cli.socket.as_deref())?;
|
||||
match &cli.command {
|
||||
Command::Theme { action } => theme_command(action, &socket, cli.json),
|
||||
Command::Activity { action } => activities::command(action, &socket, cli.json),
|
||||
Command::EventStream => event_stream(&socket),
|
||||
command => {
|
||||
let (command, args) = command.ipc().expect("IPC command");
|
||||
@@ -52,7 +69,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result<Value, Box<dyn Error>> {
|
||||
fn request(stream: &mut (impl io::Read + io::Write), command: &str, args: Value) -> Result<Value, Box<dyn Error>> {
|
||||
write_frame(
|
||||
stream,
|
||||
&Request {
|
||||
@@ -74,7 +91,7 @@ fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result<Value,
|
||||
}
|
||||
|
||||
fn theme_command(action: &ThemeAction, socket: &Path, json_output: bool) -> Result<(), Box<dyn Error>> {
|
||||
let mut connection = ferese_ipc::theme::Connection::connect_to(socket)?;
|
||||
let mut connection = ferese_ipc::Connection::connect_to(socket)?;
|
||||
let result = match action {
|
||||
ThemeAction::Get => serde_json::to_value(connection.get(ferese_config::families::builtins)?)?,
|
||||
ThemeAction::Subscribe => {
|
||||
|
||||
@@ -189,3 +189,284 @@ fn event_stream_uses_the_selected_socket_and_prints_complete_json_lines() {
|
||||
stop.send(()).unwrap();
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_start_uses_versioned_framing_and_parent_scope() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("activity.sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request: Request = read_frame(&mut stream).unwrap();
|
||||
assert_eq!(request.version, ferese_ipc::VERSION);
|
||||
assert_eq!(request.command, "activity-publish");
|
||||
let wire: ferese_ipc::activities::Request = serde_json::from_value(request.args).unwrap();
|
||||
assert!(matches!(wire.owner, ferese_ipc::activities::OwnerScope::Parent));
|
||||
match wire.publish {
|
||||
ferese_ipc::activities::Publish::Start { local, data, .. } => {
|
||||
assert_eq!(local, "build");
|
||||
assert_eq!(data.value, ferese_activities::Value::Indeterminate);
|
||||
}
|
||||
_ => panic!("wrong activity action"),
|
||||
}
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&Response::success(request.id, json!({"handle":{"generation":1}})),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
success(
|
||||
ctl(&[
|
||||
"--socket",
|
||||
socket.to_str().unwrap(),
|
||||
"activity",
|
||||
"start",
|
||||
"build",
|
||||
"--title",
|
||||
"Build",
|
||||
])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_preserves_arguments_cwd_environment_output_and_exit_when_publishing_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = ctl(&[
|
||||
"--socket",
|
||||
"/missing/ferese.sock",
|
||||
"run",
|
||||
"--title",
|
||||
"Test",
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
"printf '%s|%s|%s' \"$1\" \"$ACTIVITY_TEST_VALUE\" \"$PWD\"; printf stderr >&2; exit 17",
|
||||
"sh",
|
||||
"argument with spaces;$literal",
|
||||
])
|
||||
.env("ACTIVITY_TEST_VALUE", "preserved")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(17));
|
||||
assert_eq!(
|
||||
String::from_utf8(output.stdout).unwrap(),
|
||||
format!("argument with spaces;$literal|preserved|{}", dir.path().display())
|
||||
);
|
||||
assert_eq!(output.stderr, b"stderr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_preserves_child_exit_and_signal_status() {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
let output = ctl(&[
|
||||
"run",
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
"printf '{\"reason\":\"build-finished\",\"success\":true}\\n'; exit 19",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(19));
|
||||
let output = ctl(&["run", "--", "sh", "-c", "kill -TERM $$"]).output().unwrap();
|
||||
assert_eq!(output.status.signal(), Some(libc::SIGTERM));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapper_forwards_termination_and_preserves_job_control_group() {
|
||||
use std::os::unix::process::{CommandExt, ExitStatusExt};
|
||||
let mut command = ctl(&["run", "--", "sh", "-c", "kill -STOP $$; sleep 30"]);
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
libc::setpgid(0, 0);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut child = command.spawn().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
let pid = child.id() as i32;
|
||||
assert_eq!(unsafe { libc::kill(-pid, libc::SIGCONT) }, 0);
|
||||
assert_eq!(unsafe { libc::kill(pid, libc::SIGTERM) }, 0);
|
||||
assert_eq!(child.wait().unwrap().signal(), Some(libc::SIGTERM));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_ctrl_c_reaches_child_and_wrapper_returns_sigint() {
|
||||
use std::os::unix::process::{CommandExt, ExitStatusExt};
|
||||
let mut command = ctl(&["run", "--", "sleep", "30"]);
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
libc::setpgid(0, 0);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut child = command.spawn().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
assert_eq!(unsafe { libc::kill(-(child.id() as i32), libc::SIGINT) }, 0);
|
||||
assert_eq!(child.wait().unwrap().signal(), Some(libc::SIGINT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_broker_cannot_prevent_or_change_command_execution() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let socket = directory.path().join("blocked.sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let worker = thread::spawn(move || {
|
||||
let (_stream, _) = listener.accept().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
});
|
||||
let began = std::time::Instant::now();
|
||||
let result = ctl(&[
|
||||
"--socket",
|
||||
socket.to_str().unwrap(),
|
||||
"run",
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
"printf executed; exit 23",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(result.stdout, b"executed");
|
||||
assert_eq!(result.status.code(), Some(23));
|
||||
assert!(began.elapsed() < std::time::Duration::from_secs(3));
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
fn fake_cargo(dir: &Path, script: &str) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = dir.join("cargo");
|
||||
std::fs::write(&path, script).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
format!("{}:{}", dir.display(), std::env::var("PATH").unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_adapter_completion_does_not_wait_for_descendant_stdout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = fake_cargo(
|
||||
dir.path(),
|
||||
"#!/bin/sh\nsleep 5 &\necho $! > descendant\nprintf captured\nexit 23\n",
|
||||
);
|
||||
let output = dir.path().join("output");
|
||||
let mut child = ctl(&[
|
||||
"--socket",
|
||||
"/missing/activity.sock",
|
||||
"run",
|
||||
"--cargo-json",
|
||||
"--",
|
||||
"cargo",
|
||||
"build",
|
||||
])
|
||||
.env("PATH", path)
|
||||
.current_dir(dir.path())
|
||||
.stdout(std::fs::File::create(&output).unwrap())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break Some(status);
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
break None;
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
};
|
||||
let descendant: i32 = std::fs::read_to_string(dir.path().join("descendant"))
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
unsafe {
|
||||
libc::kill(descendant, libc::SIGTERM);
|
||||
}
|
||||
assert_eq!(
|
||||
status.and_then(|status| status.code()),
|
||||
Some(23),
|
||||
"waited for descendant EOF"
|
||||
);
|
||||
assert_eq!(std::fs::read(&output).unwrap(), b"captured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stalled_progress_acknowledgements_do_not_block_cargo_stdout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = fake_cargo(
|
||||
dir.path(),
|
||||
r##"#!/usr/bin/env python3
|
||||
import json, time, sys
|
||||
start = time.monotonic()
|
||||
time.sleep(.3)
|
||||
for _ in range(20000):
|
||||
print('{"reason":"compiler-artifact"}')
|
||||
sys.stdout.flush()
|
||||
with open('elapsed', 'w') as f:
|
||||
f.write(str(time.monotonic() - start))
|
||||
print('{"reason":"build-finished","success":true}')
|
||||
print('child output')
|
||||
print(json.dumps({'reason': 'compiler-message', 'message': {'rendered': 'diagnostic\n'}}))
|
||||
sys.exit(19)
|
||||
"##,
|
||||
);
|
||||
let socket = dir.path().join("broker.sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
let mut stalled = Vec::new();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
let Ok((mut stream, _)) = listener.accept() else {
|
||||
thread::sleep(std::time::Duration::from_millis(5));
|
||||
continue;
|
||||
};
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
let request: Request = read_frame(&mut stream).unwrap();
|
||||
match request.args["publish"]["action"].as_str().unwrap() {
|
||||
"run" => {
|
||||
let handle = ferese_activities::Id {
|
||||
owner: ferese_activities::Owner { pid: 1, birth: 1 },
|
||||
local: request.args["publish"]["local"].as_str().unwrap().into(),
|
||||
session: 1,
|
||||
generation: 1,
|
||||
};
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&Response::success(request.id, json!({"handle":handle,"progress":"cargo-json"})),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"update" => stalled.push(stream), // Accept the update but never acknowledge it.
|
||||
"end" => {
|
||||
assert_eq!(request.args["publish"]["result"], "failed");
|
||||
write_frame(&mut stream, &Response::success(request.id, json!({}))).unwrap();
|
||||
return stalled.len();
|
||||
}
|
||||
_ => panic!("unexpected publication"),
|
||||
}
|
||||
}
|
||||
panic!("completion was not published");
|
||||
});
|
||||
let output = ctl(&["--socket", socket.to_str().unwrap(), "run", "--", "cargo", "build"])
|
||||
.env("PATH", path)
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(19));
|
||||
assert_eq!(output.stdout, b"child output\ndiagnostic\n");
|
||||
assert!(server.join().unwrap() > 0, "probe must exercise progress publishing");
|
||||
let elapsed: f64 = std::fs::read_to_string(dir.path().join("elapsed"))
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(elapsed < 1.5, "stdout reader stalled the child for {elapsed}s");
|
||||
}
|
||||
Reference in new issue
Block a user