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:
83
third_party/pi/packages/env/daemon/src/decode.rs
vendored
Normal file
83
third_party/pi/packages/env/daemon/src/decode.rs
vendored
Normal 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}");
|
||||
}
|
||||
}
|
||||
126
third_party/pi/packages/env/daemon/src/errors.rs
vendored
Normal file
126
third_party/pi/packages/env/daemon/src/errors.rs
vendored
Normal 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,
|
||||
})
|
||||
}
|
||||
483
third_party/pi/packages/env/daemon/src/exec.rs
vendored
Normal file
483
third_party/pi/packages/env/daemon/src/exec.rs
vendored
Normal 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)
|
||||
}
|
||||
120
third_party/pi/packages/env/daemon/src/frame.rs
vendored
Normal file
120
third_party/pi/packages/env/daemon/src/frame.rs
vendored
Normal 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());
|
||||
}
|
||||
}
|
||||
258
third_party/pi/packages/env/daemon/src/fs.rs
vendored
Normal file
258
third_party/pi/packages/env/daemon/src/fs.rs
vendored
Normal 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
|
||||
}
|
||||
368
third_party/pi/packages/env/daemon/src/list.rs
vendored
Normal file
368
third_party/pi/packages/env/daemon/src/list.rs
vendored
Normal 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");
|
||||
}
|
||||
}
|
||||
551
third_party/pi/packages/env/daemon/src/main.rs
vendored
Normal file
551
third_party/pi/packages/env/daemon/src/main.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
100
third_party/pi/packages/env/daemon/src/output.rs
vendored
Normal file
100
third_party/pi/packages/env/daemon/src/output.rs
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
556
third_party/pi/packages/env/daemon/src/pty.rs
vendored
Normal file
556
third_party/pi/packages/env/daemon/src/pty.rs
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
215
third_party/pi/packages/env/daemon/src/scan.rs
vendored
Normal file
215
third_party/pi/packages/env/daemon/src/scan.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
third_party/pi/packages/env/daemon/src/sys/mod.rs
vendored
Normal file
91
third_party/pi/packages/env/daemon/src/sys/mod.rs
vendored
Normal 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()
|
||||
}
|
||||
323
third_party/pi/packages/env/daemon/src/sys/unix.rs
vendored
Normal file
323
third_party/pi/packages/env/daemon/src/sys/unix.rs
vendored
Normal 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)
|
||||
}
|
||||
665
third_party/pi/packages/env/daemon/src/sys/windows.rs
vendored
Normal file
665
third_party/pi/packages/env/daemon/src/sys/windows.rs
vendored
Normal 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\"");
|
||||
}
|
||||
}
|
||||
778
third_party/pi/packages/env/daemon/src/watch.rs
vendored
Normal file
778
third_party/pi/packages/env/daemon/src/watch.rs
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
269
third_party/pi/packages/env/daemon/src/window.rs
vendored
Normal file
269
third_party/pi/packages/env/daemon/src/window.rs
vendored
Normal 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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user