use crate::paths;
use crate::proto::{Request, Response};
use anyhow::{bail, Context, Result};
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::{Duration, Instant};
const SPAWN_TIMEOUT: Duration = Duration::from_secs(10);
const COORDINATOR_CEILING: Duration = Duration::from_secs(300);
const SAY_STILL_WAITING_AFTER: Duration = Duration::from_secs(10);
pub const STEP_VARIABLE: &str = "QEX_SAY_WAITING_AFTER_SECS";
fn step_of_a_wait() -> Duration {
match std::env::var(STEP_VARIABLE)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
{
Some(seconds) if seconds > 0 && seconds <= SAY_STILL_WAITING_AFTER.as_secs() => {
Duration::from_secs(seconds)
}
_ => SAY_STILL_WAITING_AFTER,
}
}
static READER_DEADLINE: std::sync::OnceLock<std::sync::Mutex<Option<Instant>>> =
std::sync::OnceLock::new();
static SAID_IT_WAITS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn reader_deadline() -> &'static std::sync::Mutex<Option<Instant>> {
READER_DEADLINE.get_or_init(|| std::sync::Mutex::new(None))
}
pub const CEILING_VARIABLE: &str = "QEX_COORDINATOR_CEILING_SECS";
const LARGEST_CEILING: Duration = Duration::from_secs(24 * 60 * 60);
fn ceiling() -> Duration {
match std::env::var(CEILING_VARIABLE)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
{
Some(seconds) if seconds > 0 && seconds <= LARGEST_CEILING.as_secs() => {
Duration::from_secs(seconds)
}
_ => COORDINATOR_CEILING,
}
}
fn deadline_for_one_wait() -> Instant {
let mine = Instant::now() + ceiling();
match *reader_deadline().lock().unwrap_or_else(|e| e.into_inner()) {
Some(asked) if asked < mine => asked,
_ => mine,
}
}
pub fn take_the_limit_of_the_reader(limit: Duration) {
let asked = Instant::now() + limit;
let mut held = reader_deadline().lock().unwrap_or_else(|e| e.into_inner());
match *held {
Some(earlier) if earlier <= asked => {}
_ => *held = Some(asked),
}
}
fn say_that_qex_still_waits(since: Instant, deadline: Instant, what: &str) {
use std::sync::atomic::Ordering;
if since.elapsed() < step_of_a_wait() || SAID_IT_WAITS.swap(true, Ordering::SeqCst) {
return;
}
let left = deadline.saturating_duration_since(Instant::now());
let reader_gave = reader_deadline()
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some();
if reader_gave {
eprintln!(
"qex: still waiting for the coordinator: {what}. \
Your `--timeout` gives it {} seconds more.",
left.as_secs().max(1)
);
} else {
eprintln!(
"qex: still waiting for the coordinator: {what}. \
qex gives this answer {} seconds more, and `--timeout` gives the \
command a shorter limit.",
left.as_secs().max(1)
);
}
}
pub struct Client {
stream: UnixStream,
reader: BufReader<UnixStream>,
lost_its_place: bool,
}
impl Client {
pub fn connect() -> Result<Self> {
Self::connect_or_explain().map_err(name_the_sandbox)
}
fn connect_or_explain() -> Result<Self> {
let socket = paths::socket_path()?;
if let Some(stream) = try_connect(&socket)? {
return Client::with_stream(stream);
}
let _lock = SpawnLock::acquire()?;
if let Some(stream) = try_connect(&socket)? {
return Client::with_stream(stream);
}
spawn_daemon()?;
let stream = wait_for_socket(&socket, SPAWN_TIMEOUT)?;
Client::with_stream(stream)
}
pub fn connect_existing() -> Option<Self> {
Self::connect_existing_result().ok().flatten()
}
pub fn connect_existing_result() -> Result<Option<Self>> {
let Ok(socket) = paths::socket_path() else {
return Ok(None);
};
match try_connect(&socket)? {
Some(stream) => Ok(Some(Client::with_stream(stream)?)),
None => Ok(None),
}
}
fn quiet_a_broken_pipe(stream: &UnixStream) {
#[cfg(target_vendor = "apple")]
{
use std::os::unix::io::AsRawFd;
let on: libc::c_int = 1;
unsafe {
libc::setsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_NOSIGPIPE,
&on as *const libc::c_int as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
);
}
}
#[cfg(not(target_vendor = "apple"))]
{
let _ = stream;
}
}
fn with_stream(stream: UnixStream) -> Result<Self> {
Self::quiet_a_broken_pipe(&stream);
let reader = BufReader::new(stream.try_clone().context("copying the socket handle")?);
Ok(Self {
stream,
reader,
lost_its_place: false,
})
}
pub fn call(&mut self, request: &Request) -> Result<Response> {
self.call_within(request, deadline_for_one_wait())
}
fn call_within(&mut self, request: &Request, deadline: Instant) -> Result<Response> {
if self.lost_its_place {
return Err(timed_out(
"qex asked the coordinator nothing more on this connection.\n\
An earlier request reached its time limit, and the answer to it \
can still arrive. A later question on the same connection would \
read that answer as its own, and qex would give you the answer \
to a question that you did not ask.\n\
Run the command again."
.to_string(),
));
}
self.send(request)?;
let ours = self.stream.read_timeout().ok().flatten().is_none();
if !ours {
return self.recv();
}
let started = Instant::now();
let answer = loop {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
break Err(timed_out(a_silent_coordinator(started.elapsed())));
}
match self.wait_readable(left.min(step_of_a_wait())) {
Ok(true) => {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
break Err(timed_out(a_silent_coordinator(started.elapsed())));
}
self.set_read_timeout(Some(left)).ok();
let got = self.recv();
self.set_read_timeout(None).ok();
break match got {
Err(e) if a_read_that_reached_its_limit(&e) => {
Err(timed_out(a_cut_answer(started.elapsed())))
}
other => other,
};
}
Ok(false) => {
say_that_qex_still_waits(started, deadline, "an answer to a request");
}
Err(e) => {
break Err(anyhow::Error::new(e).context("watching the socket for an answer"))
}
}
};
if answer.is_err() {
self.lost_its_place = true;
}
answer
}
pub fn send(&mut self, request: &Request) -> Result<()> {
let mut line = serde_json::to_string(request).context("writing the request")?;
line.push('\n');
self.write_quietly(line.as_bytes())
.context("sending the request to the coordinator")?;
self.stream.flush().ok();
Ok(())
}
fn write_quietly(&mut self, bytes: &[u8]) -> std::io::Result<()> {
use std::os::unix::io::AsRawFd;
let fd = self.stream.as_raw_fd();
let mut sent = 0;
while sent < bytes.len() {
#[cfg(target_vendor = "apple")]
let flags = 0;
#[cfg(not(target_vendor = "apple"))]
let flags = libc::MSG_NOSIGNAL;
let wrote = unsafe {
libc::send(
fd,
bytes[sent..].as_ptr() as *const libc::c_void,
bytes.len() - sent,
flags,
)
};
if wrote > 0 {
sent += wrote as usize;
continue;
}
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(e);
}
Ok(())
}
pub fn recv(&mut self) -> Result<Response> {
match self.recv_opt()? {
Some(response) => Ok(response),
None => bail!("the coordinator closed the connection without an answer"),
}
}
pub fn recv_opt(&mut self) -> Result<Option<Response>> {
let mut line = String::new();
let n = self
.reader
.read_line(&mut line)
.context("reading the answer of the coordinator")?;
if n == 0 {
return Ok(None);
}
serde_json::from_str(&line)
.map(Some)
.with_context(|| format!("reading this answer of the coordinator: {}", line.trim()))
}
pub fn wait_readable(&self, timeout: Duration) -> std::io::Result<bool> {
use std::os::unix::io::AsRawFd;
let mut fds = libc::pollfd {
fd: self.stream.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let ms = timeout.as_millis().min(i32::MAX as u128) as i32;
let rc = unsafe { libc::poll(&mut fds, 1, ms) };
match rc {
0 => Ok(false),
-1 => {
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::Interrupted {
Ok(false)
} else {
Err(e)
}
}
_ => Ok(true),
}
}
pub fn set_read_timeout(&mut self, timeout: Option<Duration>) -> Result<()> {
self.stream
.set_read_timeout(timeout)
.context("setting the timeout of the socket")
}
}
#[derive(Debug)]
pub struct CoordinatorTimeout;
impl std::fmt::Display for CoordinatorTimeout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"the limit for an answer of the coordinator ended this wait"
)
}
}
impl std::error::Error for CoordinatorTimeout {}
fn timed_out(message: String) -> anyhow::Error {
anyhow::Error::new(CoordinatorTimeout).context(message)
}
pub fn is_a_coordinator_timeout(error: &anyhow::Error) -> bool {
error.downcast_ref::<CoordinatorTimeout>().is_some()
}
fn a_read_that_reached_its_limit(error: &anyhow::Error) -> bool {
error
.chain()
.filter_map(|e| e.downcast_ref::<std::io::Error>())
.any(|e| {
matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
})
}
fn a_cut_answer(waited: Duration) -> String {
format!(
"the coordinator began an answer and did not finish it in {} seconds.\n\
The part that arrived is not a whole answer, so qex read none of it.\n\
Run `qex info --no-start` to see whether it answers now, and read {} \
for what it did.",
waited.as_secs().max(1),
paths::daemon_log_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "the log of the coordinator".to_string())
)
}
fn a_silent_coordinator(waited: Duration) -> String {
format!(
"the coordinator took the request and gave no answer in {} seconds.\n\
Run `qex info --no-start` to see whether it answers now, and read {} \
for what it did.",
waited.as_secs().max(1),
paths::daemon_log_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "the log of the coordinator".to_string())
)
}
fn try_connect(socket: &Path) -> Result<Option<UnixStream>> {
let started = Instant::now();
let deadline = deadline_for_one_wait();
loop {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(timed_out(format!(
"the socket {} gave no answer in {} seconds.\n\
Run `qex info --no-start` to see whether a coordinator answers, \
and read {} for what it did.",
socket.display(),
started.elapsed().as_secs().max(1),
paths::daemon_log_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "the log of the coordinator".to_string())
)));
}
match paths::connect_within(socket, left.min(step_of_a_wait())) {
paths::Connected::Open(stream) => return Ok(Some(stream)),
paths::Connected::NobodyListens => return Ok(None),
paths::Connected::NoAnswer => {
say_that_qex_still_waits(started, deadline, "a connection to the socket");
}
}
}
}
fn wait_for_socket(socket: &Path, timeout: Duration) -> Result<UnixStream> {
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(2);
let probe_at = Instant::now() + Duration::from_secs(1);
let mut probed = false;
while Instant::now() < deadline {
if let paths::Connected::Open(stream) = paths::connect_within(socket, delay) {
return Ok(stream);
}
if !probed && Instant::now() >= probe_at {
probed = true;
if let Some(message) = socket_is_refused() {
bail!("{message}");
}
}
std::thread::sleep(delay);
delay = (delay * 2).min(Duration::from_millis(50));
}
let log = paths::daemon_log_path()?;
if let Some(message) = socket_is_refused() {
bail!("{message}");
}
let tail = last_lines_of(&log, 5);
bail!(
"the coordinator did not start in {} seconds.\n\
Its log file is {}{}",
timeout.as_secs(),
log.display(),
tail
)
}
fn socket_is_refused() -> Option<String> {
let socket = paths::socket_path().ok()?;
socket_refused_in(socket.parent()?)
}
fn socket_refused_in(dir: &Path) -> Option<String> {
classify_refusal(dir, probe_a_socket).map(|refusal| refusal.message(dir))
}
enum Refusal {
Socket(std::io::Error),
Directory(std::io::Error),
}
impl Refusal {
fn message(&self, dir: &Path) -> String {
let page = "IF YOU ARE AN AGENT: tell the person that you work with. They must let qex \
run outside the sandbox, or give the sandbox permission for that \
directory. The page for them is:\n\
\x20 https://github.com/stephenc/qex/blob/main/docs/sandbox.md";
match self {
Refusal::Socket(e) => format!(
"qex cannot make a Unix socket in {}: {e}. An ordinary file in that directory \
works, so this is the socket and not the directory.\n\n\
qex keeps one coordinator process, and every qex command talks to it through \
a Unix socket. A sandbox that refuses that socket stops qex completely, and \
that is the usual cause of this message.\n\n{page}",
dir.display()
),
Refusal::Directory(e) => format!(
"qex cannot write in {}: {e}.\n\n\
qex keeps its records, its log files and the socket of its coordinator in \
that directory. A sandbox that gives it a read-only mount, or no mount at \
all, gives this fault.\n\n{page}",
dir.display()
),
}
}
}
fn classify_refusal(dir: &Path, bind: impl Fn(&Path) -> std::io::Result<()>) -> Option<Refusal> {
let name = format!("probe-{}", std::process::id());
let file = dir.join(format!("{name}.tmp"));
if let Err(e) = std::fs::write(&file, b"qex") {
return a_refusal(e).map(Refusal::Directory);
}
std::fs::remove_file(&file).ok();
let socket = dir.join(name);
std::fs::remove_file(&socket).ok();
let answer = bind(&socket);
std::fs::remove_file(&socket).ok();
answer.err().and_then(a_refusal).map(Refusal::Socket)
}
fn a_refusal(error: std::io::Error) -> Option<std::io::Error> {
let refused = matches!(
error.raw_os_error(),
Some(libc::EACCES)
| Some(libc::EPERM)
| Some(libc::EROFS)
| Some(libc::EAFNOSUPPORT)
| Some(libc::EPROTONOSUPPORT)
| Some(libc::EOPNOTSUPP)
| Some(libc::ENOSYS)
);
let refused = refused
|| (error.raw_os_error().is_none()
&& matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported
));
refused.then_some(error)
}
fn probe_a_socket(path: &Path) -> std::io::Result<()> {
let listener = std::os::unix::net::UnixListener::bind(path)?;
let answer = UnixStream::connect(path).map(|_| ());
drop(listener);
answer
}
fn name_the_sandbox(error: anyhow::Error) -> anyhow::Error {
if format!("{error:#}").contains("docs/sandbox.md") {
return error;
}
if !looks_like_a_sandbox(&error) {
return error;
}
anyhow::anyhow!(
"{error:#}\n\n\
qex could not make the files that it needs. A sandbox that gives qex a read-only \
mount, or no permission for that directory, gives this fault.\n\
IF YOU ARE AN AGENT: tell the person that you work with. The page for them is \
https://github.com/stephenc/qex/blob/main/docs/sandbox.md"
)
}
fn looks_like_a_sandbox(error: &anyhow::Error) -> bool {
for cause in error.chain() {
let Some(io) = cause.downcast_ref::<std::io::Error>() else {
continue;
};
if io.raw_os_error() == Some(libc::EROFS) {
return true;
}
if matches!(
io.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported
) {
return true;
}
}
false
}
fn last_lines_of(path: &Path, count: usize) -> String {
let Ok(text) = std::fs::read_to_string(path) else {
return String::new();
};
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.is_empty() {
return String::new();
}
let tail = lines[lines.len().saturating_sub(count)..].join("\n ");
format!(", and it ends with:\n {tail}")
}
fn spawn_daemon() -> Result<()> {
use std::os::unix::process::CommandExt;
let exe = paths::program_path()?;
let log_path = paths::daemon_log_path()?;
paths::ensure_dir(&paths::runtime_dir()?, 0o700)?;
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("opening the log file {}", log_path.display()))?;
let log_err = log.try_clone().context("copying the log file handle")?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("daemon")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(log_err))
.current_dir("/");
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
cmd.spawn().context("starting the coordinator")?;
Ok(())
}
pub struct SpawnLock {
file: std::fs::File,
}
impl SpawnLock {
pub fn acquire() -> Result<Self> {
let dir = paths::runtime_dir()?;
paths::ensure_dir(&dir, 0o700)?;
let path = paths::spawn_lock_path()?;
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening the lock file {}", path.display()))?;
use std::os::unix::io::AsRawFd;
let started = Instant::now();
let deadline = deadline_for_one_wait();
loop {
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
return Ok(Self { file });
}
let error = std::io::Error::last_os_error();
match error.raw_os_error() {
Some(libc::EWOULDBLOCK) | Some(libc::EINTR) => {}
_ => {
return Err(error).with_context(|| format!("locking {}", path.display()));
}
}
say_that_qex_still_waits(started, deadline, "the lock that guards a start");
if Instant::now() >= deadline {
return Err(timed_out(format!(
"a different qex command held the lock {} for {} seconds.\n\
That lock covers the start of a coordinator, so this \
command waited for a start that did not finish.\n\
The kernel gives the lock back when the process that holds \
it stops, so the holder still operates.\n\
Run `qex info --no-start` to see whether a coordinator answers.",
path.display(),
started.elapsed().as_secs().max(1)
)));
}
std::thread::sleep(Duration::from_millis(20));
}
}
}
impl Drop for SpawnLock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(self.file.as_raw_fd(), libc::LOCK_UN);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_connection_that_reached_a_limit_reads_no_later_answer() {
use std::io::Write as _;
let (ours, theirs) = UnixStream::pair().unwrap();
let mut client = Client::with_stream(ours).unwrap();
let soon = Instant::now() + Duration::from_millis(200);
let first = client.call_within(&Request::Info, soon);
assert!(first.is_err(), "the first call must reach the limit");
let late = serde_json::to_string(&Response::Capabilities {
names: vec!["late-answer".to_string()],
})
.unwrap();
let mut writer = theirs;
writeln!(writer, "{late}").unwrap();
writer.flush().unwrap();
let second = client.call_within(&Request::Info, soon);
let Err(e) = second else {
panic!("the second call read an answer that belongs to an earlier request");
};
let said = format!("{e:#}");
assert!(
said.contains("asked the coordinator nothing more"),
"the fault must say what qex did: {said}"
);
assert!(
said.contains("question that you did not ask"),
"the fault must say what it prevented: {said}"
);
}
fn refused(kind: std::io::ErrorKind) -> impl Fn(&Path) -> std::io::Result<()> {
move |_| Err(std::io::Error::new(kind, "refused"))
}
fn writable_dir(name: &str) -> Option<std::path::PathBuf> {
let dir = std::env::temp_dir().join(format!("qx-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok()?;
match std::fs::write(dir.join("control"), b"qex") {
Ok(()) => {
std::fs::remove_file(dir.join("control")).ok();
Some(dir)
}
Err(_) => {
std::fs::remove_dir_all(&dir).ok();
None
}
}
}
#[test]
fn each_refusal_has_its_own_words() {
let socket = Refusal::Socket(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
.message(Path::new("/state/run"));
assert!(socket.contains("Unix socket in /state/run"));
assert!(socket.contains("docs/sandbox.md"));
let directory =
Refusal::Directory(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
.message(Path::new("/state/run"));
assert!(directory.contains("cannot write in /state/run"));
assert!(
!directory.contains("Unix socket in"),
"the message of a directory must not blame the socket: {directory}"
);
assert!(directory.contains("docs/sandbox.md"));
}
#[test]
fn a_socket_that_is_refused_names_the_cause_and_the_page() {
let Some(dir) = writable_dir("sock") else {
return;
};
let answer = classify_refusal(&dir, refused(std::io::ErrorKind::PermissionDenied));
std::fs::remove_dir_all(&dir).ok();
let message = answer
.expect("a socket that is refused must give a message")
.message(Path::new("/state/run"));
assert!(message.contains("Unix socket"), "got: {message}");
assert!(message.contains("docs/sandbox.md"), "got: {message}");
}
#[test]
fn a_socket_that_works_is_not_a_fault() {
let Some(dir) = writable_dir("ok") else {
return;
};
let answer = classify_refusal(&dir, |_| Ok(()));
std::fs::remove_dir_all(&dir).ok();
assert!(answer.is_none(), "a socket that works is not a fault");
}
#[test]
fn a_disk_that_filled_is_not_a_sandbox() {
let full = std::io::Error::from_raw_os_error(libc::ENOSPC);
assert!(a_refusal(full).is_none());
let too_many_files = std::io::Error::from_raw_os_error(libc::EMFILE);
assert!(a_refusal(too_many_files).is_none());
for code in [libc::EACCES, libc::EPERM, libc::EROFS, libc::EAFNOSUPPORT] {
assert!(
a_refusal(std::io::Error::from_raw_os_error(code)).is_some(),
"the code {code} must count as a refusal"
);
}
}
#[test]
fn a_directory_that_qex_cannot_write_is_not_the_socket() {
let Some(parent) = writable_dir("deny") else {
return;
};
let dir = parent.join("locked");
std::fs::create_dir_all(&dir).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let answer = classify_refusal(&dir, |_| Ok(()));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).ok();
std::fs::remove_dir_all(&parent).ok();
if unsafe { libc::geteuid() } == 0 {
return;
}
let message = answer
.expect("a directory that qex cannot write must give a message")
.message(&dir);
assert!(message.contains("cannot write"), "got: {message}");
assert!(!message.contains("Unix socket in"), "got: {message}");
}
#[test]
fn an_ordinary_error_does_not_name_a_sandbox() {
let error = anyhow::anyhow!(std::io::Error::from(std::io::ErrorKind::NotFound));
assert!(!format!("{:#}", name_the_sandbox(error)).contains("docs/sandbox.md"));
let refused = anyhow::anyhow!(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
assert!(format!("{:#}", name_the_sandbox(refused)).contains("docs/sandbox.md"));
}
}