chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -0,0 +1,83 @@
//! WHATWG UTF-8 decoding that matches Node's `new TextDecoder().decode(bytes)` of a whole input when the input arrives
//! in pieces (Durable `env/decode.ts`): invalid sequences become U+FFFD by the maximal-subpart rule, and only a
//! byte-order mark at the very start is dropped.
use encoding_rs::{Decoder, UTF_8};
/// Decodes one stream chunk by chunk, dropping a leading byte-order mark.
pub struct StreamDecoder {
decoder: Decoder,
}
impl StreamDecoder {
pub fn new() -> StreamDecoder {
// Removes a UTF-8 BOM only; plain `new_decoder()` would also sniff UTF-16 BOMs and switch encodings.
StreamDecoder {
decoder: UTF_8.new_decoder_with_bom_removal(),
}
}
/// Text for `bytes`, holding back an incomplete character; with `last`, the end of the stream.
pub fn decode(&mut self, bytes: &[u8], last: bool) -> String {
decode_into(&mut self.decoder, bytes, last)
}
}
/// A decoder for a byte range that does not start at the beginning of its input: no BOM handling at all.
pub fn range_decoder() -> Decoder {
UTF_8.new_decoder_without_bom_handling()
}
pub fn decode_into(decoder: &mut Decoder, bytes: &[u8], last: bool) -> String {
let capacity = decoder
.max_utf8_buffer_length(bytes.len())
.unwrap_or(bytes.len() * 3 + 16);
let mut text = String::with_capacity(capacity);
let (_result, read, _replaced) = decoder.decode_to_string(bytes, &mut text, last);
debug_assert_eq!(read, bytes.len());
text
}
/// Whether decoding the whole input drops its first three bytes as a byte-order mark.
pub fn starts_with_bom(bytes: &[u8]) -> bool {
bytes.starts_with(&[0xef, 0xbb, 0xbf])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_a_feff_that_does_not_start_the_stream() {
let mut decoder = StreamDecoder::new();
let bytes = [0xe2, 0x82, 0xef, 0xbb, 0xbf, 0x61];
let text: String = bytes
.iter()
.map(|byte| decoder.decode(&[*byte], false))
.collect::<String>()
+ &decoder.decode(&[], true);
assert_eq!(text, "\u{fffd}\u{feff}a");
}
#[test]
fn drops_only_a_leading_mark_and_never_sniffs_utf16() {
let mut decoder = StreamDecoder::new();
assert_eq!(
decoder.decode(&[0xef, 0xbb], false) + &decoder.decode(&[0xbf, 0x61], true),
"a"
);
let mut decoder = StreamDecoder::new();
assert_eq!(
decoder.decode(&[0xff, 0xfe, 0x41, 0x00], true),
"\u{fffd}\u{fffd}A\u{0}"
);
}
#[test]
fn replaces_an_incomplete_sequence_once() {
let mut decoder = StreamDecoder::new();
assert_eq!(decoder.decode(&[0xe2, 0x82, 0x41], true), "\u{fffd}A");
let mut decoder = StreamDecoder::new();
assert_eq!(decoder.decode(&[0xe2, 0x82], true), "\u{fffd}");
}
}

View File

@@ -0,0 +1,126 @@
//! Errors as Node reports them: a code such as `ENOENT` plus a diagnostic message.
use serde_json::{Value, json};
use std::io;
/// A failure with a Node-style code; messages are diagnostic only.
#[derive(Debug)]
pub struct Failure {
pub code: String,
pub message: String,
details: Option<Box<Details>>,
}
#[derive(Debug, Default)]
struct Details {
syscall: Option<String>,
path: Option<String>,
/// Extra fields for the client, such as `spillPath`.
extra: Option<Value>,
}
impl Failure {
pub fn new(code: &str, message: impl Into<String>) -> Failure {
Failure {
code: code.to_string(),
message: message.into(),
details: None,
}
}
fn details(&mut self) -> &mut Details {
self.details.get_or_insert_with(Default::default)
}
/// Add fields to the error JSON, such as `spillPath`.
pub fn extra(mut self, extra: Value) -> Failure {
self.details().extra = Some(extra);
self
}
/// An I/O error of `syscall` on `path`, with libuv's error name as code.
pub fn io(error: &io::Error, syscall: &str, path: &str) -> Failure {
Failure::io_between(error, syscall, path, None)
}
/// Node's message for an error of a two-path call: `CODE: description, syscall 'from' -> 'to'`.
pub fn io_between(
error: &io::Error,
syscall: &str,
path: &str,
destination: Option<&str>,
) -> Failure {
let code = crate::sys::error_name(error).to_string();
let description = describe(&code).map(str::to_string).unwrap_or_else(|| {
// Unknown to libuv's table: the OS text without Rust's " (os error N)".
let text = error.to_string();
text.split(" (os error")
.next()
.unwrap_or(&text)
.to_lowercase()
});
let target = match destination {
Some(destination) => format!("'{path}' -> '{destination}'"),
None => format!("'{path}'"),
};
let mut failure = Failure::new(&code, format!("{code}: {description}, {syscall} {target}"));
failure.details().syscall = Some(syscall.to_string());
failure.path(path)
}
pub fn path(mut self, path: &str) -> Failure {
self.details().path = Some(path.to_string());
self
}
pub fn to_json(&self) -> Value {
let mut value = json!({ "code": self.code, "message": self.message });
let Some(details) = &self.details else {
return value;
};
if let Some(syscall) = &details.syscall {
value["syscall"] = json!(syscall);
}
if let Some(path) = &details.path {
value["path"] = json!(path);
}
if let Some(Value::Object(extra)) = &details.extra {
for (key, field) in extra {
value[key] = field.clone();
}
}
value
}
}
/// libuv's `uv_strerror` texts, which Node uses on every system.
fn describe(code: &str) -> Option<&'static str> {
Some(match code {
"EACCES" => "permission denied",
"EAGAIN" => "resource temporarily unavailable",
"EBADF" => "bad file descriptor",
"EBUSY" => "resource busy or locked",
"EEXIST" => "file already exists",
"EFBIG" => "file too large",
"EINVAL" => "invalid argument",
"EIO" => "i/o error",
"EISDIR" => "illegal operation on a directory",
"ELOOP" => "too many symbolic links encountered",
"EMFILE" => "too many open files",
"EMLINK" => "too many links",
"ENAMETOOLONG" => "name too long",
"ENFILE" => "file table overflow",
"ENOENT" => "no such file or directory",
"ENOMEM" => "not enough memory",
"ENOSPC" => "no space left on device",
"ENOSYS" => "function not implemented",
"ENOTDIR" => "not a directory",
"ENOTEMPTY" => "directory not empty",
"ENOTSUP" => "operation not supported on socket",
"EPERM" => "operation not permitted",
"EROFS" => "read-only file system",
"ETXTBSY" => "text file is busy",
"EXDEV" => "cross-device link not permitted",
_ => return None,
})
}

View File

@@ -0,0 +1,483 @@
//! `exec`: Durable's `NodeExecutionEnv.exec` semantics (docs/semantics.md), output streamed as `output` events.
use crate::decode::StreamDecoder;
use crate::errors::Failure;
use crate::frame::{EVENT, Frame};
use crate::output::Output;
use crate::sys;
use crate::window::{Pending, Window};
use serde_json::{Map, Value, json};
use std::collections::HashSet;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const EXIT_STDIO_GRACE: Duration = Duration::from_millis(100);
/// How often a delivery waiting for the link to drain looks again.
const UNSENT_POLL: Duration = Duration::from_millis(20);
/// Commands this daemon started and has not reaped; killed when the connection ends.
pub type Groups = Arc<Mutex<HashSet<u32>>>;
pub enum Message {
Data(usize, Vec<u8>),
Eof(usize),
Exit(ExitStatus),
WaitFailed(String),
/// Abort: the command settles as `aborted`.
Cancel,
/// Kill without aborting, as `cleanup()` does: the command settles with the killed process's status.
Kill,
}
/// Cancellation of one request, registered before the request runs so a cancel that arrives first is not lost.
#[derive(Default)]
pub struct Control {
pub aborted: AtomicBool,
pub killed: AtomicBool,
sender: Mutex<Option<Sender<Message>>>,
}
impl Control {
pub fn cancel(&self, kill: bool) {
let sender = self.sender.lock().unwrap();
if kill {
self.killed.store(true, Ordering::SeqCst);
} else {
self.aborted.store(true, Ordering::SeqCst);
}
if let Some(sender) = sender.as_ref() {
let _ = sender.send(if kill { Message::Kill } else { Message::Cancel });
}
}
pub fn attach(&self, sender: Sender<Message>) {
*self.sender.lock().unwrap() = Some(sender);
}
}
pub struct ExecRequest {
pub command: Option<String>,
pub argv: Option<Vec<String>>,
pub cwd: String,
pub env: Map<String, Value>,
pub inherit_env: bool,
pub shell_path: Option<String>,
pub timeout: Option<Duration>,
pub spill: Option<(u64, u64)>,
pub window: Option<Window>,
}
impl ExecRequest {
pub fn from_json(json: &Value) -> Result<ExecRequest, Failure> {
let strings = |value: &Value| -> Option<Vec<String>> {
value
.as_array()?
.iter()
.map(|item| item.as_str().map(str::to_string))
.collect()
};
Ok(ExecRequest {
command: json["command"].as_str().map(str::to_string),
argv: strings(&json["argv"]),
cwd: json["cwd"]
.as_str()
.ok_or_else(|| Failure::new("EINVAL", "exec needs cwd"))?
.to_string(),
env: json["env"].as_object().cloned().unwrap_or_default(),
inherit_env: json["inheritEnv"].as_bool().unwrap_or(true),
shell_path: json["shellPath"].as_str().map(str::to_string),
timeout: json["timeoutMs"]
.as_f64()
.map(|ms| Duration::from_secs_f64(ms / 1000.0)),
spill: json["spill"].as_object().and_then(|spill| {
Some((
spill.get("afterBytes")?.as_u64()?,
spill.get("afterLines")?.as_u64()?,
))
}),
window: Window::from_json(&json["window"]),
})
}
}
pub fn kill_group(pid: u32) {
sys::kill_tree(pid);
}
fn random_uuid() -> String {
let mut bytes = sys::random_bytes();
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
/// Spill of the complete raw output, started once it crosses either threshold, like Node's.
struct Spill {
after_bytes: u64,
after_lines: u64,
seen_bytes: u64,
seen_newlines: u64,
prefix: Vec<Vec<u8>>,
file: Option<File>,
path: Option<String>,
failed: Option<String>,
}
impl Spill {
fn push(&mut self, chunk: &[u8], tmpdir: &str) {
if self.failed.is_some() || chunk.is_empty() {
return;
}
if self.file.is_none() {
self.seen_bytes += chunk.len() as u64;
self.seen_newlines += chunk.iter().filter(|byte| **byte == b'\n').count() as u64;
let lines = self.seen_newlines + u64::from(chunk.last() != Some(&b'\n'));
if self.seen_bytes <= self.after_bytes && lines <= self.after_lines {
self.prefix.push(chunk.to_vec());
return;
}
if let Err(error) = self.start(tmpdir) {
self.failed = Some(error);
return;
}
}
if let Some(file) = &mut self.file
&& let Err(error) = file.write_all(chunk)
{
self.failed = Some(error.to_string());
}
}
/// `createTempFile({ prefix: "pi-output-", suffix: ".log" })`: a fresh `tmp-` directory holding the file.
fn start(&mut self, tmpdir: &str) -> Result<(), String> {
let prefix = Path::new(tmpdir)
.join("tmp-")
.to_string_lossy()
.into_owned();
let directory = sys::mkdtemp(&prefix).map_err(|error| error.to_string())?;
let path = Path::new(&directory)
.join(format!("pi-output-{}.log", random_uuid()))
.to_string_lossy()
.into_owned();
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(&path)
.map_err(|error| error.to_string())?;
for chunk in self.prefix.drain(..) {
file.write_all(&chunk).map_err(|error| error.to_string())?;
}
self.file = Some(file);
self.path = Some(path);
Ok(())
}
}
fn spawn(
request: &ExecRequest,
program: &str,
args: &[String],
piped_stdin: bool,
) -> std::io::Result<Child> {
let mut command = Command::new(program);
command
.current_dir(&request.cwd)
.stdin(if piped_stdin {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
sys::configure(&mut command, args, &request.env, request.inherit_env);
let child = command.spawn()?;
sys::after_spawn(&child);
Ok(child)
}
/// Read one pipe on its own thread. Without an output window, reading waits while too much output is unsent.
fn spawn_reader(
stream: usize,
pipe: impl sys::Pipe + Send + 'static,
sender: Sender<Message>,
stop: Arc<AtomicBool>,
output: Option<Arc<Output>>,
) -> JoinHandle<()> {
thread::spawn(move || {
sys::read_pipe(pipe, &stop, |bytes| {
let delivered = sender.send(Message::Data(stream, bytes.to_vec())).is_ok();
if let Some(output) = &output {
output.wait_for_room(&stop);
}
delivered
});
let _ = sender.send(Message::Eof(stream));
})
}
fn output_frame(id: u32, stream: &str, text: String, skipped: Option<Value>) -> Frame {
let mut json = json!({ "kind": "output", "stream": stream });
if let Some(skipped) = skipped {
json["skipped"] = skipped;
}
Frame::with_payload(EVENT, id, json, text.into_bytes())
}
/// Run one command, sending output events for request `id`, until it settles.
pub fn run(
id: u32,
request: ExecRequest,
tmpdir: &str,
output: &Arc<Output>,
control: &Control,
groups: &Groups,
) -> Result<Value, Failure> {
let (sender, receiver) = mpsc::channel();
control.attach(sender.clone());
if control.aborted.load(Ordering::SeqCst) {
return Err(Failure::new("aborted", "aborted"));
}
// A string runs through the shell (as its last argument, or on stdin for legacy WSL bash); argv runs directly.
let (program, args, stdin_command) = match (&request.command, &request.argv) {
(Some(command), _) => {
let shell = sys::shell_config(request.shell_path.as_deref())?;
let mut args = shell.args;
if shell.command_on_stdin {
(shell.program, args, Some(command.clone()))
} else {
args.push(command.clone());
(shell.program, args, None)
}
}
(None, Some(argv)) => match argv.split_first() {
Some((first, rest)) => (
sys::resolve_program(first, &request.cwd, &request.env)?,
rest.to_vec(),
None,
),
None => return Err(Failure::new("spawn_error", "Empty argv: no program to run")),
},
(None, None) => return Err(Failure::new("EINVAL", "exec needs command or argv")),
};
if !Path::new(&request.cwd).exists() {
return Err(Failure::new(
"spawn_error",
format!(
"Working directory does not exist: {}\nCannot execute bash commands.",
request.cwd
),
));
}
let mut child = spawn(&request, &program, &args, stdin_command.is_some())
.map_err(|error| Failure::new("spawn_error", error.to_string()))?;
if let (Some(command), Some(mut stdin)) = (stdin_command, child.stdin.take()) {
let _ = stdin.write_all(command.as_bytes());
}
let pid = child.id();
groups.lock().unwrap().insert(pid);
let deadline = request.timeout.map(|timeout| Instant::now() + timeout);
let stop = Arc::new(AtomicBool::new(false));
// Without a window, output is not coalesced; reading then waits for the link.
let gate = if request.window.is_none() {
Some(output.clone())
} else {
None
};
let readers = [
spawn_reader(
0,
child.stdout.take().unwrap(),
sender.clone(),
stop.clone(),
gate.clone(),
),
spawn_reader(
1,
child.stderr.take().unwrap(),
sender.clone(),
stop.clone(),
gate,
),
];
let waiter = sender.clone();
thread::spawn(move || {
let _ = waiter.send(match child.wait() {
Ok(status) => Message::Exit(status),
Err(error) => Message::WaitFailed(error.to_string()),
});
});
drop(sender);
let mut decoders = [StreamDecoder::new(), StreamDecoder::new()];
let mut spill = request.spill.map(|(after_bytes, after_lines)| Spill {
after_bytes,
after_lines,
seen_bytes: 0,
seen_newlines: 0,
prefix: Vec::new(),
file: None,
path: None,
failed: None,
});
let mut pending = request.window.map(Pending::new);
let unsent = Arc::new(AtomicUsize::new(0));
let names = ["stdout", "stderr"];
let emit = |pending: &mut Option<Pending>, stream: usize, text: String| {
if text.is_empty() {
return;
}
match pending {
Some(pending) => pending.push(names[stream], text),
None => output.bulk(output_frame(id, names[stream], text, None), None),
}
};
let deliver = |pending: &mut Pending| {
for event in pending.take() {
output.bulk(
output_frame(id, event.stream, event.text, event.skipped),
Some(unsent.clone()),
);
}
};
let mut ended = [false, false];
let mut status: Option<Result<ExitStatus, String>> = None;
let mut idle_until: Option<Instant> = None;
let mut timed_out = false;
let mut aborted = false;
let mut killed = false;
let kill = |killed: &mut bool| {
if !*killed {
*killed = true;
kill_group(pid);
}
};
if control.killed.load(Ordering::SeqCst) {
kill(&mut killed);
}
loop {
let now = Instant::now();
// Node's timer stays armed until the command settles, including the grace period after exit.
if !timed_out && deadline.is_some_and(|deadline| now >= deadline) {
timed_out = true;
kill(&mut killed);
}
if status.is_some() && ended[0] && ended[1] {
break;
}
if idle_until.is_some_and(|idle| now >= idle) {
// A descendant may hold the pipes open after the process exited; stop waiting for it.
break;
}
if let Some(pending) = &mut pending
&& !pending.is_empty()
&& now >= pending.next_send()
&& unsent.load(Ordering::SeqCst) == 0
{
deliver(pending);
}
let mut wait = Duration::from_secs(3600);
if let Some(deadline) = deadline.filter(|_| !timed_out) {
wait = wait.min(deadline.saturating_duration_since(now));
}
if let Some(idle) = idle_until {
wait = wait.min(idle.saturating_duration_since(now));
}
if let Some(pending) = pending.as_ref().filter(|pending| !pending.is_empty()) {
wait = wait.min(if unsent.load(Ordering::SeqCst) > 0 {
UNSENT_POLL
} else {
pending.next_send().saturating_duration_since(now)
});
}
match receiver.recv_timeout(wait) {
Ok(Message::Data(stream, bytes)) => {
emit(&mut pending, stream, decoders[stream].decode(&bytes, false));
if let Some(spill) = &mut spill {
spill.push(&bytes, tmpdir);
if spill.failed.is_some() {
kill(&mut killed);
}
}
if status.is_some() {
idle_until = Some(Instant::now() + EXIT_STDIO_GRACE);
}
}
Ok(Message::Eof(stream)) => ended[stream] = true,
Ok(Message::Exit(exit)) => {
status = Some(Ok(exit));
idle_until = Some(Instant::now() + EXIT_STDIO_GRACE);
}
Ok(Message::WaitFailed(error)) => {
status = Some(Err(error));
idle_until = Some(Instant::now());
}
Ok(Message::Cancel) => {
aborted = true;
kill(&mut killed);
}
Ok(Message::Kill) => kill(&mut killed),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
}
// Like Node destroying its streams: stop reading, so the pipes close even if a descendant still holds them.
stop.store(true, Ordering::SeqCst);
for reader in &readers {
sys::interrupt_reader(reader);
}
groups.lock().unwrap().remove(&pid);
emit(&mut pending, 0, decoders[0].decode(&[], true));
emit(&mut pending, 1, decoders[1].decode(&[], true));
if let Some(pending) = &mut pending
&& !pending.is_empty()
{
deliver(pending);
}
let spill_path = spill.as_ref().and_then(|spill| spill.path.clone());
let with_spill = |failure: Failure| match &spill_path {
Some(path) => failure.extra(json!({ "spillPath": path })),
None => failure,
};
if timed_out {
return Err(with_spill(Failure::new("timeout", "timeout")));
}
if aborted {
return Err(with_spill(Failure::new("aborted", "aborted")));
}
if let Some(error) = spill.as_ref().and_then(|spill| spill.failed.clone()) {
return Err(Failure::new(
"unknown",
format!("Failed to preserve complete shell output: {error}"),
));
}
let status = match status {
Some(Ok(status)) => status,
Some(Err(error)) => return Err(Failure::new("spawn_error", error)),
None => {
return Err(Failure::new(
"spawn_error",
"process did not report its exit",
));
}
};
let mut result = json!({ "exitCode": sys::exit_code(status) });
if let Some(path) = spill_path {
result["spillPath"] = json!(path);
}
Ok(result)
}

View File

@@ -0,0 +1,120 @@
//! Length-prefixed frames: `u32 length | u8 type | u32 id | u32 jsonLength | json | payload` (docs/protocol.md).
use serde_json::Value;
use std::io::{self, Read, Write};
pub const REQUEST: u8 = 1;
pub const RESULT: u8 = 2;
pub const ERROR: u8 = 3;
pub const EVENT: u8 = 4;
pub const CANCEL: u8 = 5;
pub const PING: u8 = 6;
/// Largest frame either side accepts.
pub const MAX_FRAME: usize = 16 * 1024 * 1024;
/// Largest payload a response carries, leaving room for its JSON.
pub const MAX_PAYLOAD: usize = MAX_FRAME - 64 * 1024;
pub struct Frame {
pub kind: u8,
pub id: u32,
pub json: Value,
pub payload: Vec<u8>,
}
impl Frame {
pub fn new(kind: u8, id: u32, json: Value) -> Frame {
Frame {
kind,
id,
json,
payload: Vec::new(),
}
}
pub fn with_payload(kind: u8, id: u32, json: Value, payload: Vec<u8>) -> Frame {
Frame {
kind,
id,
json,
payload,
}
}
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.to_string())
}
/// Read one frame; `None` at a clean end of input.
pub fn read_frame(input: &mut impl Read) -> io::Result<Option<Frame>> {
let mut length = [0u8; 4];
match input.read_exact(&mut length) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(error) => return Err(error),
}
let length = u32::from_be_bytes(length) as usize;
if !(9..=MAX_FRAME).contains(&length) {
return Err(invalid("frame length out of range"));
}
let mut body = vec![0u8; length];
input.read_exact(&mut body)?;
let kind = body[0];
let id = u32::from_be_bytes([body[1], body[2], body[3], body[4]]);
let json_length = u32::from_be_bytes([body[5], body[6], body[7], body[8]]) as usize;
if 9 + json_length > length {
return Err(invalid("JSON length out of range"));
}
// Unparseable JSON leaves the framing intact: the frame arrives with `Value::Null` and gets an error reply.
let json = if json_length == 0 {
Value::Object(Default::default())
} else {
serde_json::from_slice(&body[9..9 + json_length]).unwrap_or(Value::Null)
};
let payload = body[9 + json_length..].to_vec();
Ok(Some(Frame {
kind,
id,
json,
payload,
}))
}
pub fn write_frame(output: &mut impl Write, frame: &Frame) -> io::Result<()> {
let json = serde_json::to_vec(&frame.json).map_err(|_| invalid("unserializable JSON"))?;
let length = 9 + json.len() + frame.payload.len();
if length > MAX_FRAME {
return Err(invalid("frame too large"));
}
let mut header = Vec::with_capacity(13);
header.extend_from_slice(&(length as u32).to_be_bytes());
header.push(frame.kind);
header.extend_from_slice(&frame.id.to_be_bytes());
header.extend_from_slice(&(json.len() as u32).to_be_bytes());
output.write_all(&header)?;
output.write_all(&json)?;
output.write_all(&frame.payload)?;
output.flush()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn round_trips_json_and_payload() {
let mut buffer = Vec::new();
write_frame(
&mut buffer,
&Frame::with_payload(RESULT, 7, json!({ "a": 1 }), vec![0, 255, 10]),
)
.unwrap();
let frame = read_frame(&mut buffer.as_slice()).unwrap().unwrap();
assert_eq!((frame.kind, frame.id), (RESULT, 7));
assert_eq!(frame.json, json!({ "a": 1 }));
assert_eq!(frame.payload, vec![0, 255, 10]);
assert!(read_frame(&mut &[][..]).unwrap().is_none());
}
}

View File

@@ -0,0 +1,258 @@
//! File operations with Node's semantics where the Rust standard library differs (docs/semantics.md).
use crate::errors::Failure;
use crate::sys;
use serde_json::{Value, json};
use std::ffi::OsStr;
use std::fs::{self, File, Metadata};
use std::io::{self, Read, Write};
use std::path::Path;
pub type Outcome<T> = Result<T, Failure>;
/// `{ name, kind, size, mtimeSec, mtimeNsec, dev, ino }` of lstat or fstat metadata.
pub fn info(path: &str, metadata: &Metadata) -> Value {
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
"file"
} else if file_type.is_dir() {
"directory"
} else if file_type.is_symlink() {
"symlink"
} else {
"other"
};
let name = Path::new(path)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let (mtime_sec, mtime_nsec) = sys::mtime(metadata);
let (dev, ino) = sys::identity(metadata);
json!({
"name": name,
"kind": kind,
"size": metadata.len(),
"mtimeSec": mtime_sec,
"mtimeNsec": mtime_nsec,
"dev": dev,
"ino": ino,
})
}
pub fn lstat(path: &str) -> Outcome<Value> {
fs::symlink_metadata(path)
.map(|metadata| info(path, &metadata))
.map_err(|error| Failure::io(&error, "lstat", path))
}
pub fn realpath(path: &str) -> Outcome<Value> {
sys::realpath(path)
.map(|resolved| json!({ "path": resolved }))
.map_err(|error| Failure::io(&error, "realpath", path))
}
/// Node's recursive `mkdir`: an existing directory is fine, an existing file is `EEXIST`, a file in the way of a
/// parent is `ENOTDIR`.
pub fn mkdir_recursive(path: &Path) -> io::Result<()> {
let is_dir = |path: &Path| {
fs::metadata(path)
.map(|metadata| metadata.is_dir())
.unwrap_or(false)
};
match fs::create_dir(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
if is_dir(path) {
Ok(())
} else {
Err(error)
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return Err(error);
};
match mkdir_recursive(parent) {
Err(parent_error) if parent_error.kind() == io::ErrorKind::AlreadyExists => {
return Err(sys::synthetic("ENOTDIR"));
}
other => other?,
}
match fs::create_dir(path) {
Err(error) if error.kind() == io::ErrorKind::AlreadyExists && is_dir(path) => {
Ok(())
}
other => other,
}
}
Err(error) => Err(error),
}
}
pub fn mkdir(path: &str, recursive: bool) -> Outcome<Value> {
let result = if recursive {
mkdir_recursive(Path::new(path))
} else {
fs::create_dir(path)
};
result
.map(|()| json!({}))
.map_err(|error| Failure::io(&error, "mkdir", path))
}
/// `writeFile`/`appendFile`: create missing parents like Node's recursive `mkdir`, then write. Returns the open file
/// for further chunks.
pub fn write(path: &str, append: bool, parents: bool, content: &[u8]) -> Outcome<File> {
if let Some(parent) = Path::new(path)
.parent()
.filter(|_| parents)
.filter(|parent| !parent.as_os_str().is_empty())
{
mkdir_recursive(parent)
.map_err(|error| Failure::io(&error, "mkdir", &parent.to_string_lossy()))?;
}
let mode = if append {
sys::OpenMode::Append
} else {
sys::OpenMode::Write
};
let mut file = sys::open(path, mode).map_err(|error| Failure::io(&error, "open", path))?;
file.write_all(content)
.map_err(|error| Failure::io(&error, "write", path))?;
Ok(file)
}
fn open_existing_for_write(path: &str) -> Outcome<File> {
sys::open(path, sys::OpenMode::ReadWrite).map_err(|error| Failure::io(&error, "open", path))
}
pub fn truncate(path: &str, size: u64) -> Outcome<Value> {
let file = open_existing_for_write(path)?;
file.set_len(size)
.map_err(|error| Failure::io(&error, "ftruncate", path))?;
Ok(json!({}))
}
pub fn fsync(path: &str) -> Outcome<Value> {
// POSIX refuses to open a directory for writing; check first so Windows reports the same.
if fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
return Err(Failure::new(
"EISDIR",
format!("EISDIR: illegal operation on a directory, open '{path}'"),
)
.path(path));
}
let file = open_existing_for_write(path)?;
file.sync_all()
.map_err(|error| Failure::io(&error, "fsync", path))?;
Ok(json!({}))
}
pub fn rename(from: &str, to: &str) -> Outcome<Value> {
sys::rename(from, to)
.map(|()| json!({}))
.map_err(|error| Failure::io_between(&error, "rename", from, Some(to)))
}
/// Node's `fs.rm`: missing paths fail unless `force`; a directory without `recursive` fails with `ERR_FS_EISDIR`.
pub fn rm(path: &str, recursive: bool, force: bool) -> Outcome<Value> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if force && error.kind() == io::ErrorKind::NotFound => return Ok(json!({})),
Err(error) => return Err(Failure::io(&error, "lstat", path)),
};
let result = if metadata.is_dir() {
if !recursive {
return Err(Failure::new(
"ERR_FS_EISDIR",
format!("Path is a directory: rm returned EISDIR (is a directory) {path}"),
)
.path(path));
}
fs::remove_dir_all(path)
} else {
sys::remove_file(path)
};
result
.map(|()| json!({}))
.map_err(|error| Failure::io(&error, "rm", path))
}
/// A new directory named `prefix` plus six random characters, as libuv's `mkdtemp` creates it.
pub fn mkdtemp(prefix: &str) -> Outcome<Value> {
sys::mkdtemp(prefix)
.map(|path| json!({ "path": path }))
.map_err(|error| Failure::io(&error, "mkdtemp", &format!("{prefix}XXXXXX")))
}
/// Open a regular file for positional reads; directories fail with `EISDIR`, other non-regular files with
/// `NOT_REGULAR`, and with `no_follow` a final symbolic link with `SYMLINK`.
pub fn open_reader(path: &str, no_follow: bool) -> Outcome<(File, Value)> {
let file = sys::open_reader(path, no_follow)?;
let metadata = file
.metadata()
.map_err(|error| Failure::io(&error, "fstat", path))?;
if metadata.is_dir() {
return Err(
Failure::new("EISDIR", "EISDIR: illegal operation on a directory, read").path(path),
);
}
if !metadata.is_file() {
return Err(Failure::new("NOT_REGULAR", "Not a regular file").path(path));
}
let info = info(path, &metadata);
Ok((file, info))
}
/// Up to `length` bytes at `offset`; fewer only at the end of the file.
pub fn pread(file: &File, path: &str, offset: u64, length: usize) -> Outcome<Vec<u8>> {
let mut buffer = vec![0u8; length];
let mut filled = 0;
while filled < length {
match sys::read_at(file, &mut buffer[filled..], offset + filled as u64) {
Ok(0) => break,
Ok(read) => filled += read,
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(Failure::io(&error, "read", path)),
}
}
buffer.truncate(filled);
Ok(buffer)
}
/// Up to `length` bytes from the current position: one `read` call, as Node's `readFile` makes for files of unknown
/// size (FIFOs, devices).
pub fn read(mut file: &File, path: &str, length: usize) -> Outcome<Vec<u8>> {
let mut buffer = vec![0u8; length];
loop {
match file.read(&mut buffer) {
Ok(read) => {
buffer.truncate(read);
return Ok(buffer);
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(Failure::io(&error, "read", path)),
}
}
}
/// One directory entry as Node lists it: the name decoded as UTF-8 (invalid bytes replaced), and lstat of the
/// directory joined with that decoded name, so a name that is not valid UTF-8 reports `ENOENT` as in Node. `raw`
/// carries the original bytes of such a name, which libuv sorts by.
pub fn dir_entry(dir: &str, file_name: &OsStr) -> Value {
let name = file_name.to_string_lossy().into_owned();
let entry_path = Path::new(dir).join(&name).to_string_lossy().into_owned();
let mut entry = match fs::symlink_metadata(&entry_path) {
Ok(metadata) => json!({ "name": name, "info": info(&entry_path, &metadata) }),
Err(error) => {
json!({ "name": name, "error": Failure::io(&error, "lstat", &entry_path).to_json() })
}
};
if let Some(raw) = sys::raw_name(file_name).filter(|raw| *raw != name.as_bytes()) {
entry["raw"] = json!(raw);
}
entry
}

View File

@@ -0,0 +1,368 @@
//! `listFiles`: the files and directories below a directory, as `fd --hidden --exclude .git` lists them (without
//! following symbolic links), skipping what git ignores. Uses the `ignore` crate, which fd and ripgrep use.
use crate::errors::Failure;
use crate::frame::MAX_PAYLOAD;
use ignore::WalkBuilder;
use serde_json::{Value, json};
use std::ffi::OsString;
use std::fs;
use std::path::{Component, Path};
use std::time::{Duration, Instant};
const DEFAULT_MAX_ENTRIES: u64 = 50_000;
const DEFAULT_MAX_BYTES: u64 = 4 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
pub struct Options {
/// Include names starting with `.` (and, on Windows, hidden files).
pub hidden: bool,
/// Skip what git ignores inside git work trees: `.gitignore` files, `.git/info/exclude`, the global excludes file.
pub gitignore: bool,
/// Names skipped with everything below them, in addition to `.git`.
pub exclude: Vec<OsString>,
pub max_entries: u64,
/// Bytes of the listing payload: per entry one kind byte, the path, and a NUL.
pub max_bytes: u64,
pub timeout: Duration,
}
impl Options {
pub fn from_json(json: &Value) -> Result<Options, Failure> {
let count = |key: &str, default: u64| -> Result<u64, Failure> {
match &json[key] {
Value::Null => Ok(default),
value => value
.as_u64()
.ok_or_else(|| Failure::new("EINVAL", format!("invalid field {key}"))),
}
};
let exclude = match &json["exclude"] {
Value::Null => Vec::new(),
Value::Array(names) => names
.iter()
.map(|name| name.as_str().map(OsString::from))
.collect::<Option<Vec<_>>>()
.ok_or_else(|| Failure::new("EINVAL", "invalid field exclude"))?,
_ => return Err(Failure::new("EINVAL", "invalid field exclude")),
};
Ok(Options {
hidden: json["hidden"].as_bool().unwrap_or(true),
gitignore: json["gitignore"].as_bool().unwrap_or(true),
exclude,
max_entries: count("maxEntries", DEFAULT_MAX_ENTRIES)?,
max_bytes: count("maxBytes", DEFAULT_MAX_BYTES)?.min(MAX_PAYLOAD as u64),
timeout: Duration::from_millis(count("timeoutMs", DEFAULT_TIMEOUT_MS)?),
})
}
}
pub struct Listing {
/// Records of a kind byte (`f`, `d`, `l`), the path relative to the root with `/` separators, and a NUL.
pub payload: Vec<u8>,
pub count: u64,
pub truncated: bool,
}
/// The path of `path` below `root`, its components joined with `/`; names that are not UTF-8 get replacement
/// characters, as `readdir` reports them.
fn relative(root: &Path, path: &Path) -> Option<String> {
let rest = path.strip_prefix(root).ok()?;
let mut text = String::new();
for component in rest.components() {
let Component::Normal(name) = component else {
return None;
};
if !text.is_empty() {
text.push('/');
}
text.push_str(&name.to_string_lossy());
}
(!text.is_empty()).then_some(text)
}
/// List `root` depth first, siblings in byte order of their names. Stops with `truncated` at the first entry beyond
/// a bound; fails with `aborted` once `aborted()` is true. Entries that cannot be read are skipped.
pub fn list(root: &str, options: &Options, aborted: &dyn Fn() -> bool) -> Result<Listing, Failure> {
let metadata = fs::metadata(root).map_err(|error| Failure::io(&error, "scandir", root))?;
if !metadata.is_dir() {
return Err(Failure::new(
"ENOTDIR",
format!("ENOTDIR: not a directory, scandir '{root}'"),
)
.path(root));
}
let root_path = Path::new(root);
let exclude = options.exclude.clone();
let walk = WalkBuilder::new(root_path)
.hidden(!options.hidden)
.ignore(false)
.parents(options.gitignore)
.git_ignore(options.gitignore)
.git_exclude(options.gitignore)
.git_global(options.gitignore)
.require_git(true)
.follow_links(false)
// The global excludes file is found relative to the listed directory, not the daemon's.
.current_dir(root_path)
.sort_by_file_name(|a, b| a.cmp(b))
.filter_entry(move |entry| {
let name = entry.file_name();
name != ".git" && !exclude.iter().any(|excluded| excluded == name)
})
.build();
let started = Instant::now();
let mut listing = Listing {
payload: Vec::new(),
count: 0,
truncated: false,
};
for entry in walk {
if aborted() {
return Err(Failure::new("aborted", "aborted"));
}
let Ok(entry) = entry else { continue };
if entry.depth() == 0 {
continue;
}
let Some(file_type) = entry.file_type() else {
continue;
};
let kind = if file_type.is_symlink() {
b'l'
} else if file_type.is_dir() {
b'd'
} else if file_type.is_file() {
b'f'
} else {
continue;
};
let Some(path) = relative(root_path, entry.path()) else {
continue;
};
let size = path.len() as u64 + 2;
if listing.count >= options.max_entries
|| listing.payload.len() as u64 + size > options.max_bytes
|| started.elapsed() > options.timeout
{
listing.truncated = true;
break;
}
listing.payload.push(kind);
listing.payload.extend_from_slice(path.as_bytes());
listing.payload.push(0);
listing.count += 1;
}
Ok(listing)
}
pub fn run(json: &Value, aborted: &dyn Fn() -> bool) -> Result<(Value, Vec<u8>), Failure> {
let root = json["path"]
.as_str()
.ok_or_else(|| Failure::new("EINVAL", "missing field path"))?;
let listing = list(root, &Options::from_json(json)?, aborted)?;
Ok((
json!({ "count": listing.count, "truncated": listing.truncated }),
listing.payload,
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct Tree(std::path::PathBuf);
impl Tree {
fn new(files: &[&str]) -> Tree {
static NEXT: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"pi-env-list-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let tree = Tree(root);
for file in files {
tree.write(file, "");
}
tree
}
fn write(&self, path: &str, content: &str) {
let path = self.0.join(path);
if path.to_string_lossy().ends_with('/') {
fs::create_dir_all(&path).unwrap();
return;
}
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
fn list(&self, options: Options) -> (Vec<String>, bool) {
let listing = list(&self.0.to_string_lossy(), &options, &|| false).unwrap();
let entries: Vec<String> = listing
.payload
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
.map(|record| {
format!(
"{} {}",
record[0] as char,
String::from_utf8_lossy(&record[1..])
)
})
.collect();
assert_eq!(entries.len() as u64, listing.count);
(entries, listing.truncated)
}
}
impl Drop for Tree {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn options() -> Options {
Options::from_json(&json!({})).unwrap()
}
#[test]
fn lists_files_and_directories_depth_first_in_name_order() {
let tree = Tree::new(&[
"b.txt",
"a/z.txt",
"a/b/c.txt",
"empty/",
".hidden/x",
"A.txt",
]);
let (entries, truncated) = tree.list(options());
assert_eq!(
entries,
[
"d .hidden",
"f .hidden/x",
"f A.txt",
"d a",
"d a/b",
"f a/b/c.txt",
"f a/z.txt",
"f b.txt",
"d empty"
]
);
assert!(!truncated);
let (entries, _) = tree.list(Options {
hidden: false,
..options()
});
assert!(!entries.iter().any(|entry| entry.contains(".hidden")));
}
#[test]
fn skips_what_git_ignores_only_in_work_trees() {
let tree = Tree::new(&[
"keep.txt",
"out/x.js",
"sub/keep.txt",
"sub/drop.log",
"node_modules/m/i.js",
]);
tree.write(".gitignore", "out/\n*.log\n");
tree.write("sub/.gitignore", "!keep.txt\n");
// Not a work tree yet: everything is listed.
let (entries, _) = tree.list(options());
assert!(entries.contains(&"f out/x.js".to_string()));
tree.write(".git/HEAD", "ref: refs/heads/main\n");
tree.write(".git/info/exclude", "keep.txt\n");
let (entries, _) = tree.list(Options {
exclude: vec!["node_modules".into()],
..options()
});
assert_eq!(
entries,
[
"f .gitignore",
"d sub",
"f sub/.gitignore",
"f sub/keep.txt"
]
);
let (entries, _) = tree.list(Options {
gitignore: false,
..options()
});
assert!(entries.contains(&"f out/x.js".to_string()));
assert!(!entries.iter().any(|entry| entry.contains(".git/")));
// A subdirectory of a work tree applies the rules of its parents.
let sub = list(&tree.0.join("sub").to_string_lossy(), &options(), &|| false).unwrap();
assert_eq!(sub.payload, b"f.gitignore\0fkeep.txt\0");
}
#[cfg(unix)]
#[test]
fn lists_symbolic_links_without_following_them() {
let tree = Tree::new(&["dir/inner.txt"]);
std::os::unix::fs::symlink(tree.0.join("dir"), tree.0.join("link")).unwrap();
std::os::unix::fs::symlink("missing", tree.0.join("dangling")).unwrap();
std::os::unix::fs::symlink(&tree.0, tree.0.join("dir/loop")).unwrap();
let (entries, _) = tree.list(options());
assert_eq!(
entries,
[
"l dangling",
"d dir",
"f dir/inner.txt",
"l dir/loop",
"l link"
]
);
}
#[test]
fn stops_at_bounds_with_truncated() {
let tree = Tree::new(&["a", "b", "c"]);
let (entries, truncated) = tree.list(Options {
max_entries: 2,
..options()
});
assert_eq!((entries.len(), truncated), (2, true));
let (entries, truncated) = tree.list(Options {
max_entries: 3,
..options()
});
assert_eq!((entries.len(), truncated), (3, false));
let (entries, truncated) = tree.list(Options {
max_bytes: 6,
..options()
});
assert_eq!(
(entries, truncated),
(vec!["f a".to_string(), "f b".to_string()], true)
);
let (entries, truncated) = tree.list(Options {
timeout: Duration::ZERO,
..options()
});
assert!(truncated && entries.is_empty());
}
#[test]
fn fails_on_missing_roots_files_and_aborts() {
let tree = Tree::new(&["file"]);
let missing = list(
&tree.0.join("missing").to_string_lossy(),
&options(),
&|| false,
);
assert_eq!(missing.err().unwrap().code, "ENOENT");
let file = list(&tree.0.join("file").to_string_lossy(), &options(), &|| {
false
});
assert_eq!(file.err().unwrap().code, "ENOTDIR");
let aborted = list(&tree.0.to_string_lossy(), &options(), &|| true);
assert_eq!(aborted.err().unwrap().code, "aborted");
}
}

View File

@@ -0,0 +1,551 @@
//! pi-env: a small daemon that gives a Pi Durable host an execution environment on this machine over stdin/stdout
//! (docs/protocol.md). Started as `pi-env serve --token <hex>`, usually through `ssh`.
mod decode;
mod errors;
mod exec;
mod frame;
mod fs;
mod list;
mod output;
mod pty;
mod scan;
mod sys;
mod watch;
mod window;
use errors::Failure;
use exec::Control;
use frame::{CANCEL, ERROR, Frame, PING, REQUEST, RESULT};
use output::Output;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::env;
use std::fs::{File, ReadDir};
use std::io::{self, BufReader, BufWriter, Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const PROTOCOL: u64 = 1;
/// Operations added after protocol 1's first release; clients check `hello.features` before using them.
const FEATURES: &[&str] = &["listFiles"];
/// The npm package version this daemon ships with (build.rs).
const VERSION: &str = env!("PI_ENV_VERSION");
const PING_INTERVAL: Duration = Duration::from_secs(5);
const SILENCE_LIMIT_MS: u64 = 30_000;
const SCAN_CHUNK: usize = 64 * 1024;
/// Threads for file operations; commands run on their own threads.
const WORKERS: usize = 16;
/// Open handles per connection, beyond which opening fails with `EMFILE`.
const MAX_HANDLES: usize = 4096;
/// Replies with larger payloads queue behind command output instead of going first.
const CONTROL_PAYLOAD: usize = 64 * 1024;
struct FileHandle {
file: File,
path: String,
/// A failed chunk write poisons a write handle, so later chunks cannot leave a gap.
failed: AtomicBool,
/// Chunk writes run one at a time in arrival order, though the client sends several at once.
serial: Mutex<Serial>,
}
#[derive(Default)]
struct Serial {
jobs: VecDeque<Job>,
running: bool,
}
struct DirHandle {
entries: ReadDir,
path: String,
}
enum Handle {
File(Arc<FileHandle>),
Dir(Arc<Mutex<DirHandle>>),
}
type Job = Box<dyn FnOnce() + Send>;
struct Server {
output: Arc<Output>,
tmpdir: String,
handles: Mutex<HashMap<u64, Handle>>,
next_handle: AtomicU64,
controls: Mutex<HashMap<u32, Arc<Control>>>,
groups: exec::Groups,
terminals: pty::Terminals,
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0)
}
fn field<'a>(json: &'a Value, key: &str) -> Result<&'a str, Failure> {
json[key]
.as_str()
.ok_or_else(|| Failure::new("EINVAL", format!("missing field {key}")))
}
fn number(json: &Value, key: &str) -> Result<u64, Failure> {
json[key]
.as_u64()
.ok_or_else(|| Failure::new("EINVAL", format!("missing or invalid field {key}")))
}
fn flag(json: &Value, key: &str) -> bool {
json[key].as_bool().unwrap_or(false)
}
impl Server {
fn file(&self, json: &Value) -> Result<Arc<FileHandle>, Failure> {
match self.handles.lock().unwrap().get(&number(json, "handle")?) {
Some(Handle::File(file)) => Ok(file.clone()),
Some(Handle::Dir(_)) => Err(Failure::new("EBADF", "not a file handle")),
None => Err(Failure::new("EBADF", "unknown handle")),
}
}
fn insert(&self, handle: Handle) -> Result<u64, Failure> {
let mut handles = self.handles.lock().unwrap();
if handles.len() >= MAX_HANDLES {
return Err(Failure::new("EMFILE", "EMFILE: too many open files"));
}
let id = self.next_handle.fetch_add(1, Ordering::Relaxed);
handles.insert(id, handle);
Ok(id)
}
fn insert_file(&self, file: File, path: &str) -> Result<u64, Failure> {
self.insert(Handle::File(Arc::new(FileHandle {
file,
path: path.to_string(),
failed: AtomicBool::new(false),
serial: Mutex::new(Serial::default()),
})))
}
fn dispatch(
&self,
id: u32,
json: &Value,
payload: Vec<u8>,
control: &Control,
) -> Result<(Value, Vec<u8>), Failure> {
if json.is_null() {
return Err(Failure::new("EINVAL", "invalid JSON"));
}
let op = json["op"].as_str().unwrap_or_default();
let plain = |value: Result<Value, Failure>| value.map(|value| (value, Vec::new()));
match op {
"hello" => plain(Ok(json!({
"protocol": PROTOCOL,
"version": VERSION,
"os": env::consts::OS,
"arch": env::consts::ARCH,
"home": sys::home(),
"separator": sys::PATH_SEPARATOR,
"tmpdir": self.tmpdir,
"cwd": sys::cwd(),
"driveCwds": sys::drive_cwds(),
"pid": std::process::id(),
"features": FEATURES,
}))),
"lstat" => plain(fs::lstat(field(json, "path")?)),
"realpath" => plain(fs::realpath(field(json, "path")?)),
"write" => {
let path = field(json, "path")?;
let parents = json["parents"].as_bool().unwrap_or(true);
let file = fs::write(path, flag(json, "append"), parents, &payload)?;
if !flag(json, "keep") {
return plain(Ok(json!({})));
}
plain(Ok(json!({ "handle": self.insert_file(file, path)? })))
}
"writeChunk" => {
let handle = self.file(json)?;
if handle.failed.load(Ordering::SeqCst) {
return Err(Failure::new("EBADF", "an earlier write failed"));
}
(&handle.file).write_all(&payload).map_err(|error| {
handle.failed.store(true, Ordering::SeqCst);
Failure::io(&error, "write", &handle.path)
})?;
plain(Ok(json!({})))
}
"truncate" => plain(fs::truncate(field(json, "path")?, number(json, "size")?)),
"fsync" => plain(fs::fsync(field(json, "path")?)),
"rename" => plain(fs::rename(field(json, "path")?, field(json, "to")?)),
"mkdir" => plain(fs::mkdir(field(json, "path")?, flag(json, "recursive"))),
"rm" => plain(fs::rm(
field(json, "path")?,
flag(json, "recursive"),
flag(json, "force"),
)),
"mkdtemp" => plain(fs::mkdtemp(field(json, "path")?)),
"open" => {
let path = field(json, "path")?;
if json["mode"].as_str() == Some("read") {
// Node's `open(path, "r")`: any kind of file, blocking; reads report what the file is.
let file = sys::open(path, sys::OpenMode::Read)
.map_err(|error| Failure::io(&error, "open", path))?;
let stat = match file.metadata() {
Ok(metadata) => json!({ "stat": fs::info(path, &metadata) }),
Err(error) => {
json!({ "statError": Failure::io(&error, "fstat", path).to_json() })
}
};
let mut result = stat;
result["handle"] = json!(self.insert_file(file, path)?);
return plain(Ok(result));
}
let (file, info) = fs::open_reader(path, flag(json, "noFollow"))?;
plain(Ok(
json!({ "handle": self.insert_file(file, path)?, "info": info }),
))
}
"pread" => {
let handle = self.file(json)?;
let length = (number(json, "length")? as usize).min(frame::MAX_PAYLOAD);
let bytes = match json["offset"].as_u64() {
Some(offset) => fs::pread(&handle.file, &handle.path, offset, length)?,
None => fs::read(&handle.file, &handle.path, length)?,
};
Ok((json!({}), bytes))
}
"fstat" => {
let handle = self.file(json)?;
let metadata = handle
.file
.metadata()
.map_err(|error| Failure::io(&error, "fstat", &handle.path))?;
plain(Ok(fs::info(&handle.path, &metadata)))
}
"scanLines" => {
let handle = self.file(json)?;
plain(scan_lines(&handle.file, &handle.path, json, control))
}
"opendir" => {
let path = field(json, "path")?;
let entries =
sys::read_dir(path).map_err(|error| Failure::io(&error, "opendir", path))?;
let handle = self.insert(Handle::Dir(Arc::new(Mutex::new(DirHandle {
entries,
path: path.to_string(),
}))))?;
plain(Ok(json!({ "handle": handle })))
}
"readdir" => plain(self.read_dir(json)),
"listFiles" => list::run(json, &|| control.aborted.load(Ordering::SeqCst)),
"close" => {
self.handles
.lock()
.unwrap()
.remove(&number(json, "handle")?);
plain(Ok(json!({})))
}
"exec" => plain(exec::run(
id,
exec::ExecRequest::from_json(json)?,
&self.tmpdir,
&self.output,
control,
&self.groups,
)),
"watch" => plain(watch::run(id, json, &self.output, control)),
"pty" => plain(pty::run(
id,
pty::PtyRequest::from_json(json)?,
&self.output,
control,
&self.groups,
&self.terminals,
)),
_ => Err(Failure::new("EINVAL", format!("unknown operation {op}"))),
}
}
fn read_dir(&self, json: &Value) -> Result<Value, Failure> {
let max = number(json, "max")?.max(1) as usize;
// Lock only this directory, so other requests are not held up by a slow listing.
let dir = match self.handles.lock().unwrap().get(&number(json, "handle")?) {
Some(Handle::Dir(dir)) => dir.clone(),
_ => return Err(Failure::new("EBADF", "not a directory handle")),
};
let mut dir = dir.lock().unwrap();
let mut entries = Vec::new();
let mut done = false;
while entries.len() < max {
match dir.entries.next() {
None => {
done = true;
break;
}
Some(Err(error)) => return Err(Failure::io(&error, "readdir", &dir.path)),
Some(Ok(entry)) => entries.push(fs::dir_entry(&dir.path, &entry.file_name())),
}
}
Ok(json!({ "entries": entries, "done": done }))
}
}
fn scan_lines(file: &File, path: &str, json: &Value, control: &Control) -> Result<Value, Failure> {
let start_line = number(json, "startLine")?;
let end_line = json["endLine"].as_u64();
if end_line.is_some_and(|end| end <= start_line) {
return Err(Failure::new("EINVAL", "Invalid line range"));
}
let mut scanner = scan::LineScanner::new(start_line, end_line);
let mut buffer = vec![0u8; SCAN_CHUNK];
let mut position = 0u64;
loop {
if control.aborted.load(Ordering::SeqCst) {
return Err(Failure::new("aborted", "aborted"));
}
let read = sys::read_at(file, &mut buffer, position)
.map_err(|error| Failure::io(&error, "read", path))?;
if read == 0 {
return Ok(scanner.finish());
}
scanner.push(&buffer[..read]);
position += read as u64;
}
}
fn kill_all(groups: &exec::Groups) {
for pid in groups.lock().unwrap().iter() {
exec::kill_group(*pid);
}
}
/// Stdin that records when bytes last arrived, so a large frame arriving slowly counts as a live client.
struct Seen<R> {
inner: R,
last_seen: Arc<AtomicU64>,
}
impl<R: Read> Read for Seen<R> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
let read = self.inner.read(buffer)?;
if read > 0 {
self.last_seen.store(now_ms(), Ordering::Relaxed);
}
Ok(read)
}
}
fn start_workers() -> Sender<Job> {
let (jobs, queue) = mpsc::channel::<Job>();
let queue = Arc::new(Mutex::new(queue));
for _ in 0..WORKERS {
let queue: Arc<Mutex<Receiver<Job>>> = queue.clone();
thread::spawn(move || {
loop {
let job = queue.lock().unwrap().recv();
match job {
Ok(job) => job(),
Err(_) => return,
}
}
});
}
jobs
}
fn respond(server: &Server, id: u32, request: Frame, control: Arc<Control>) {
let is_exec = matches!(request.json["op"].as_str(), Some("exec" | "pty"));
let reply = match server.dispatch(id, &request.json, request.payload, &control) {
Ok((json, payload)) => Frame::with_payload(RESULT, id, json, payload),
Err(failure) => Frame::new(ERROR, id, failure.to_json()),
};
server.controls.lock().unwrap().remove(&id);
// A command's result follows its output; large payloads do not hold up pings and small replies.
if is_exec || reply.payload.len() > CONTROL_PAYLOAD {
server.output.bulk(reply, None);
} else {
server.output.control(reply);
}
}
/// Queue `job` behind the handle's earlier chunk writes; one pool job drains the queue.
fn run_in_order(jobs: &Sender<Job>, handle: Arc<FileHandle>, job: Job) {
let mut serial = handle.serial.lock().unwrap();
serial.jobs.push_back(job);
if serial.running {
return;
}
serial.running = true;
drop(serial);
let _ = jobs.send(Box::new(move || {
loop {
let next = {
let mut serial = handle.serial.lock().unwrap();
let next = serial.jobs.pop_front();
if next.is_none() {
serial.running = false;
}
next
};
match next {
Some(job) => job(),
None => return,
}
}
}));
}
fn serve_frames(server: &Arc<Server>, input: &mut impl Read) -> io::Result<()> {
let jobs = start_workers();
while let Some(request) = frame::read_frame(input)? {
match request.kind {
REQUEST
if matches!(
request.json["op"].as_str(),
Some("ptyWrite" | "ptyResize" | "ptyForeground")
) =>
{
// Answered here, in arrival order: through the worker pool, two writes could swap.
let reply = match request.json["op"].as_str() {
Some("ptyWrite") => {
pty::write(&server.terminals, &request.json, request.payload)
}
Some("ptyResize") => pty::resize(&server.terminals, &request.json),
_ => pty::foreground(&server.terminals, &request.json),
};
server.output.control(match reply {
Ok(json) => Frame::new(RESULT, request.id, json),
Err(failure) => Frame::new(ERROR, request.id, failure.to_json()),
});
}
REQUEST => {
let id = request.id;
// Registered before the request runs, so a cancel right behind it is not lost.
let control = Arc::new(Control::default());
server.controls.lock().unwrap().insert(id, control.clone());
// Commands and watchers last; they get their own threads (watchers recurse through trees).
if matches!(request.json["op"].as_str(), Some("exec" | "watch" | "pty")) {
let worker = server.clone();
let spawned = thread::Builder::new()
.stack_size(16 * 1024 * 1024)
.spawn(move || respond(&worker, id, request, control));
if let Err(error) = spawned {
server.controls.lock().unwrap().remove(&id);
let failure = Failure::new("spawn_error", error.to_string());
server
.output
.bulk(Frame::new(ERROR, id, failure.to_json()), None);
}
} else {
let worker = server.clone();
let serial = match request.json["op"].as_str() {
Some("writeChunk") => server.file(&request.json).ok(),
_ => None,
};
let job: Job = Box::new(move || respond(&worker, id, request, control));
match serial {
Some(handle) => run_in_order(&jobs, handle, job),
None => {
let _ = jobs.send(job);
}
}
}
}
CANCEL => {
let control = server.controls.lock().unwrap().get(&request.id).cloned();
if let Some(control) = control {
control.cancel(request.json["mode"].as_str() == Some("kill"));
}
}
_ => {}
}
}
Ok(())
}
fn serve(token: &str) -> io::Result<()> {
{
let mut stdout = io::stdout().lock();
writeln!(stdout, "PI-ENV {token}")?;
stdout.flush()?;
}
let output = Arc::new(Output::default());
let writer = {
let output = output.clone();
thread::spawn(move || {
output.run(&mut BufWriter::with_capacity(1 << 20, io::stdout().lock()))
})
};
let server = Arc::new(Server {
output: output.clone(),
tmpdir: sys::tmpdir(),
handles: Mutex::new(HashMap::new()),
next_handle: AtomicU64::new(1),
controls: Mutex::new(HashMap::new()),
groups: Arc::new(Mutex::new(Default::default())),
terminals: Arc::new(Mutex::new(HashMap::new())),
});
let last_seen = Arc::new(AtomicU64::new(now_ms()));
{
let output = output.clone();
let last_seen = last_seen.clone();
let groups = server.groups.clone();
thread::spawn(move || {
loop {
thread::sleep(PING_INTERVAL);
output.control(Frame::new(PING, 0, json!({})));
// A client that went silent (a phone that lost its network) leaves nothing running.
if now_ms().saturating_sub(last_seen.load(Ordering::Relaxed)) > SILENCE_LIMIT_MS {
kill_all(&groups);
std::process::exit(0);
}
}
});
}
let mut input = BufReader::with_capacity(
1 << 20,
Seen {
inner: io::stdin().lock(),
last_seen,
},
);
let result = serve_frames(&server, &mut input);
// However input ended, the client is gone: stop everything it started.
kill_all(&server.groups);
output.close();
let _ = writer.join();
result
}
fn main() {
let args: Vec<String> = env::args().collect();
match args.get(1).map(String::as_str) {
Some("serve") => {
let token = args
.iter()
.position(|arg| arg == "--token")
.and_then(|index| args.get(index + 1));
let Some(token) = token else {
eprintln!("usage: pi-env serve --token <hex>");
std::process::exit(2);
};
if let Err(error) = serve(token) {
eprintln!("pi-env: {error}");
std::process::exit(1);
}
// Request threads may still be blocked (a FIFO open); nothing is left to answer them.
std::process::exit(0);
}
Some("--version") => println!("pi-env {VERSION}"),
_ => {
eprintln!("usage: pi-env serve --token <hex>");
std::process::exit(2);
}
}
}

View File

@@ -0,0 +1,100 @@
//! The single writer of stdout. Control frames (results, errors, pings, watch events) always go before bulk command
//! output, so a slow link delays output but never a cancel's result or a ping. Bulk output is bounded: readers of
//! commands without an output window wait while too much of it is unsent, which slows the command down like a full
//! pipe would.
use crate::frame::{self, Frame};
use std::collections::VecDeque;
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
/// Unsent bulk bytes above which unwindowed command output waits.
const BULK_LIMIT: usize = 4 * 1024 * 1024;
#[derive(Default)]
struct Queues {
control: VecDeque<Frame>,
bulk: VecDeque<(Frame, Option<Arc<AtomicUsize>>)>,
bulk_bytes: usize,
closed: bool,
}
#[derive(Default)]
pub struct Output {
queues: Mutex<Queues>,
/// Signalled when a frame is queued or the writer stops.
queued: Condvar,
/// Signalled when bulk bytes were written.
drained: Condvar,
}
impl Output {
pub fn control(&self, frame: Frame) {
let mut queues = self.queues.lock().unwrap();
queues.control.push_back(frame);
self.queued.notify_one();
}
/// Queue bulk output; `unsent` counts this sender's frames until they are written.
pub fn bulk(&self, frame: Frame, unsent: Option<Arc<AtomicUsize>>) {
let mut queues = self.queues.lock().unwrap();
queues.bulk_bytes += frame.payload.len();
if let Some(counter) = &unsent {
counter.fetch_add(1, Ordering::SeqCst);
}
queues.bulk.push_back((frame, unsent));
self.queued.notify_one();
}
/// Wait while more than the bulk limit is unsent, unless `stop` is set.
pub fn wait_for_room(&self, stop: &AtomicBool) {
let mut queues = self.queues.lock().unwrap();
while queues.bulk_bytes > BULK_LIMIT && !queues.closed && !stop.load(Ordering::SeqCst) {
queues = self
.drained
.wait_timeout(queues, Duration::from_millis(100))
.unwrap()
.0;
}
}
pub fn close(&self) {
let mut queues = self.queues.lock().unwrap();
queues.closed = true;
self.queued.notify_all();
self.drained.notify_all();
}
/// Write frames to `writer` until closed or a write fails.
pub fn run(&self, writer: &mut impl Write) {
loop {
let (frame, unsent) = {
let mut queues = self.queues.lock().unwrap();
loop {
if let Some(frame) = queues.control.pop_front() {
break (frame, None);
}
if let Some((frame, unsent)) = queues.bulk.pop_front() {
queues.bulk_bytes -= frame.payload.len();
break (frame, unsent);
}
if queues.closed {
return;
}
queues = self.queued.wait(queues).unwrap();
}
};
let failed = frame::write_frame(writer, &frame).is_err();
if let Some(counter) = unsent {
counter.fetch_sub(1, Ordering::SeqCst);
}
self.drained.notify_all();
if failed {
self.close();
return;
}
}
}
}

View File

@@ -0,0 +1,556 @@
//! `pty`: a program in a pseudo-terminal, for interactive terminals. Unlike `exec`, output is the terminal's raw
//! bytes, and the program reads input: `ptyWrite`, `ptyResize` and `ptyForeground` address the running `pty` request by
//! its id.
use crate::errors::Failure;
use crate::exec::{Control, Groups};
#[cfg(unix)]
use crate::exec::{Message, kill_group};
#[cfg(unix)]
use crate::frame::{EVENT, Frame};
use crate::output::Output;
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
/// After the program exits, output still arriving is collected until this much silence.
#[cfg(unix)]
const EXIT_GRACE: std::time::Duration = std::time::Duration::from_millis(100);
/// Largest terminal size accepted, in cells.
const MAX_CELLS: u64 = 4096;
/// What `ptyWrite`, `ptyResize` and `ptyForeground` reach: the terminal of one running `pty` request.
pub struct Terminal {
input: Sender<Vec<u8>>,
#[cfg(unix)]
master: Arc<std::fs::File>,
/// The program's process id, which is also its process group's (it leads the terminal's session).
#[cfg(unix)]
pid: u32,
}
/// Running terminals by the id of their `pty` request.
pub type Terminals = Arc<Mutex<HashMap<u32, Arc<Terminal>>>>;
#[cfg_attr(not(unix), allow(dead_code))]
pub struct PtyRequest {
argv: Option<Vec<String>>,
cwd: String,
env: Map<String, Value>,
inherit_env: bool,
cols: u16,
rows: u16,
}
fn size(json: &Value, key: &str) -> Result<u16, Failure> {
match json[key].as_u64() {
Some(value) if (1..=MAX_CELLS).contains(&value) => Ok(value as u16),
_ => Err(Failure::new(
"EINVAL",
format!("{key} must be between 1 and {MAX_CELLS}"),
)),
}
}
impl PtyRequest {
pub fn from_json(json: &Value) -> Result<PtyRequest, Failure> {
let argv = match &json["argv"] {
Value::Null => None,
value => Some(
value
.as_array()
.and_then(|items| {
items
.iter()
.map(|item| item.as_str().map(str::to_string))
.collect()
})
.filter(|argv: &Vec<String>| !argv.is_empty())
.ok_or_else(|| {
Failure::new("EINVAL", "argv must be a non-empty array of strings")
})?,
),
};
Ok(PtyRequest {
argv,
cwd: json["cwd"]
.as_str()
.ok_or_else(|| Failure::new("EINVAL", "pty needs cwd"))?
.to_string(),
env: json["env"].as_object().cloned().unwrap_or_default(),
inherit_env: json["inheritEnv"].as_bool().unwrap_or(true),
cols: size(json, "cols")?,
rows: size(json, "rows")?,
})
}
}
fn find(terminals: &Terminals, json: &Value) -> Result<Arc<Terminal>, Failure> {
let target = json["target"]
.as_u64()
.ok_or_else(|| Failure::new("EINVAL", "missing field target"))?;
terminals
.lock()
.unwrap()
.get(&(target as u32))
.cloned()
.ok_or_else(|| Failure::new("EBADF", "no running terminal with this id"))
}
/// Queue input for the terminal; it is written in the order it arrives, without holding up other requests.
pub fn write(terminals: &Terminals, json: &Value, payload: Vec<u8>) -> Result<Value, Failure> {
let terminal = find(terminals, json)?;
terminal
.input
.send(payload)
.map_err(|_| Failure::new("EBADF", "the terminal has ended"))?;
Ok(json!({}))
}
/// Change the terminal's size; the program receives `SIGWINCH`.
pub fn resize(terminals: &Terminals, json: &Value) -> Result<Value, Failure> {
let terminal = find(terminals, json)?;
let (cols, rows) = (size(json, "cols")?, size(json, "rows")?);
#[cfg(unix)]
unix::set_size(&terminal.master, cols, rows)
.map_err(|error| Failure::io(&error, "ioctl", "pty"))?;
#[cfg(not(unix))]
let _ = (terminal, cols, rows);
Ok(json!({}))
}
/// The process group in the terminal's foreground when it is not the program's own: a job a shell started, such as an
/// editor. `{ foreground: null }` while the program itself is in front (a shell at its prompt).
pub fn foreground(terminals: &Terminals, json: &Value) -> Result<Value, Failure> {
let terminal = find(terminals, json)?;
#[cfg(unix)]
{
let group = unix::foreground(&terminal.master)
.map_err(|error| Failure::io(&error, "tcgetpgrp", "pty"))?;
if group <= 0 || group as u32 == terminal.pid {
return Ok(json!({ "foreground": null }));
}
let name = unix::process_name(group);
Ok(json!({ "foreground": { "pid": group, "name": name } }))
}
#[cfg(not(unix))]
{
let _ = terminal;
Ok(json!({ "foreground": null }))
}
}
#[cfg(not(unix))]
pub fn run(
_id: u32,
_request: PtyRequest,
_output: &Arc<Output>,
_control: &Control,
_groups: &Groups,
_terminals: &Terminals,
) -> Result<Value, Failure> {
Err(Failure::new(
"ENOTSUP",
"Terminals are not supported on this system yet",
))
}
/// Run the program in a new terminal, sending a `started` event and then its output, until it exits.
#[cfg(unix)]
pub fn run(
id: u32,
request: PtyRequest,
output: &Arc<Output>,
control: &Control,
groups: &Groups,
terminals: &Terminals,
) -> Result<Value, Failure> {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::{Duration, Instant};
let (sender, receiver) = mpsc::channel();
control.attach(sender.clone());
if control.aborted.load(Ordering::SeqCst) {
return Err(Failure::new("aborted", "aborted"));
}
if !std::path::Path::new(&request.cwd).exists() {
return Err(Failure::new(
"spawn_error",
format!("Working directory does not exist: {}", request.cwd),
));
}
let (master, mut child) =
unix::spawn(&request).map_err(|error| Failure::new("spawn_error", error.to_string()))?;
let master = Arc::new(master);
let pid = child.id();
groups.lock().unwrap().insert(pid);
let (input, inputs) = mpsc::channel::<Vec<u8>>();
terminals.lock().unwrap().insert(
id,
Arc::new(Terminal {
input,
master: master.clone(),
pid,
}),
);
output.control(Frame::new(
EVENT,
id,
json!({ "kind": "started", "pid": pid }),
));
let stop = Arc::new(AtomicBool::new(false));
let reader = {
let master = master.clone();
let sender = sender.clone();
let stop = stop.clone();
let output = output.clone();
thread::spawn(move || {
unix::read(&master, &stop, |bytes| {
let delivered = sender.send(Message::Data(0, bytes.to_vec())).is_ok();
// Like a full pipe: a program that writes faster than the link waits.
output.wait_for_room(&stop);
delivered
});
let _ = sender.send(Message::Eof(0));
})
};
{
let master = master.clone();
let stop = stop.clone();
thread::spawn(move || {
for bytes in inputs {
if !unix::write_all(&master, &bytes, &stop) {
return;
}
}
});
}
let waiter = sender.clone();
thread::spawn(move || {
let _ = waiter.send(match child.wait() {
Ok(status) => Message::Exit(status),
Err(error) => Message::WaitFailed(error.to_string()),
});
});
drop(sender);
let mut status = None;
let mut ended = false;
let mut idle_until: Option<Instant> = None;
let mut aborted = false;
let mut killed = false;
let kill = |killed: &mut bool| {
if !*killed {
*killed = true;
kill_group(pid);
}
};
if control.killed.load(Ordering::SeqCst) {
kill(&mut killed);
}
loop {
if status.is_some() && ended {
break;
}
let wait = match idle_until {
Some(idle) => {
let now = Instant::now();
if now >= idle {
// A background job may keep the terminal open after the program exited.
break;
}
idle - now
}
None => Duration::from_secs(3600),
};
match receiver.recv_timeout(wait) {
Ok(Message::Data(_, bytes)) => {
output.bulk(
Frame::with_payload(EVENT, id, json!({ "kind": "output" }), bytes),
None,
);
if status.is_some() {
idle_until = Some(Instant::now() + EXIT_GRACE);
}
}
Ok(Message::Eof(_)) => ended = true,
Ok(Message::Exit(exit)) => {
status = Some(Ok(exit));
idle_until = Some(Instant::now() + EXIT_GRACE);
}
Ok(Message::WaitFailed(error)) => {
status = Some(Err(error));
idle_until = Some(Instant::now());
}
Ok(Message::Cancel) => {
aborted = true;
kill(&mut killed);
}
Ok(Message::Kill) => kill(&mut killed),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
}
stop.store(true, Ordering::SeqCst);
// Ending the input channel ends the writer thread.
terminals.lock().unwrap().remove(&id);
groups.lock().unwrap().remove(&pid);
let _ = reader.join();
if aborted {
return Err(Failure::new("aborted", "aborted"));
}
match status {
Some(Ok(status)) => Ok(json!({ "exitCode": crate::sys::exit_code(status) })),
Some(Err(error)) => Err(Failure::new("spawn_error", error)),
None => Err(Failure::new(
"spawn_error",
"process did not report its exit",
)),
}
}
#[cfg(unix)]
mod unix {
use super::PtyRequest;
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::Value;
fn check(result: libc::c_int) -> io::Result<libc::c_int> {
if result < 0 {
Err(io::Error::last_os_error())
} else {
Ok(result)
}
}
fn winsize(cols: u16, rows: u16) -> libc::winsize {
libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
}
}
pub fn set_size(master: &File, cols: u16, rows: u16) -> io::Result<()> {
let size = winsize(cols, rows);
// SAFETY: TIOCSWINSZ reads one winsize from a valid pointer.
check(unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ as _, &size) })?;
Ok(())
}
/// The terminal's foreground process group.
pub fn foreground(master: &File) -> io::Result<libc::pid_t> {
// SAFETY: tcgetpgrp on a descriptor the terminal owns.
let group = unsafe { libc::tcgetpgrp(master.as_raw_fd()) };
if group < 0 {
Err(io::Error::last_os_error())
} else {
Ok(group)
}
}
/// A process's name, if it still runs and the system tells.
#[cfg(target_vendor = "apple")]
pub fn process_name(pid: libc::pid_t) -> Option<String> {
let mut buffer = [0u8; 256];
// SAFETY: proc_name writes at most `buffer.len()` bytes and returns how many.
let length =
unsafe { libc::proc_name(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
(length > 0).then(|| String::from_utf8_lossy(&buffer[..length as usize]).into_owned())
}
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn process_name(pid: libc::pid_t) -> Option<String> {
let name = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
Some(name.trim_end().to_string()).filter(|name| !name.is_empty())
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
pub fn process_name(_pid: libc::pid_t) -> Option<String> {
None
}
fn cloexec(fd: libc::c_int) -> io::Result<()> {
// SAFETY: fcntl on a descriptor this function's caller owns.
unsafe {
let flags = check(libc::fcntl(fd, libc::F_GETFD))?;
check(libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC))?;
}
Ok(())
}
/// The program to run: `argv`, or the user's shell as a login shell (`-` before its name, as `login` does).
fn command(request: &PtyRequest) -> Command {
match &request.argv {
Some(argv) => {
let mut command = Command::new(&argv[0]);
command.args(&argv[1..]);
command
}
None => {
let shell = request
.env
.get("SHELL")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| std::env::var("SHELL").ok())
.filter(|shell| !shell.is_empty() && crate::sys::path_exists(shell))
.unwrap_or_else(|| "/bin/sh".into());
let name = shell.rsplit('/').next().unwrap_or("sh").to_string();
let mut command = Command::new(&shell);
command.arg0(format!("-{name}"));
command
}
}
}
pub fn spawn(request: &PtyRequest) -> io::Result<(File, Child)> {
let mut master: libc::c_int = -1;
let mut slave: libc::c_int = -1;
let mut size = winsize(request.cols, request.rows);
// SAFETY: openpty writes two descriptors; the name and termios are not requested.
check(unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
&raw mut size,
)
})?;
// SAFETY: openpty returned two new descriptors this function now owns.
let (master, slave) =
unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) };
cloexec(master.as_raw_fd())?;
cloexec(slave.as_raw_fd())?;
let slave = File::from(slave);
let mut command = command(request);
command
.current_dir(&request.cwd)
.stdin(Stdio::from(slave.try_clone()?))
.stdout(Stdio::from(slave.try_clone()?))
.stderr(Stdio::from(slave.try_clone()?));
if !request.inherit_env {
command.env_clear();
}
for (key, value) in &request.env {
if let Some(value) = value.as_str() {
command.env(key, value);
}
}
// SAFETY: only async-signal-safe calls between fork and exec.
unsafe {
command.pre_exec(|| {
// A new session whose controlling terminal is the pty (stdin), so job control and SIGHUP work; default
// signal handling and an empty mask, as for `exec`.
if libc::setsid() < 0 {
return Err(io::Error::last_os_error());
}
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 {
return Err(io::Error::last_os_error());
}
for signal in 1..32 {
if signal != libc::SIGKILL && signal != libc::SIGSTOP {
libc::signal(signal, libc::SIG_DFL);
}
}
let mut mask: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut mask);
libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
Ok(())
});
}
let child = command.spawn()?;
// Only the program holds the terminal now, so reads end once it and its descendants close it.
drop(slave);
let master = File::from(master);
// SAFETY: fcntl on the descriptor owned above; reads and writes poll instead of blocking.
unsafe {
let flags = check(libc::fcntl(master.as_raw_fd(), libc::F_GETFL))?;
check(libc::fcntl(
master.as_raw_fd(),
libc::F_SETFL,
flags | libc::O_NONBLOCK,
))?;
}
Ok((master, child))
}
fn poll(fd: libc::c_int, events: libc::c_short) -> io::Result<bool> {
let mut poll = libc::pollfd {
fd,
events,
revents: 0,
};
// SAFETY: one valid pollfd.
let ready = unsafe { libc::poll(&mut poll, 1, 100) };
if ready < 0 {
let error = io::Error::last_os_error();
return if error.kind() == io::ErrorKind::Interrupted {
Ok(false)
} else {
Err(error)
};
}
Ok(ready > 0)
}
/// Read until the terminal closes (Linux reports `EIO` once no program holds it), `on_data` returns false, or
/// `stop` is set.
pub fn read(master: &File, stop: &AtomicBool, mut on_data: impl FnMut(&[u8]) -> bool) {
let mut buffer = vec![0u8; 64 * 1024];
while !stop.load(Ordering::SeqCst) {
match poll(master.as_raw_fd(), libc::POLLIN) {
Ok(false) => continue,
Ok(true) => {}
Err(_) => return,
}
match (&*master).read(&mut buffer) {
Ok(0) => return,
Ok(read) => {
if !on_data(&buffer[..read]) {
return;
}
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) => {}
Err(_) => return,
}
}
}
/// Write all of `bytes`, waiting while the program does not read its input; false once the terminal is gone.
pub fn write_all(master: &File, mut bytes: &[u8], stop: &AtomicBool) -> bool {
while !bytes.is_empty() {
if stop.load(Ordering::SeqCst) {
return false;
}
match (&*master).write(bytes) {
Ok(0) => return false,
Ok(written) => bytes = &bytes[written..],
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if poll(master.as_raw_fd(), libc::POLLOUT).is_err() {
return false;
}
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(_) => return false,
}
}
true
}
}

View File

@@ -0,0 +1,215 @@
//! Port of Durable's `LineScanner` (`env/line-scan.ts`): locates lines `[start_line, end_line)` of a file fed in order
//! and measures their decoded UTF-8 size as `new TextDecoder().decode(file)` would produce it.
use crate::decode::{decode_into, range_decoder, starts_with_bom};
use encoding_rs::Decoder;
use serde_json::{Value, json};
const NEWLINE: u8 = 0x0a;
pub struct LineScanner {
start_line: u64,
end_line: Option<u64>,
position: u64,
newlines: u64,
line_start: u64,
start: Option<u64>,
end: Option<u64>,
first_line_end: Option<u64>,
last_line_start: Option<u64>,
selected_bytes: u64,
first_line_bytes: u64,
selection: Option<Decoder>,
first_line: Option<Decoder>,
head: Option<Vec<u8>>,
bom: bool,
}
impl LineScanner {
/// `end_line`, when given, must be greater than `start_line`.
pub fn new(start_line: u64, end_line: Option<u64>) -> LineScanner {
let mut scanner = LineScanner {
start_line,
end_line,
position: 0,
newlines: 0,
line_start: 0,
start: None,
end: None,
first_line_end: None,
last_line_start: None,
selected_bytes: 0,
first_line_bytes: 0,
selection: None,
first_line: None,
head: Some(Vec::with_capacity(3)),
bom: false,
};
if start_line == 0 {
scanner.begin(0);
}
scanner
}
fn is_last_line(&self, line: u64) -> bool {
self.end_line.is_some_and(|end| line + 1 == end)
}
pub fn push(&mut self, mut chunk: &[u8]) {
if let Some(head) = &mut self.head {
let take = (3 - head.len()).min(chunk.len());
head.extend_from_slice(&chunk[..take]);
if head.len() < 3 {
return;
}
chunk = &chunk[take..];
self.release_head();
}
self.process(chunk);
}
fn release_head(&mut self) {
let head = self.head.take().unwrap_or_default();
self.bom = starts_with_bom(&head);
self.process(&head);
}
fn process(&mut self, chunk: &[u8]) {
let base = self.position;
let mut from = 0;
let mut index = 0;
while let Some(found) = chunk[index..].iter().position(|byte| *byte == NEWLINE) {
let newline = index + found;
// The newline ends line `self.newlines`; it belongs to the selection between selected lines only.
self.feed(chunk, base, from, newline);
let line = self.newlines;
let position = base + newline as u64;
if line == self.start_line {
self.end_first_line(position);
}
if self.is_last_line(line) {
self.end_selection(position);
}
self.feed(chunk, base, newline, newline + 1);
from = newline + 1;
self.newlines += 1;
self.line_start = position + 1;
if self.newlines == self.start_line {
self.begin(self.line_start);
}
if self.is_last_line(self.newlines) {
self.last_line_start = Some(self.line_start);
}
index = newline + 1;
}
self.feed(chunk, base, from, chunk.len());
self.position += chunk.len() as u64;
}
pub fn finish(mut self) -> Value {
if self.head.is_some() {
self.release_head();
}
let size = self.position;
let Some(start) = self.start else {
return json!({
"newlines": self.newlines, "start": size, "end": size, "firstLineEnd": size,
"lastLineStart": size, "selectedBytes": 0, "firstLineBytes": 0,
});
};
if self.first_line_end.is_none() {
self.end_first_line(size);
}
if self.end.is_none() {
self.end_selection(size);
}
json!({
"newlines": self.newlines,
"start": start,
"end": self.end.unwrap_or(size),
"firstLineEnd": self.first_line_end.unwrap_or(size),
// A selection that reaches past the last line ends with the last line.
"lastLineStart": self.last_line_start.unwrap_or(self.line_start),
"selectedBytes": self.selected_bytes,
"firstLineBytes": self.first_line_bytes,
})
}
fn begin(&mut self, start: u64) {
self.start = Some(start);
if self.is_last_line(self.start_line) {
self.last_line_start = Some(start);
}
self.selection = Some(range_decoder());
self.first_line = Some(range_decoder());
}
fn end_first_line(&mut self, position: u64) {
self.first_line_end = Some(position);
if let Some(mut decoder) = self.first_line.take() {
self.first_line_bytes += decode_into(&mut decoder, &[], true).len() as u64;
}
}
fn end_selection(&mut self, position: u64) {
self.end = Some(position);
if let Some(mut decoder) = self.selection.take() {
self.selected_bytes += decode_into(&mut decoder, &[], true).len() as u64;
}
}
fn feed(&mut self, chunk: &[u8], base: u64, mut from: usize, to: usize) {
// Decoding the whole file drops a leading byte-order mark.
if self.bom && base + (from as u64) < 3 {
from = to.min((3 - base) as usize);
}
if to <= from {
return;
}
let bytes = &chunk[from..to];
if let Some(decoder) = &mut self.selection {
self.selected_bytes += decode_into(decoder, bytes, false).len() as u64;
}
if let Some(decoder) = &mut self.first_line {
self.first_line_bytes += decode_into(decoder, bytes, false).len() as u64;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scan(file: &[u8], start: u64, end: Option<u64>, chunk: usize) -> Value {
let mut scanner = LineScanner::new(start, end);
for piece in file.chunks(chunk.max(1)) {
scanner.push(piece);
}
scanner.finish()
}
#[test]
fn locates_and_measures_lines_like_whole_file_decoding() {
// BOM, "a", invalid sequence, empty line, a later U+FEFF, and no final newline.
let file = [
0xef, 0xbb, 0xbf, 0x61, 0x0a, 0xe2, 0x82, 0x0a, 0x0a, 0xef, 0xbb, 0xbf, 0x62, 0x0a,
0xc3, 0xa9,
];
for chunk in 1..6 {
let all = scan(&file, 0, None, chunk);
assert_eq!(all["newlines"], 4);
// "a\n\u{fffd}\n\n\u{feff}b\né" is 1+1+3+1+1+3+1+1+2 = 14 bytes.
assert_eq!(all["selectedBytes"], 14);
assert_eq!(all["firstLineBytes"], 1);
let middle = scan(&file, 1, Some(3), chunk);
assert_eq!(
(middle["start"].as_u64(), middle["end"].as_u64()),
(Some(5), Some(8))
);
assert_eq!(middle["selectedBytes"], 4);
assert_eq!(middle["lastLineStart"], 8);
let past = scan(&file, 9, None, chunk);
assert_eq!(past["start"], 16);
}
}
}

View File

@@ -0,0 +1,91 @@
//! What differs between operating systems: error names, positional reads, temporary directories, opening readers,
//! and starting and killing commands. Each implementation follows what Node (libuv) does on that system.
#[cfg(unix)]
mod unix;
#[cfg(unix)]
pub use unix::*;
#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub use windows::*;
/// How a string command runs: the shell, its arguments before the command, and whether the command goes to its
/// stdin instead of its arguments (legacy WSL `bash.exe`).
pub struct ShellConfig {
pub program: String,
pub args: Vec<String>,
pub command_on_stdin: bool,
}
/// How Node opens files: `r`, `writeFile` (`w`), `appendFile` (`a`), and `r+` for truncate and fsync.
#[derive(Clone, Copy)]
pub enum OpenMode {
/// Node's `open(path, "r")`: any kind of file, blocking reads.
Read,
Write,
Append,
ReadWrite,
}
/// An error code libuv reports where the system call itself reported another one, such as `ENOTDIR` from a recursive
/// `mkdir` through a file, or `EISDIR` when Windows creates a file where a directory exists.
#[derive(Debug)]
pub struct Synthetic(pub &'static str);
impl std::fmt::Display for Synthetic {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.0)
}
}
impl std::error::Error for Synthetic {}
pub fn synthetic(code: &'static str) -> std::io::Error {
std::io::Error::other(Synthetic(code))
}
/// The libuv name of an error: a synthetic code, or the system's code translated per platform.
pub fn error_name(error: &std::io::Error) -> &'static str {
if let Some(Synthetic(code)) = error
.get_ref()
.and_then(|inner| inner.downcast_ref::<Synthetic>())
{
return code;
}
os_error_name(error)
}
/// Whether a path exists, following symbolic links, like Node's `access(path, F_OK)`.
pub fn path_exists(path: &str) -> bool {
std::path::Path::new(path).metadata().is_ok()
}
/// Sixteen random bytes for temporary names (not for secrets).
pub fn random_bytes() -> [u8; 16] {
use std::hash::{BuildHasher, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let state = std::collections::hash_map::RandomState::new();
let mut bytes = [0u8; 16];
for half in 0..2 {
let mut hasher = state.build_hasher();
hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
hasher.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|time| time.as_nanos())
.unwrap_or(0),
);
bytes[half * 8..half * 8 + 8].copy_from_slice(&hasher.finish().to_le_bytes());
}
bytes
}
/// The daemon's working directory, the `process.cwd()` Node's `path.resolve` falls back to.
pub fn cwd() -> String {
std::env::current_dir()
.map(|cwd| cwd.to_string_lossy().into_owned())
.unwrap_or_default()
}

View File

@@ -0,0 +1,323 @@
//! Linux, macOS and Android.
use super::{OpenMode, ShellConfig, path_exists};
use crate::errors::Failure;
use serde_json::{Map, Value};
use std::ffi::{CString, OsStr};
use std::fs::{File, Metadata, OpenOptions};
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt};
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
/// libuv's name for an OS error.
pub fn os_error_name(error: &io::Error) -> &'static str {
let Some(errno) = error.raw_os_error() else {
return "UNKNOWN";
};
match errno {
libc::EPERM => "EPERM",
libc::ENOENT => "ENOENT",
libc::EIO => "EIO",
libc::EBADF => "EBADF",
libc::EAGAIN => "EAGAIN",
libc::ENOMEM => "ENOMEM",
libc::EACCES => "EACCES",
libc::EBUSY => "EBUSY",
libc::EEXIST => "EEXIST",
libc::EXDEV => "EXDEV",
libc::ENOTDIR => "ENOTDIR",
libc::EISDIR => "EISDIR",
libc::EINVAL => "EINVAL",
libc::ENFILE => "ENFILE",
libc::EMFILE => "EMFILE",
libc::ETXTBSY => "ETXTBSY",
libc::EFBIG => "EFBIG",
libc::ENOSPC => "ENOSPC",
libc::EROFS => "EROFS",
libc::EMLINK => "EMLINK",
libc::ENAMETOOLONG => "ENAMETOOLONG",
libc::ENOTEMPTY => "ENOTEMPTY",
libc::ELOOP => "ELOOP",
libc::ENOSYS => "ENOSYS",
libc::ENOTSUP => "ENOTSUP",
_ => "UNKNOWN",
}
}
pub const PATH_SEPARATOR: &str = "/";
pub fn read_at(file: &File, buffer: &mut [u8], offset: u64) -> io::Result<usize> {
file.read_at(buffer, offset)
}
/// Modification time as seconds and nanoseconds since the epoch.
pub fn mtime(metadata: &Metadata) -> (i64, i64) {
(metadata.mtime(), metadata.mtime_nsec())
}
/// Device and inode, which identify a file across renames.
pub fn identity(metadata: &Metadata) -> (u64, u64) {
(metadata.dev(), metadata.ino())
}
/// Node's `os.tmpdir()`; Termux's Node falls back to `$PREFIX/tmp`.
pub fn tmpdir() -> String {
let configured = ["TMPDIR", "TMP", "TEMP"]
.iter()
.find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty()));
let mut path = configured.unwrap_or_else(|| {
if cfg!(target_os = "android") {
format!(
"{}/tmp",
std::env::var("PREFIX")
.unwrap_or_else(|_| "/data/data/com.termux/files/usr".into())
)
} else {
"/tmp".into()
}
});
if path.len() > 1 && path.ends_with('/') {
path.pop();
}
path
}
pub fn home() -> String {
std::env::var("HOME").unwrap_or_default()
}
/// `mkdtemp(3)` of `prefix`, as libuv calls it: six random characters from `[A-Za-z0-9]`.
pub fn mkdtemp(prefix: &str) -> io::Result<String> {
let mut template = prefix.as_bytes().to_vec();
template.extend_from_slice(b"XXXXXX");
let template =
CString::new(template).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?;
let mut buffer = template.into_bytes_with_nul();
// SAFETY: `buffer` is a NUL-terminated, writable template that mkdtemp fills in place.
let created = unsafe { libc::mkdtemp(buffer.as_mut_ptr().cast()) };
if created.is_null() {
return Err(io::Error::last_os_error());
}
buffer.pop();
Ok(OsStr::from_bytes(&buffer).to_string_lossy().into_owned())
}
/// Open a regular file for positional reads, without blocking on FIFOs and, with `no_follow`, without following a
/// final symbolic link.
pub fn open_reader(path: &str, no_follow: bool) -> Result<File, Failure> {
let mut flags = libc::O_NONBLOCK;
if no_follow {
flags |= libc::O_NOFOLLOW;
}
OpenOptions::new()
.read(true)
.custom_flags(flags)
.open(path)
.map_err(|error| {
if no_follow && matches!(error.raw_os_error(), Some(libc::ELOOP) | Some(libc::EMLINK)) {
Failure::new("SYMLINK", "Refusing to follow a symbolic link").path(path)
} else {
Failure::io(&error, "open", path)
}
})
}
/// Open a file in one of Node's write modes.
pub fn open(path: &str, mode: OpenMode) -> io::Result<File> {
let mut options = OpenOptions::new();
match mode {
OpenMode::Read => options.read(true),
OpenMode::Write => options.write(true).create(true).truncate(true),
OpenMode::Append => options.append(true).create(true),
OpenMode::ReadWrite => options.read(true).write(true),
};
options.open(path)
}
pub fn realpath(path: &str) -> io::Result<String> {
std::fs::canonicalize(path).map(|resolved| resolved.to_string_lossy().into_owned())
}
pub fn rename(from: &str, to: &str) -> io::Result<()> {
std::fs::rename(from, to)
}
pub fn remove_file(path: &str) -> io::Result<()> {
std::fs::remove_file(path)
}
/// A pipe a reader thread can poll, so that it stops when the command settles.
pub trait Pipe: io::Read + std::os::fd::AsRawFd {}
impl<T: io::Read + std::os::fd::AsRawFd> Pipe for T {}
/// Read `pipe` until it ends, `on_data` returns false, or `stop` is set; the pipe closes when this returns.
pub fn read_pipe(mut pipe: impl Pipe, stop: &AtomicBool, mut on_data: impl FnMut(&[u8]) -> bool) {
let fd = pipe.as_raw_fd();
// SAFETY: fcntl on a descriptor this thread owns.
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
let mut buffer = vec![0u8; 64 * 1024];
while !stop.load(Ordering::SeqCst) {
let mut poll = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
// SAFETY: one valid pollfd.
let ready = unsafe { libc::poll(&mut poll, 1, 100) };
if ready == 0
|| (ready < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted)
{
continue;
}
if ready < 0 {
return;
}
match pipe.read(&mut buffer) {
Ok(0) => return,
Ok(read) => {
if !on_data(&buffer[..read]) {
return;
}
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) => {}
Err(_) => return,
}
}
}
/// Readers poll `stop` every 100 ms.
pub fn interrupt_reader(_reader: &std::thread::JoinHandle<()>) {}
/// The program an argv command runs; `Command` searches `PATH` like `execvp`, as libuv does.
pub fn resolve_program(
program: &str,
_cwd: &str,
_env: &Map<String, Value>,
) -> Result<String, Failure> {
Ok(program.to_string())
}
fn which_bash() -> Option<String> {
let output = Command::new("which")
.arg("bash")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let first = String::from_utf8_lossy(&output.stdout)
.trim()
.lines()
.next()?
.to_string();
path_exists(&first).then_some(first)
}
/// Node's shell resolution: a configured shell must exist; otherwise `/bin/bash`, `which bash`, then `sh`.
pub fn shell_config(shell_path: Option<&str>) -> Result<ShellConfig, Failure> {
let shell = |program: &str| ShellConfig {
program: program.into(),
args: vec!["-c".into()],
command_on_stdin: false,
};
if let Some(configured) = shell_path {
if path_exists(configured) {
return Ok(shell(configured));
}
return Err(Failure::new(
"shell_unavailable",
format!("Custom shell path not found: {configured}"),
));
}
if path_exists("/bin/bash") {
return Ok(shell("/bin/bash"));
}
Ok(shell(&which_bash().unwrap_or_else(|| "sh".into())))
}
/// Arguments, environment and process setup as libuv applies them.
pub fn configure(
command: &mut Command,
args: &[String],
env: &Map<String, Value>,
inherit_env: bool,
) {
command.args(args);
if !inherit_env {
command.env_clear();
}
for (key, value) in env {
if let Some(value) = value.as_str() {
command.env(key, value);
}
}
// SAFETY: only async-signal-safe calls between fork and exec.
unsafe {
command.pre_exec(|| {
// A new session, so a kill reaches every descendant; default signal handling and an empty mask, as libuv sets.
libc::setsid();
for signal in 1..32 {
if signal != libc::SIGKILL && signal != libc::SIGSTOP {
libc::signal(signal, libc::SIG_DFL);
}
}
let mut mask: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut mask);
libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
Ok(())
});
}
}
pub fn after_spawn(_child: &Child) {}
/// Kill the command's process group, or the process if the group is gone.
pub fn kill_tree(pid: u32) {
let pid = pid as i32;
// SAFETY: plain signal delivery.
unsafe {
if libc::kill(-pid, libc::SIGKILL) != 0 {
libc::kill(pid, libc::SIGKILL);
}
}
}
/// A process killed by a signal has no exit code; report 128 + the signal, as the shell does.
pub fn exit_code(status: ExitStatus) -> i64 {
i64::from(
status
.code()
.unwrap_or_else(|| 128 + status.signal().unwrap_or(0)),
)
}
/// The bytes of a file name, which libuv sorts directory listings by.
pub fn raw_name(name: &OsStr) -> Option<Vec<u8>> {
Some(name.as_bytes().to_vec())
}
/// Windows only: the per-drive working directories of the `=C:` variables.
pub fn drive_cwds() -> Map<String, Value> {
Map::new()
}
pub fn read_dir(path: &str) -> io::Result<std::fs::ReadDir> {
std::fs::read_dir(path)
}
/// Device and inode of `path`; the metadata already has them.
pub fn path_identity(_path: &str, metadata: &Metadata, _follow: bool) -> (u64, u64) {
identity(metadata)
}

View File

@@ -0,0 +1,665 @@
//! Windows, following libuv (`src/win/error.c`, `src/win/process.c`, `src/win/fs.c`) and Node's `child_process`.
use super::{OpenMode, ShellConfig, path_exists, synthetic};
use crate::errors::Failure;
use serde_json::{Map, Value};
use std::collections::hash_map::RandomState;
use std::ffi::OsStr;
use std::fs::{File, Metadata};
use std::hash::{BuildHasher, Hasher};
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::fs::FileExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle};
use std::os::windows::process::CommandExt;
use std::path::Path;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::UNIX_EPOCH;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, CREATE_ALWAYS, CreateFileW, FILE_APPEND_DATA,
FILE_ATTRIBUTE_NORMAL, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
FILE_WRITE_DATA, GetFileInformationByHandle, MOVEFILE_REPLACE_EXISTING, MoveFileExW,
OPEN_ALWAYS, OPEN_EXISTING,
};
use windows_sys::Win32::System::IO::CancelSynchronousIo;
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_BREAKAWAY_OK,
JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectExtendedLimitInformation, SetInformationJobObject,
};
use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
/// libuv's `uv_translate_sys_error` for the errors file and process operations produce.
pub fn os_error_name(error: &io::Error) -> &'static str {
let Some(code) = error.raw_os_error() else {
return "UNKNOWN";
};
match code {
1 => "EISDIR", // ERROR_INVALID_FUNCTION
2 | 3 | 15 | 123 | 161 | 4392 => "ENOENT", // FILE_NOT_FOUND, PATH_NOT_FOUND, INVALID_DRIVE, INVALID_NAME, BAD_PATHNAME, INVALID_REPARSE_DATA
4 => "EMFILE", // ERROR_TOO_MANY_OPEN_FILES
5 | 1314 => "EPERM", // ACCESS_DENIED, PRIVILEGE_NOT_HELD
6 => "EBADF", // ERROR_INVALID_HANDLE
8 | 14 => "ENOMEM", // NOT_ENOUGH_MEMORY, OUTOFMEMORY
17 => "EXDEV", // ERROR_NOT_SAME_DEVICE
19 => "EROFS", // ERROR_WRITE_PROTECT
32 | 33 => "EBUSY", // SHARING_VIOLATION, LOCK_VIOLATION
39 | 82 | 112 => "ENOSPC", // HANDLE_DISK_FULL, CANNOT_MAKE, DISK_FULL
50 => "ENOTSUP", // ERROR_NOT_SUPPORTED
80 | 183 => "EEXIST", // FILE_EXISTS, ALREADY_EXISTS
13 | 87 | 122 | 131 | 1464 => "EINVAL", // INVALID_DATA, INVALID_PARAMETER, INSUFFICIENT_BUFFER, NEGATIVE_SEEK, SYMLINK_NOT_SUPPORTED
126 => "ENOENT", // ERROR_MOD_NOT_FOUND
145 => "ENOTEMPTY", // ERROR_DIR_NOT_EMPTY
206 => "ENAMETOOLONG", // ERROR_FILENAME_EXCED_RANGE
267 => "ENOENT", // ERROR_DIRECTORY (libuv maps it to ENOENT)
998 => "EFAULT", // ERROR_NOACCESS
740 | 1920 => "EACCES", // ELEVATION_REQUIRED, CANT_ACCESS_FILE
1921 => "ELOOP", // ERROR_CANT_RESOLVE_FILENAME
_ => "UNKNOWN",
}
}
pub const PATH_SEPARATOR: &str = "\\";
pub fn read_at(file: &File, buffer: &mut [u8], offset: u64) -> io::Result<usize> {
file.seek_read(buffer, offset)
}
/// Modification time as seconds and nanoseconds since the epoch; before 1970 the seconds are negative.
pub fn mtime(metadata: &Metadata) -> (i64, i64) {
let Ok(time) = metadata.modified() else {
return (0, 0);
};
match time.duration_since(UNIX_EPOCH) {
Ok(after) => (after.as_secs() as i64, i64::from(after.subsec_nanos())),
Err(before) => {
let before = before.duration();
let nanos = i64::from(before.subsec_nanos());
if nanos == 0 {
(-(before.as_secs() as i64), 0)
} else {
(-(before.as_secs() as i64) - 1, 1_000_000_000 - nanos)
}
}
}
}
/// File index numbers need a handle per file; the creation time tells a replaced file or directory apart instead.
pub fn identity(metadata: &Metadata) -> (u64, u64) {
use std::os::windows::fs::MetadataExt;
(0, metadata.creation_time())
}
/// Node's `os.tmpdir()` on Windows.
pub fn tmpdir() -> String {
let variable = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
let mut path = variable("TEMP")
.or_else(|| variable("TMP"))
.unwrap_or_else(|| {
format!(
"{}\\temp",
variable("SystemRoot")
.or_else(|| variable("windir"))
.unwrap_or_default()
)
});
if path.len() > 1 && path.ends_with('\\') && !path.ends_with(":\\") {
path.pop();
}
path
}
/// libuv's `uv_os_homedir`: `USERPROFILE` first.
pub fn home() -> String {
std::env::var("USERPROFILE").unwrap_or_default()
}
/// A NUL-terminated wide path; an embedded NUL fails like the standard library's path conversion (Node rejects such
/// paths before any system call, which the client reports with the same code).
fn wide(text: &str) -> io::Result<Vec<u16>> {
if text.contains('\0') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"path contains a NUL byte",
));
}
Ok(OsStr::new(text)
.encode_wide()
.chain(std::iter::once(0))
.collect())
}
/// libuv's `fs__mkdtemp`: `prefix` plus six characters from `[a-zA-Z0-9]`, retried while the name exists.
pub fn mkdtemp(prefix: &str) -> io::Result<String> {
const CHARACTERS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
static COUNTER: AtomicU64 = AtomicU64::new(0);
let state = RandomState::new();
for _ in 0..1000 {
let mut hasher = state.build_hasher();
hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
let mut random = hasher.finish();
let mut path = prefix.to_string();
for _ in 0..6 {
path.push(CHARACTERS[(random % CHARACTERS.len() as u64) as usize] as char);
random /= CHARACTERS.len() as u64;
}
match std::fs::create_dir(&path) {
Ok(()) => return Ok(path),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(io::Error::from_raw_os_error(80))
}
/// Windows cannot open a directory as a file and has no `O_NOFOLLOW`: check the path first, as `NodeExecutionEnv` does
/// there (not race-free).
pub fn open_reader(path: &str, no_follow: bool) -> Result<File, Failure> {
if no_follow
&& std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(Failure::new("SYMLINK", "Refusing to follow a symbolic link").path(path));
}
if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
return Err(
Failure::new("EISDIR", "EISDIR: illegal operation on a directory, read").path(path),
);
}
File::open(path).map_err(|error| Failure::io(&error, "open", path))
}
/// libuv's `fs__open` for Node's write modes: `CreateFileW` with its access, sharing and disposition, always with
/// `FILE_FLAG_BACKUP_SEMANTICS`, so a directory opens and later calls on it fail as in Node; creating a file where a
/// directory exists reports `EISDIR`. (Rust's `File::create` opens with `OPEN_ALWAYS` and truncates afterwards.)
pub fn open(path: &str, mode: OpenMode) -> io::Result<File> {
let (access, disposition) = match mode {
OpenMode::Read => (FILE_GENERIC_READ, OPEN_EXISTING),
OpenMode::Write => (FILE_GENERIC_WRITE, CREATE_ALWAYS),
OpenMode::Append => (
(FILE_GENERIC_WRITE & !FILE_WRITE_DATA) | FILE_APPEND_DATA,
OPEN_ALWAYS,
),
OpenMode::ReadWrite => (FILE_GENERIC_READ | FILE_GENERIC_WRITE, OPEN_EXISTING),
};
let name = wide(path)?;
// SAFETY: a NUL-terminated wide path; the returned handle is owned by the File on success.
let handle = unsafe {
CreateFileW(
name.as_ptr(),
access,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null(),
disposition,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_BACKUP_SEMANTICS,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
let error = io::Error::last_os_error();
// ERROR_FILE_EXISTS while creating means the path is a directory.
if error.raw_os_error() == Some(80) && matches!(mode, OpenMode::Write | OpenMode::Append) {
return Err(synthetic("EISDIR"));
}
return Err(error);
}
// SAFETY: `handle` is a valid, owned file handle.
Ok(unsafe { File::from_raw_handle(handle as _) })
}
/// libuv's `uv_fs_realpath`: the final path without the `\\?\` prefix.
pub fn realpath(path: &str) -> io::Result<String> {
let resolved = std::fs::canonicalize(path)?.to_string_lossy().into_owned();
Ok(if let Some(unc) = resolved.strip_prefix("\\\\?\\UNC\\") {
format!("\\\\{unc}")
} else {
resolved
.strip_prefix("\\\\?\\")
.map(str::to_string)
.unwrap_or(resolved)
})
}
/// libuv's `fs__rename`: `MoveFileExW` replacing an existing destination.
pub fn rename(from: &str, to: &str) -> io::Result<()> {
let (from, to) = (wide(from)?, wide(to)?);
// SAFETY: both arguments are NUL-terminated wide strings that outlive the call.
if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_REPLACE_EXISTING) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// Node's `rm` on Windows: a symbolic link or junction to a directory is removed as a directory entry (libuv's
/// `fs__unlink`), and a read-only file is retried after making it writable.
pub fn remove_file(path: &str) -> io::Result<()> {
use std::os::windows::fs::FileTypeExt;
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink_dir()) {
return std::fs::remove_dir(path);
}
match std::fs::remove_file(path) {
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
let mut permissions = std::fs::metadata(path)?.permissions();
#[allow(clippy::permissions_set_readonly_false)]
permissions.set_readonly(false);
std::fs::set_permissions(path, permissions)?;
std::fs::remove_file(path)
}
other => other,
}
}
/// A pipe a reader thread reads with blocking calls; `interrupt_reader` cancels a blocked read.
pub trait Pipe: io::Read {}
impl<T: io::Read> Pipe for T {}
/// Read `pipe` until it ends, `on_data` returns false, or `stop` is set; the pipe closes when this returns.
pub fn read_pipe(mut pipe: impl Pipe, stop: &AtomicBool, mut on_data: impl FnMut(&[u8]) -> bool) {
let mut buffer = vec![0u8; 64 * 1024];
while !stop.load(Ordering::SeqCst) {
match pipe.read(&mut buffer) {
Ok(0) | Err(_) => return,
Ok(read) => {
if !on_data(&buffer[..read]) {
return;
}
}
}
}
}
/// Cancel a reader blocked in `ReadFile`, so it sees `stop` and closes its pipe.
pub fn interrupt_reader(reader: &std::thread::JoinHandle<()>) {
// SAFETY: the handle of a live or finished thread; cancelling no pending I/O is harmless.
unsafe {
CancelSynchronousIo(reader.as_raw_handle() as _);
}
}
/// libuv's `search_path` for an argv command: a name with a directory is tried as given, otherwise the working
/// directory and then each `PATH` entry; a name without an extension also tries `.com` and `.exe`. Node refuses
/// batch files without a shell.
pub fn resolve_program(
program: &str,
cwd: &str,
env: &Map<String, Value>,
) -> Result<String, Failure> {
let not_found = || Failure::new("spawn_error", format!("spawn {program} ENOENT"));
let has_extension = Path::new(program).extension().is_some();
let candidates = |base: &Path| -> Vec<std::path::PathBuf> {
let mut names = Vec::new();
if has_extension {
names.push(base.to_path_buf());
}
for extension in ["com", "exe"] {
let mut name = base.as_os_str().to_owned();
name.push(".");
name.push(extension);
names.push(name.into());
}
names
};
let found = if program.contains(['\\', '/', ':']) {
candidates(&Path::new(cwd).join(program))
.into_iter()
.find(|path| path.is_file())
} else {
let path_variable = env
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case("PATH"))
.and_then(|(_, value)| value.as_str().map(str::to_string))
.or_else(|| std::env::var("PATH").ok())
.unwrap_or_default();
std::iter::once(std::path::PathBuf::from(cwd))
.chain(std::env::split_paths(&path_variable))
.flat_map(|directory| candidates(&directory.join(program)))
.find(|path| path.is_file())
};
let found = found.ok_or_else(not_found)?;
let lower = found.to_string_lossy().to_lowercase();
if lower.ends_with(".bat") || lower.ends_with(".cmd") {
return Err(Failure::new(
"spawn_error",
format!("spawn {program} EINVAL"),
));
}
Ok(found.to_string_lossy().into_owned())
}
/// Node's `isLegacyWslBashPath`: `^[a-z]:\\windows\\(system32|sysnative)\\bash\.exe$`, case-insensitive.
fn is_legacy_wsl_bash(path: &str) -> bool {
let normalized = path.replace('/', "\\").to_lowercase();
let mut characters = normalized.chars();
characters
.next()
.is_some_and(|drive| drive.is_ascii_lowercase())
&& matches!(
characters.as_str(),
":\\windows\\system32\\bash.exe" | ":\\windows\\sysnative\\bash.exe"
)
}
fn bash_config(program: &str) -> ShellConfig {
if is_legacy_wsl_bash(program) {
ShellConfig {
program: program.into(),
args: vec!["-s".into()],
command_on_stdin: true,
}
} else {
ShellConfig {
program: program.into(),
args: vec!["-c".into()],
command_on_stdin: false,
}
}
}
fn where_bash() -> Option<String> {
let output = Command::new("where")
.arg("bash.exe")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let first = String::from_utf8_lossy(&output.stdout)
.trim()
.lines()
.next()?
.trim()
.to_string();
path_exists(&first).then_some(first)
}
/// Node's shell resolution on Windows: a configured shell, Git Bash, `where bash.exe`, or `shell_unavailable`.
pub fn shell_config(shell_path: Option<&str>) -> Result<ShellConfig, Failure> {
if let Some(configured) = shell_path {
if path_exists(configured) {
return Ok(bash_config(configured));
}
return Err(Failure::new(
"shell_unavailable",
format!("Custom shell path not found: {configured}"),
));
}
let mut candidates = Vec::new();
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Ok(directory) = std::env::var(variable) {
candidates.push(format!("{directory}\\Git\\bin\\bash.exe"));
}
}
if let Some(found) = candidates.iter().find(|candidate| path_exists(candidate)) {
return Ok(bash_config(found));
}
if let Some(found) = where_bash() {
return Ok(bash_config(&found));
}
let searched: String = candidates
.iter()
.map(|candidate| format!(" {candidate}\n"))
.collect();
Err(Failure::new(
"shell_unavailable",
format!(
"No bash shell found. Options:\n 1. Install Git for Windows: https://git-scm.com/download/win\n 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n 3. Configure an explicit shellPath\n\nSearched Git Bash in:\n{}",
searched.trim_end()
),
))
}
/// libuv's `quote_cmd_arg`: how one argument is written into the command line the child splits again.
pub fn quote_argument(argument: &str) -> String {
if argument.is_empty() {
return "\"\"".into();
}
if !argument.contains([' ', '\t', '"']) {
return argument.into();
}
if !argument.contains(['"', '\\']) {
return format!("\"{argument}\"");
}
// Walk backwards: backslashes before a quote (or the closing quote) are doubled, and quotes are escaped.
let mut reversed = Vec::new();
let mut quote_hit = true;
for character in argument.chars().rev() {
reversed.push(character);
if quote_hit && character == '\\' {
reversed.push('\\');
} else if character == '"' {
quote_hit = true;
reversed.push('\\');
} else {
quote_hit = false;
}
}
let mut quoted = String::from("\"");
quoted.extend(reversed.into_iter().rev());
quoted.push('"');
quoted
}
/// Variables libuv copies from the parent into every child environment that lacks them.
const REQUIRED_VARIABLES: [&str; 11] = [
"HOMEDRIVE",
"HOMEPATH",
"LOGONSERVER",
"PATH",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"USERDOMAIN",
"USERNAME",
"USERPROFILE",
"WINDIR",
];
/// The child environment as Node builds it on Windows: the variables in object order, sorted by UTF-16 code units,
/// keeping the first of names that differ only in case; then libuv adds required variables the result lacks.
fn child_environment(env: &Map<String, Value>, inherit_env: bool) -> Vec<(String, String)> {
let mut entries: Vec<(String, String)> = Vec::new();
let mut set =
|key: String, value: String| match entries.iter_mut().find(|(name, _)| *name == key) {
Some(entry) => entry.1 = value,
None => entries.push((key, value)),
};
if inherit_env {
for (key, value) in std::env::vars_os() {
set(
key.to_string_lossy().into_owned(),
value.to_string_lossy().into_owned(),
);
}
}
for (key, value) in env {
if let Some(value) = value.as_str() {
set(key.clone(), value.to_string());
}
}
entries.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
let mut seen = std::collections::HashSet::new();
entries.retain(|(name, _)| seen.insert(name.to_uppercase()));
for required in REQUIRED_VARIABLES {
if !entries
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case(required))
&& let Ok(value) = std::env::var(required)
{
entries.push((required.to_string(), value));
}
}
entries
}
/// Arguments quoted as libuv quotes them, Node's environment, and a hidden console.
pub fn configure(
command: &mut Command,
args: &[String],
env: &Map<String, Value>,
inherit_env: bool,
) {
for argument in args {
command.raw_arg(quote_argument(argument));
}
command.env_clear();
for (key, value) in child_environment(env, inherit_env) {
command.env(key, value);
}
command.creation_flags(CREATE_NO_WINDOW);
}
/// libuv puts every child it starts into one job that ends its processes when the daemon exits.
fn job() -> Option<isize> {
static JOB: OnceLock<Option<isize>> = OnceLock::new();
*JOB.get_or_init(|| {
// SAFETY: plain Win32 calls with a zeroed, correctly sized information struct.
unsafe {
let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if job.is_null() {
return None;
}
let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_BREAKAWAY_OK
| JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK
| JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION
| JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
(&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
Some(job as isize)
}
})
}
pub fn after_spawn(child: &Child) {
if let Some(job) = job() {
// SAFETY: both handles are valid for the duration of the call.
unsafe {
AssignProcessToJobObject(job as _, child.as_raw_handle() as _);
}
}
}
/// Node's `killProcessTree` on Windows: `taskkill /F /T`, not waited for.
pub fn kill_tree(pid: u32) {
let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
let _ = Command::new(format!("{system_root}\\System32\\taskkill.exe"))
.args(["/F", "/T", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
/// Exit codes are unsigned 32-bit values on Windows (an access violation is 3221225477).
pub fn exit_code(status: ExitStatus) -> i64 {
i64::from(status.code().unwrap_or(1) as u32)
}
/// Windows listings keep the file system's order; nothing sorts by raw names.
pub fn raw_name(_name: &OsStr) -> Option<Vec<u8>> {
None
}
/// The per-drive working directories of the `=C:` variables, which Node's `path.resolve` uses for drive-relative paths.
pub fn drive_cwds() -> Map<String, Value> {
std::env::vars_os()
.filter_map(|(key, value)| {
let key = key.to_str()?;
let bytes = key.as_bytes();
(bytes.len() == 3
&& bytes[0] == b'='
&& bytes[1].is_ascii_alphabetic()
&& bytes[2] == b':')
.then(|| {
(
key[1..].to_ascii_uppercase(),
Value::String(value.to_string_lossy().into_owned()),
)
})
})
.collect()
}
/// libuv's `scandir` and `opendir` report a file as `ENOTDIR` (elsewhere `ERROR_DIRECTORY` is `ENOENT`).
pub fn read_dir(path: &str) -> io::Result<std::fs::ReadDir> {
std::fs::read_dir(path).map_err(|error| {
if error.raw_os_error() == Some(267) {
synthetic("ENOTDIR")
} else {
error
}
})
}
/// Volume serial number and file index of `path`, as libuv's `stat` reports `dev` and `ino`: the creation time is not
/// enough, because NTFS gives a file created under a recently removed name the old one's creation time ("tunneling").
/// Opens a handle, like libuv; falls back to the metadata when the path cannot be opened.
pub fn path_identity(path: &str, metadata: &Metadata, follow: bool) -> (u64, u64) {
let Ok(name) = wide(path) else {
return identity(metadata);
};
let flags = FILE_FLAG_BACKUP_SEMANTICS
| if follow {
0
} else {
FILE_FLAG_OPEN_REPARSE_POINT
};
// SAFETY: a NUL-terminated wide path; the handle is closed below.
let handle = unsafe {
CreateFileW(
name.as_ptr(),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null(),
OPEN_EXISTING,
flags,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return identity(metadata);
}
// SAFETY: a valid handle and a zeroed buffer for the call to fill.
let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let found = unsafe { GetFileInformationByHandle(handle, &mut information) } != 0;
// SAFETY: the handle opened above.
unsafe { CloseHandle(handle) };
if !found {
return identity(metadata);
}
(
u64::from(information.dwVolumeSerialNumber),
(u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow),
)
}
#[cfg(test)]
mod tests {
use super::quote_argument;
#[test]
fn quotes_arguments_like_libuv() {
assert_eq!(quote_argument(""), "\"\"");
assert_eq!(quote_argument("plain"), "plain");
assert_eq!(quote_argument("a b"), "\"a b\"");
assert_eq!(quote_argument("*.txt"), "*.txt");
assert_eq!(quote_argument("say \"hi\""), "\"say \\\"hi\\\"\"");
assert_eq!(
quote_argument("C:\\dir with space\\"),
"\"C:\\dir with space\\\\\""
);
assert_eq!(quote_argument("a\\\\b c"), "\"a\\\\b c\"");
}
}

View File

@@ -0,0 +1,778 @@
//! `watch`: Durable's `NodeFileWatcher` (packages/durable/src/env/node-watch.ts) running next to the files, so a scan
//! costs no round trips. Native events only trigger a debounced rescan; changes are the difference between snapshots
//! plus the event paths. Events go out as `change` events of the request, which lasts until it is cancelled.
use crate::errors::Failure;
use crate::exec::Control;
use crate::frame::{EVENT, Frame};
use crate::output::Output;
use crate::sys;
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet};
use std::fs::{self, Metadata};
use std::hash::Hasher;
use std::io;
use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::time::{Duration, Instant, SystemTime};
const DEBOUNCE: Duration = Duration::from_millis(50);
/// macOS: FSEvents streams go live after `watch` returns; a rescan this long after installing watchers catches changes
/// made in between.
const FSEVENTS_SETTLE: Duration = Duration::from_millis(500);
const DEFAULT_POLL_MS: u64 = 2000;
const DEFAULT_MAX_DIRECTORIES: usize = 10_000;
/// In polling mode, recently modified small files are also compared by content.
const HASH_MAX_BYTES: u64 = 256 * 1024;
const HASH_RECENT: Duration = Duration::from_secs(5);
/// How often a waiting watcher looks for a cancel.
const CANCEL_POLL: Duration = Duration::from_millis(100);
/// Linux `statfs` magic numbers of file systems that accept watches but do not report changes made elsewhere.
#[cfg(any(target_os = "linux", target_os = "android"))]
const UNRELIABLE_FILE_SYSTEMS: [u64; 12] = [
0x6969, // NFS
0x517b, // SMB
0xff534d42, // CIFS
0xfe534d42, // SMB2
0x65735546, // FUSE (sshfs, Android shared storage)
0x01021997, // 9P (WSL2 Windows drives)
0x0bd00bd0, // Lustre
0x47504653, // GPFS
0x00c36400, // Ceph
0x5346414f, // OpenAFS
0x6b414653, // kAFS
0x5dca2df5, // sdcardfs
];
struct Target {
path: String,
recursive: bool,
hidden: bool,
names: HashSet<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
File,
Directory,
Symlink,
Other,
}
/// What a snapshot remembers of one path. Directories and ancestors are compared by identity only.
#[derive(Clone, PartialEq, Eq)]
struct Entry {
kind: Kind,
dev: u64,
ino: u64,
size: u64,
mtime: (i64, i64),
hash: Option<u64>,
}
type Snapshot = HashMap<String, Entry>;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Native,
Polling,
}
impl Mode {
fn name(self) -> &'static str {
match self {
Mode::Native => "native",
Mode::Polling => "polling",
}
}
}
enum ScanError {
Budget(usize),
Io(io::Error, &'static str, String),
}
impl ScanError {
/// The error `NodeExecutionEnv.watch` returns when opening fails.
fn failure(&self) -> Failure {
match self {
ScanError::Budget(max) => {
Failure::new("EINVAL", format!("Watched paths exceed {max} directories"))
}
ScanError::Io(error, syscall, path) => Failure::io(error, syscall, path),
}
}
/// The `FileError` code `NodeFileWatcher` reports when a later rescan fails.
fn code(&self) -> &'static str {
match self {
ScanError::Io(error, ..) if denied(error) => "permission_denied",
_ => "invalid",
}
}
}
fn denied(error: &io::Error) -> bool {
error.kind() == io::ErrorKind::PermissionDenied
}
fn kind_of(metadata: &Metadata) -> Kind {
let file_type = metadata.file_type();
if file_type.is_file() {
Kind::File
} else if file_type.is_dir() {
Kind::Directory
} else if file_type.is_symlink() {
Kind::Symlink
} else {
Kind::Other
}
}
/// What a snapshot remembers of `path`, whose `metadata` came from `stat` (`follow`) or `lstat`.
fn entry_of(path: &str, metadata: &Metadata, follow: bool, hash: Option<u64>) -> Entry {
let kind = kind_of(metadata);
let (dev, ino) = sys::path_identity(path, metadata, follow);
let directory = kind == Kind::Directory;
Entry {
kind,
dev,
ino,
size: if directory { 0 } else { metadata.len() },
mtime: if directory {
(0, 0)
} else {
sys::mtime(metadata)
},
hash,
}
}
/// Ancestors of `path`, nearest first, up to the root.
fn ancestors(path: &str) -> Vec<String> {
Path::new(path)
.ancestors()
.skip(1)
.map(|ancestor| ancestor.to_string_lossy().into_owned())
.collect()
}
fn with_separator(directory: &str) -> String {
if directory.ends_with(sys::PATH_SEPARATOR) {
directory.to_string()
} else {
format!("{directory}{}", sys::PATH_SEPARATOR)
}
}
/// Whether `path` is `ancestor` or below it.
fn is_within(path: &str, ancestor: &str) -> bool {
path == ancestor || path.starts_with(&with_separator(ancestor))
}
fn excluded(target: &Target, name: &str) -> bool {
(target.hidden && name.starts_with('.')) || target.names.contains(name)
}
/// JavaScript's default sort: by UTF-16 code units.
fn sort_like_javascript(paths: &mut [String]) {
paths.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
}
/// Whether any path, or its nearest existing ancestor, is on a file system that does not report remote changes.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn any_unreliable(targets: &[Target]) -> bool {
use std::ffi::CString;
for target in targets {
for candidate in std::iter::once(target.path.clone()).chain(ancestors(&target.path)) {
let Ok(name) = CString::new(candidate) else {
break;
};
// SAFETY: a NUL-terminated path and a zeroed statfs buffer for the call to fill.
let mut info: libc::statfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statfs(name.as_ptr(), &mut info) } != 0 {
continue;
}
#[allow(clippy::unnecessary_cast)]
let kind = (info.f_type as u64) & 0xffff_ffff;
if UNRELIABLE_FILE_SYSTEMS.contains(&kind) {
return true;
}
break;
}
}
false
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
fn any_unreliable(_targets: &[Target]) -> bool {
false
}
/// One scan: the snapshot, and targets that are symbolic links to files, whose files need their own watchers.
struct Scan {
snapshot: Snapshot,
linked_files: HashSet<String>,
/// Directories counted against the limit, and directories traversed per target.
counted: HashSet<String>,
traversed: HashSet<(usize, String)>,
/// Kinds of listed entries without following links.
listed: HashMap<String, Kind>,
}
struct Installed {
dev: u64,
ino: u64,
/// The watched path with links resolved, as FSEvents spells event paths (`/private/var` for `/var`).
canonical: Option<String>,
}
struct FileWatcher<'a> {
id: u32,
output: &'a Output,
control: &'a Control,
targets: Vec<Target>,
mode: Mode,
poll_interval: Duration,
max_directories: usize,
snapshot: Snapshot,
native: Option<RecommendedWatcher>,
events_sender: Sender<notify::Result<Event>>,
installed: HashMap<String, Installed>,
events: HashSet<String>,
settle_at: Option<Instant>,
}
impl FileWatcher<'_> {
fn send(&self, json: Value) {
self.output.control(Frame::new(EVENT, self.id, json));
}
fn cancelled(&self) -> bool {
self.control.aborted.load(Ordering::SeqCst)
}
fn record(&self, scan: &mut Scan, path: &str, metadata: &Metadata, follow: bool) {
let mut hash = None;
let recent = metadata
.modified()
.map(|modified| {
SystemTime::now()
.duration_since(modified)
.map_or(true, |age| age < HASH_RECENT)
})
.unwrap_or(false);
if self.mode == Mode::Polling
&& metadata.is_file()
&& metadata.len() <= HASH_MAX_BYTES
&& recent
{
hash = fs::read(path).ok().map(|content| {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
hasher.write(&content);
hasher.finish()
});
}
scan.snapshot
.insert(path.to_string(), entry_of(path, metadata, follow, hash));
}
fn count(&self, scan: &mut Scan, path: &str) -> Result<(), ScanError> {
scan.counted.insert(path.to_string());
if scan.counted.len() > self.max_directories {
return Err(ScanError::Budget(self.max_directories));
}
Ok(())
}
fn scan_directory(
&self,
scan: &mut Scan,
index: usize,
directory: &str,
) -> Result<(), ScanError> {
let target = &self.targets[index];
if !scan.traversed.insert((index, directory.to_string())) {
return Ok(());
}
let skip = |error: io::Error| -> Result<(), ScanError> {
// The watched directory itself must be readable; below it, unreadable directories are skipped.
if directory == target.path && denied(&error) {
return Err(ScanError::Io(error, "scandir", directory.to_string()));
}
match error.kind() {
io::ErrorKind::NotFound
| io::ErrorKind::PermissionDenied
| io::ErrorKind::NotADirectory => Ok(()),
_ => Err(ScanError::Io(error, "scandir", directory.to_string())),
}
};
let mut names = Vec::new();
match fs::read_dir(directory) {
Err(error) => return skip(error),
Ok(entries) => {
for entry in entries {
match entry {
Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
Err(error) => return skip(error),
}
}
}
}
let prefix = with_separator(directory);
for name in names {
if excluded(target, &name) {
continue;
}
let path = format!("{prefix}{name}");
let kind = match scan.listed.get(&path) {
Some(kind) => *kind,
None => {
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
let kind = kind_of(&metadata);
scan.listed.insert(path.clone(), kind);
// A target's own entry (following links) wins over its listing by another target.
if !scan.snapshot.contains_key(&path) {
self.record(scan, &path, &metadata, false);
}
kind
}
};
if target.recursive && kind == Kind::Directory {
self.count(scan, &path)?;
self.scan_directory(scan, index, &path)?;
}
}
Ok(())
}
fn scan(&self) -> Result<Scan, ScanError> {
let mut scan = Scan {
snapshot: Snapshot::new(),
linked_files: HashSet::new(),
counted: HashSet::new(),
traversed: HashSet::new(),
listed: HashMap::new(),
};
for (index, target) in self.targets.iter().enumerate() {
for ancestor in ancestors(&target.path) {
if scan.snapshot.contains_key(&ancestor) {
continue;
}
// Identity only: an ancestor's own timestamps change with every unrelated sibling.
if let Ok(metadata) = fs::symlink_metadata(&ancestor) {
let entry = Entry {
size: 0,
mtime: (0, 0),
..entry_of(&ancestor, &metadata, false, None)
};
scan.snapshot.insert(ancestor, entry);
}
}
// The target may be a symbolic link to what is watched; follow it. A missing target is watched for its
// creation; one that cannot be reached for lack of permission fails.
let metadata = match fs::metadata(&target.path) {
Ok(metadata) => metadata,
Err(error) if denied(&error) => {
return Err(ScanError::Io(error, "stat", target.path.clone()));
}
Err(_) => continue,
};
self.record(&mut scan, &target.path, &metadata, true);
if metadata.is_file()
&& fs::symlink_metadata(&target.path)
.is_ok_and(|link| link.file_type().is_symlink())
{
scan.linked_files.insert(target.path.clone());
}
if metadata.is_dir() {
self.count(&mut scan, &target.path)?;
self.scan_directory(&mut scan, index, &target.path)?;
}
}
Ok(scan)
}
/// An ancestor that changed identity moved every target below it; report those targets.
fn reported(&self, path: &str) -> String {
if self
.targets
.iter()
.any(|target| is_within(path, &target.path))
{
return path.to_string();
}
self.targets
.iter()
.find(|target| is_within(&target.path, path))
.map_or_else(|| path.to_string(), |target| target.path.clone())
}
fn diff(&self, previous: &Snapshot, next: &Snapshot) -> Vec<String> {
let mut changed = Vec::new();
for (path, entry) in next {
if previous.get(path) != Some(entry) {
changed.push(self.reported(path));
}
}
for path in previous.keys() {
if !next.contains_key(path) {
changed.push(self.reported(path));
}
}
changed
}
/// Rescan, report differences, and install watchers for new directories, rescanning until none are new.
fn sync(&mut self, mut report: bool) -> Result<HashSet<String>, ScanError> {
let mut changed = HashSet::new();
for _ in 0..10 {
if self.cancelled() {
break;
}
let scan = self.scan()?;
if report {
changed.extend(self.diff(&self.snapshot, &scan.snapshot));
}
self.snapshot = scan.snapshot;
if self.mode == Mode::Polling || !self.reconcile(&scan.linked_files) {
break;
}
if cfg!(target_os = "macos") {
self.settle_at = Some(Instant::now() + FSEVENTS_SETTLE);
}
// Something written into a new directory before its watcher existed shows up in the next round.
report = true;
}
Ok(changed)
}
/// Watch every existing ancestor of each target, each target directory, each target that links to a file, and on
/// Linux each directory below a recursive target (elsewhere one recursive watcher per target). Returns whether a
/// watcher was added.
fn reconcile(&mut self, linked_files: &HashSet<String>) -> bool {
let per_directory = cfg!(any(target_os = "linux", target_os = "android"));
let mut wanted: HashMap<String, bool> = linked_files
.iter()
.map(|path| (path.clone(), false))
.collect();
for target in &self.targets {
for ancestor in ancestors(&target.path) {
if self
.snapshot
.get(&ancestor)
.is_some_and(|entry| entry.kind == Kind::Directory)
{
wanted.entry(ancestor).or_insert(false);
}
}
if self
.snapshot
.get(&target.path)
.is_none_or(|entry| entry.kind != Kind::Directory)
{
continue;
}
wanted.insert(target.path.clone(), target.recursive && !per_directory);
if target.recursive && per_directory {
for (path, entry) in &self.snapshot {
if entry.kind == Kind::Directory
&& *path != target.path
&& is_within(path, &target.path)
{
wanted.insert(path.clone(), false);
}
}
}
}
// Gone, or replaced: a watcher follows the directory it was installed on, not the path.
let stale: Vec<String> = self
.installed
.iter()
.filter(|(path, installed)| {
!wanted.contains_key(*path)
|| self.snapshot.get(*path).is_none_or(|entry| {
entry.dev != installed.dev || entry.ino != installed.ino
})
})
.map(|(path, _)| path.clone())
.collect();
for path in stale {
self.installed.remove(&path);
if let Some(native) = &mut self.native {
let _ = native.unwatch(Path::new(&path));
}
}
let mut added = false;
for (path, recursive) in wanted {
let Some(entry) = self.snapshot.get(&path) else {
continue;
};
if self.installed.contains_key(&path) {
continue;
}
let (dev, ino) = (entry.dev, entry.ino);
if self.native.is_none() {
let sender = self.events_sender.clone();
match RecommendedWatcher::new(
move |event| drop(sender.send(event)),
notify::Config::default(),
) {
Ok(native) => self.native = Some(native),
Err(_) => {
self.switch_to_polling();
return false;
}
}
}
let mode = if recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
match self.native.as_mut().unwrap().watch(Path::new(&path), mode) {
Ok(()) => {
let canonical = fs::canonicalize(&path)
.ok()
.map(|canonical| canonical.to_string_lossy().into_owned())
.filter(|canonical| *canonical != path);
self.installed.insert(
path,
Installed {
dev,
ino,
canonical,
},
);
added = true;
}
Err(error) => {
let gone = match &error.kind {
notify::ErrorKind::PathNotFound => true,
notify::ErrorKind::Io(io) => {
matches!(
io.kind(),
io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
)
}
_ => false,
};
// Out of watches or unsupported: compare snapshots from now on, and say coverage was uncertain.
if !gone {
self.switch_to_polling();
return false;
}
}
}
}
added
}
fn switch_to_polling(&mut self) {
if self.mode == Mode::Polling {
return;
}
self.mode = Mode::Polling;
self.native = None;
self.installed.clear();
self.send(json!({ "kind": "change", "overflow": true, "mode": "polling" }));
}
fn in_scope(&self, path: &str) -> bool {
for target in &self.targets {
if is_within(&target.path, path) {
return true;
}
if !is_within(path, &target.path) || path == target.path {
continue;
}
let relative = &path[with_separator(&target.path).len()..];
let components: Vec<&str> = relative.split(sys::PATH_SEPARATOR).collect();
if !target.recursive && components.len() > 1 {
continue;
}
if components.iter().any(|name| excluded(target, name)) {
continue;
}
return true;
}
false
}
/// An event path in the spelling of the watched path it is under.
fn watched_spelling(&self, path: &str) -> String {
let mut best: Option<(&str, &str)> = None;
for (watched, installed) in &self.installed {
if let Some(canonical) = &installed.canonical
&& is_within(path, canonical)
&& best.is_none_or(|(_, longest)| canonical.len() > longest.len())
{
best = Some((watched, canonical));
}
}
match best {
Some((watched, canonical)) => format!("{watched}{}", &path[canonical.len()..]),
None => path.to_string(),
}
}
/// Note a native event; returns whether it calls for a rescan.
fn on_event(&mut self, event: notify::Result<Event>) -> bool {
let event = match event {
Ok(event) => event,
Err(error) => {
// A watched directory that disappears or fails: rescan, which also replaces its watcher.
for path in &error.paths {
self.installed.remove(&*path.to_string_lossy());
}
return true;
}
};
// Reads are not changes (libuv does not ask for them).
if matches!(event.kind, EventKind::Access(_)) {
return false;
}
if event.need_rescan() || event.paths.is_empty() {
return true;
}
let mut flush = false;
for path in &event.paths {
let path = self.watched_spelling(&path.to_string_lossy());
// Events about unrelated siblings of an ancestor, or about excluded entries, are ignored.
if self.in_scope(&path) {
let reported = self.reported(&path);
self.events.insert(reported);
flush = true;
}
}
flush
}
/// Rescan and report what changed; an error ends the watcher.
fn flush(&mut self) -> bool {
let events = std::mem::take(&mut self.events);
match self.sync(true) {
Ok(mut changed) => {
changed.extend(events);
if !changed.is_empty() && !self.cancelled() {
let mut paths: Vec<String> = changed.into_iter().collect();
sort_like_javascript(&mut paths);
self.send(json!({ "kind": "change", "paths": paths }));
}
true
}
Err(error) => {
let message = error.failure().message;
self.send(json!({ "kind": "error", "code": error.code(), "message": message }));
false
}
}
}
}
fn parse_targets(json: &Value) -> Result<Vec<Target>, Failure> {
let invalid = || Failure::new("EINVAL", "watch needs targets");
json["targets"]
.as_array()
.ok_or_else(invalid)?
.iter()
.map(|target| {
Ok(Target {
path: target["path"].as_str().ok_or_else(invalid)?.to_string(),
recursive: target["recursive"].as_bool().unwrap_or(false),
hidden: target["exclude"]["hidden"].as_bool().unwrap_or(false),
names: target["exclude"]["names"]
.as_array()
.map(|names| {
names
.iter()
.filter_map(|name| name.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
})
})
.collect()
}
/// Watch until cancelled: a `ready` event once coverage is established, then `change` events, or an `error` event
/// after which nothing follows.
pub fn run(id: u32, json: &Value, output: &Output, control: &Control) -> Result<Value, Failure> {
let targets = parse_targets(json)?;
// Windows refuses to rename a directory while a directory below it is open, and native watchers keep watched
// directories open, so Windows polls, as do file systems that do not report remote changes.
let mode = match json["mode"].as_str() {
Some("native") => Mode::Native,
Some("polling") => Mode::Polling,
_ if cfg!(windows) || any_unreliable(&targets) => Mode::Polling,
_ => Mode::Native,
};
let (events_sender, events) = mpsc::channel();
let mut watcher = FileWatcher {
id,
output,
control,
targets,
mode,
poll_interval: Duration::from_millis(
json["pollIntervalMs"].as_u64().unwrap_or(DEFAULT_POLL_MS),
),
max_directories: json["maxDirectories"]
.as_u64()
.map_or(DEFAULT_MAX_DIRECTORIES, |max| max as usize),
snapshot: Snapshot::new(),
native: None,
events_sender,
installed: HashMap::new(),
events: HashSet::new(),
settle_at: None,
};
watcher.sync(false).map_err(|error| error.failure())?;
watcher.send(json!({ "kind": "ready", "mode": watcher.mode.name() }));
let mut flush_at: Option<Instant> = None;
let mut poll_at = Instant::now() + watcher.poll_interval;
loop {
if watcher.cancelled() {
return Ok(json!({}));
}
let now = Instant::now();
let mut wake = now + CANCEL_POLL;
for at in [flush_at, watcher.settle_at].into_iter().flatten() {
wake = wake.min(at);
}
if watcher.mode == Mode::Polling {
wake = wake.min(poll_at);
}
match events.recv_timeout(wake.saturating_duration_since(now)) {
Ok(event) => {
if watcher.on_event(event) && flush_at.is_none() && watcher.mode == Mode::Native {
flush_at = Some(Instant::now() + DEBOUNCE);
}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => unreachable!("the watcher holds a sender"),
}
let now = Instant::now();
let due = |at: Option<Instant>| at.is_some_and(|at| at <= now);
let polling_due = watcher.mode == Mode::Polling && poll_at <= now;
if due(flush_at) || due(watcher.settle_at) || polling_due {
if due(flush_at) {
flush_at = None;
}
if due(watcher.settle_at) {
watcher.settle_at = None;
}
if !watcher.flush() {
return Ok(json!({}));
}
if polling_due {
poll_at = Instant::now() + watcher.poll_interval;
}
}
}
}

View File

@@ -0,0 +1,269 @@
//! Durable's output window (`ShellExecOptions.window`, `ShellOutputInfo.skipped`): undelivered command output is held
//! and coalesced; text followed by more than the window (by a byte or a line) can never be in the caller's tail and is
//! replaced by counts. Delivery follows the caller's commit pace, and waits while an earlier frame is still unsent.
use serde_json::{Value, json};
use std::time::{Duration, Instant};
#[derive(Clone, Copy)]
pub struct Window {
pub max_bytes: usize,
pub max_lines: usize,
pub min_interval: Duration,
pub bytes_per_second: f64,
}
impl Window {
pub fn from_json(json: &Value) -> Option<Window> {
let object = json.as_object()?;
Some(Window {
max_bytes: object.get("maxBytes")?.as_u64()? as usize,
max_lines: object.get("maxLines")?.as_u64()? as usize,
min_interval: Duration::from_secs_f64(
object.get("minIntervalMs")?.as_f64()?.max(0.0) / 1000.0,
),
bytes_per_second: object.get("bytesPerSecond")?.as_f64()?.max(1.0),
})
}
}
#[derive(Default)]
struct Skipped {
bytes: u64,
newlines: u64,
ends_with_newline: bool,
}
/// One output event: its stream, text, and the output omitted right before it.
pub struct Event {
pub stream: &'static str,
pub text: String,
pub skipped: Option<Value>,
}
pub struct Pending {
window: Window,
/// Undelivered text in arrival order, by stream.
segments: Vec<(&'static str, String)>,
bytes: usize,
newlines: usize,
skipped: Option<Skipped>,
next_send: Instant,
}
fn newline_count(text: &str) -> usize {
text.bytes().filter(|byte| *byte == b'\n').count()
}
impl Pending {
pub fn new(window: Window) -> Pending {
Pending {
window,
segments: Vec::new(),
bytes: 0,
newlines: 0,
skipped: None,
next_send: Instant::now(),
}
}
pub fn is_empty(&self) -> bool {
self.segments.is_empty()
}
/// When the next delivery may happen.
pub fn next_send(&self) -> Instant {
self.next_send
}
pub fn push(&mut self, stream: &'static str, text: String) {
if text.is_empty() {
return;
}
self.bytes += text.len();
self.newlines += newline_count(&text);
match self.segments.last_mut() {
Some((last, existing)) if *last == stream => existing.push_str(&text),
_ => self.segments.push((stream, text)),
}
// Trimming costs a pass over the held text; do it once a few windows have accumulated.
if self.bytes > 4 * (self.window.max_bytes + 1)
|| self.newlines > 4 * (self.window.max_lines + 1)
{
self.trim();
}
}
/// Whether the text after a cut still proves the cut text is outside the window.
fn proves(&self, bytes: usize, newlines: usize) -> bool {
bytes > self.window.max_bytes || newlines > self.window.max_lines
}
/// Replace the longest prefix whose remainder still proves exclusion by counts.
fn trim(&mut self) {
let mut removed = String::new();
// Whole segments first.
while self.segments.len() > 1 {
let (bytes, newlines) = {
let first = &self.segments[0].1;
(first.len(), newline_count(first))
};
if !self.proves(self.bytes - bytes, self.newlines - newlines) {
break;
}
let (_, text) = self.segments.remove(0);
self.bytes -= bytes;
self.newlines -= newlines;
removed.push_str(&text);
}
// Then a prefix of the first remaining segment, at a character boundary.
let rest_bytes = self.bytes - self.segments[0].1.len();
let rest_newlines = self.newlines - newline_count(&self.segments[0].1);
let first = &self.segments[0].1;
let mut cut = 0;
// By bytes: keep more than `max_bytes` overall.
let keep_bytes = (self.window.max_bytes + 1).saturating_sub(rest_bytes);
if first.len() > keep_bytes {
let mut index = first.len() - keep_bytes;
while index > 0 && !first.is_char_boundary(index) {
index -= 1;
}
cut = cut.max(index);
}
// By lines: keep more than `max_lines` newlines overall, starting at a newline of this segment.
let keep_newlines = (self.window.max_lines + 1).saturating_sub(rest_newlines);
if keep_newlines > 0 {
let positions: Vec<usize> = first
.bytes()
.enumerate()
.filter(|(_, byte)| *byte == b'\n')
.map(|(index, _)| index)
.collect();
if positions.len() >= keep_newlines {
cut = cut.max(positions[positions.len() - keep_newlines]);
}
} else {
cut = first.len();
}
if cut > 0 {
let first = &mut self.segments[0].1;
let prefix: String = first.drain(..cut).collect();
self.bytes -= prefix.len();
self.newlines -= newline_count(&prefix);
removed.push_str(&prefix);
if first.is_empty() {
self.segments.remove(0);
}
}
if !removed.is_empty() {
let skipped = self.skipped.get_or_insert_with(Skipped::default);
skipped.bytes += removed.len() as u64;
skipped.newlines += newline_count(&removed) as u64;
skipped.ends_with_newline = removed.ends_with('\n');
}
}
/// Deliver everything held: one event carrying the skip and all text after it, or one event per segment.
pub fn take(&mut self) -> Vec<Event> {
let segments = std::mem::take(&mut self.segments);
let delivered = self.bytes;
self.bytes = 0;
self.newlines = 0;
// The caller writes at most the window per sample, and paces itself by what it wrote.
let pace = Duration::from_secs_f64(
delivered.min(self.window.max_bytes) as f64 / self.window.bytes_per_second,
);
self.next_send = Instant::now() + self.window.min_interval.max(pace);
match self.skipped.take() {
Some(skipped) => {
let stream = segments
.last()
.map(|(stream, _)| *stream)
.unwrap_or("stdout");
let text: String = segments.into_iter().map(|(_, text)| text).collect();
vec![Event {
stream,
text,
skipped: Some(json!({
"bytes": skipped.bytes,
"newlines": skipped.newlines,
"endsWithNewline": skipped.ends_with_newline,
})),
}]
}
None => segments
.into_iter()
.map(|(stream, text)| Event {
stream,
text,
skipped: None,
})
.collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn window(max_bytes: usize, max_lines: usize) -> Window {
Window {
max_bytes,
max_lines,
min_interval: Duration::ZERO,
bytes_per_second: 1e12,
}
}
/// Bytes and newlines delivered or skipped, and the text after the last skip.
fn total(events: &[Event]) -> (u64, u64, String) {
let mut bytes = 0;
let mut newlines = 0;
let mut after = String::new();
for event in events {
if let Some(skipped) = &event.skipped {
bytes += skipped["bytes"].as_u64().unwrap();
newlines += skipped["newlines"].as_u64().unwrap();
after.clear();
}
bytes += event.text.len() as u64;
newlines += newline_count(&event.text) as u64;
after.push_str(&event.text);
}
(bytes, newlines, after)
}
#[test]
fn counts_everything_and_keeps_more_than_the_window_after_a_skip() {
let mut pending = Pending::new(window(10, 2));
let mut full = String::new();
let mut events = Vec::new();
for index in 0..500 {
let text = format!("line {index} é😀\n");
full.push_str(&text);
pending.push(if index % 7 == 0 { "stderr" } else { "stdout" }, text);
if index % 50 == 49 {
events.extend(pending.take());
}
}
events.extend(pending.take());
let (bytes, newlines, after) = total(&events);
assert_eq!(bytes, full.len() as u64);
assert_eq!(newlines, newline_count(&full) as u64);
assert!(full.ends_with(&after));
assert!(after.len() > 10 || newline_count(&after) > 2);
assert!(events.iter().any(|event| event.skipped.is_some()));
}
#[test]
fn delivers_streams_apart_without_skips() {
let mut pending = Pending::new(window(1000, 1000));
pending.push("stdout", "a".into());
pending.push("stderr", "b".into());
pending.push("stdout", "c".into());
let events = pending.take();
let streams: Vec<_> = events.iter().map(|event| event.stream).collect();
assert_eq!(streams, ["stdout", "stderr", "stdout"]);
}
}