Files
endorphin-tessera-gui/src/runtime/io_tasks.rs
T

368 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 后台文件/网络 provider。线程池只携带纯数据、取消控制与弱 Task 身份,不持有 VM。
//! Lease 由托管任务拥有,释放会撤销交付资格并取消可取消的后台工作;已经完成的结果还要由 VM worker 检查归属后才能交付。
//! 并发、队列、读取字节数、超时和 URL 协议均有边界,避免 provider 绕过运行时资源限制。
//!
//! Background providers carry plain data and weak task identities, never a VM.
use endor_core::{VmError, WeakObjectRef};
use std::{
collections::HashMap,
fs::File,
io::{Read, Write},
process::{Child, Command, Stdio},
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, AtomicUsize, Ordering},
mpsc::{SyncSender, sync_channel},
},
thread,
time::Duration,
};
// 文件与 HTTP 返回文本最多 1 MiB,最终必须为有效 UTF-8;长度按字节计算。
const MAX_TEXT: usize = 1024 * 1024;
// 每个运行时的租约额度,独立于全进程 4 个执行线程和 64 项工作队列。
const MAX_ACTIVE: usize = 16;
const WORKERS: usize = 4;
const QUEUE: usize = 64;
type Completion = Result<String, String>;
#[derive(Debug, Default)]
/// provider 共享的就绪结果与并发计数;内容只有纯数据和弱任务身份。
struct Shared {
ready: Mutex<HashMap<WeakObjectRef, Completion>>,
// 统计当前活租约而非正在执行的 OS 操作;完成结果被取走前,租约仍可能占用名额。
active: AtomicUsize,
}
#[derive(Clone, Debug, Default)]
/// 某个运行时拥有的后台 provider 能力,负责提交和取走完成结果。
pub struct Provider {
shared: Arc<Shared>,
}
#[derive(Debug, Default)]
/// 后台工作取消标记与可取消子进程,不拥有托管 VM。
struct Control {
cancelled: AtomicBool,
process: Mutex<Option<Child>>,
}
impl Control {
/// 撤销当前操作的后续交付资格,并执行对应取消清理。
fn cancel(&self) {
// 先撤销结果交付资格,再尝试终止当前子进程;文件读取无法用此标记中断,只能丢弃晚到结果。
self.cancelled.store(true, Ordering::Release);
if let Ok(mut process) = self.process.lock()
&& let Some(child) = process.as_mut()
{
// Reaping belongs to the provider worker, never the VM thread.
// VM/租约线程这里只 kill,不 wait;子进程回收由持有 Reaper 的 I/O worker 完成,避免阻塞 VM。
let _ = child.kill();
}
}
}
#[derive(Debug)]
/// 后台工作种类及其纯文本参数,源调用的类型检查在提交前完成。
pub enum Request {
ReadText(String),
FetchText(String),
}
/// Only Task payloads own this lease. Provider threads hold plain control data.
#[derive(Debug)]
/// 仅托管 Task 拥有的交付租约;释放它会取消后台资格并清理晚到结果。
pub struct Lease {
shared: Arc<Shared>,
task: WeakObjectRef,
control: Arc<Control>,
}
impl Drop for Lease {
/// 最后一个 Arc<Lease> 释放时撤销交付资格、删除待交付结果并归还一次并发额度;后台 Job 不延长租约。
fn drop(&mut self) {
// 租约释放立即取消交付资格;后台 OS 读取即使稍后结束,也不能再次唤醒旧 Task。
self.control.cancel();
if let Ok(mut ready) = self.shared.ready.lock() {
ready.remove(&self.task);
}
// Lease 包在 Arc 中,只有最后一个拥有者释放才析构并归还一次额度;Job 不持有 Lease。
self.shared.active.fetch_sub(1, Ordering::AcqRel);
}
}
/// 线程池队列中的纯数据工作,关联弱任务身份、控制和共享完成表。
struct Job {
request: Request,
task: WeakObjectRef,
control: Arc<Control>,
shared: Arc<Shared>,
}
impl Provider {
/// 检查并发额度并取得任务 Lease,再把纯数据工作放入有界线程池队列。
pub fn submit(
&self,
task: WeakObjectRef,
request: Request,
replacing: bool,
) -> Result<Arc<Lease>, VmError> {
// 原子更新同时完成检查和预占,防止并发提交都观察到剩余名额而超配。
// replacing 允许替换期间临时多一个租约,旧任务取消后回到正常上限。
self.shared
.active
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
(active < MAX_ACTIVE + usize::from(replacing)).then_some(active + 1)
})
.map_err(|_| VmError::ExecutionLimit("16 I/O tasks per runtime"))?;
// 预占后立即建立 RAII 租约;线程池不可用或队列发送失败时,局部 lease 析构会归还名额。
let lease = Arc::new(Lease {
shared: self.shared.clone(),
task,
control: Arc::new(Control::default()),
});
let sender = pool().map_err(|_| VmError::InvalidArgument("I/O provider unavailable"))?;
// 非阻塞入队,不让调用 VM 等待后台吞吐;Job 只带参数、弱任务身份和原生取消/结果状态。
sender
.try_send(Job {
request,
task,
control: lease.control.clone(),
shared: self.shared.clone(),
})
.map_err(|_| VmError::ExecutionLimit("I/O provider queue full or unavailable"))?;
Ok(lease)
}
/// 一次取走当前邮箱内全部 (弱任务身份, 纯文本结果),实际托管交付仍由 VM worker 校验后执行。
// 一次 drain 所有已就绪结果;移走纯数据不代表任务仍可交付,VM worker 还需检查弱身份和归属。
pub fn take_ready(&self) -> Result<Vec<(WeakObjectRef, Completion)>, VmError> {
Ok(self
.shared
.ready
.lock()
.map_err(|_| VmError::InvalidArgument("I/O mailbox poisoned"))?
.drain()
.collect())
}
}
/// 懒初始化共享工作线程和有界任务队列;初始化失败反馈给提交方。
fn pool() -> Result<&'static SyncSender<Job>, &'static str> {
// 全进程只初始化一次,初始化失败也会记住失败结果;后续 submit 返回 unavailable,不反复创建线程。
static POOL: OnceLock<Result<SyncSender<Job>, String>> = OnceLock::new();
POOL.get_or_init(|| {
let (sender, receiver) = sync_channel::<Job>(QUEUE);
let receiver = Arc::new(Mutex::new(receiver));
for index in 0..WORKERS {
let receiver = receiver.clone();
thread::Builder::new()
.name(format!("tessera-io-{index}"))
.spawn(move || {
loop {
// std mpsc Receiver 通过 Mutex 共享;锁只覆盖取任务,实际 I/O 在离开该 match 后并行执行。
let job = match receiver.lock() {
Ok(receiver) => receiver.recv(),
Err(_) => break,
};
let Ok(job) = job else {
break;
};
// 执行前先跳过已取消队列项,避免启动无用文件读取或 curl。
if job.control.cancelled.load(Ordering::Acquire) {
continue;
}
let result = match &job.request {
Request::ReadText(path) => read_text(path),
Request::FetchText(url) => fetch_text(url, &job.control),
};
// 持有 ready 锁后再次检查取消,与 Lease::drop 的取消/移除配合,防止取消后重新留下晚到结果。
if let Ok(mut ready) = job.shared.ready.lock()
&& !job.control.cancelled.load(Ordering::Acquire)
{
ready.insert(job.task, result);
}
}
})
.map_err(|error| error.to_string())?;
}
Ok(sender)
})
.as_ref()
.map_err(|_| "I/O provider unavailable")
}
/// 读取受字节上限限制的 UTF-8 文件内容,错误作为任务结果交付。
fn read_text(path: &str) -> Completion {
// 先按路径检查普通文件和已知长度,及早拒绝目录/超大源;实际打开后还会复核文件类型。
let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?;
if !metadata.is_file() {
return Err("text source must be a regular file".into());
}
if metadata.len() > MAX_TEXT as u64 {
return Err("text source exceeds 1 MiB".into());
}
let file = File::open(path).map_err(|error| error.to_string())?;
if !file
.metadata()
.map_err(|error| error.to_string())?
.is_file()
{
return Err("text source must be a regular file".into());
}
let mut bytes = Vec::new();
// 再用流读取硬上限检测增长中的文件:多读一个字节即可判断超限,不能只依赖之前 metadata 长度。
file.take(MAX_TEXT as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|error| error.to_string())?;
if bytes.len() > MAX_TEXT {
return Err("text source exceeds 1 MiB".into());
}
String::from_utf8(bytes).map_err(|_| "text source must be UTF-8".into())
}
/// No shell interpretation, curlrc, URL globbing, or non-HTTP redirects.
/// Authentication/custom headers are deliberately outside this text GET API.
/// 检查网络请求的 URL 协议、长度与凭据等约束,拒绝不受支持的输入。
pub fn valid_url(url: &str) -> bool {
// 控制字符必须在写 curl config 前拒绝,否则换行等可能破坏单个 url 配置项边界。
if url.len() > 8192 || url.chars().any(char::is_control) {
return false;
}
let Some((scheme, rest)) = url.split_once("://") else {
return false;
};
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return false;
}
// 仅检查协议与 authority 基本约束;@ 禁止 userinfo 凭据,空白禁止模糊主机部分。
// 这不是完整 URL 解析器,具体 HTTP 解析仍由 curl 负责。
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
!authority.is_empty() && !authority.contains('@') && !authority.chars().any(char::is_whitespace)
}
/// Created only on an I/O worker. All early exits kill and reap the child.
/// 子进程作用域收尾保护,避免取消/失败路径遗留未回收进程。
struct Reaper<'a>(&'a Control);
impl Drop for Reaper<'_> {
/// 取走残留 curl 子进程后尝试 kill/wait,保证失败或取消路径也回收进程,且不持控制锁等待。
fn drop(&mut self) {
// 从控制槽取出子进程后释放锁,再 kill/wait;只有实际留下的进程才需要回收。
let child = self.0.process.lock().ok().and_then(|mut slot| slot.take());
if let Some(mut child) = child {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// 在取消、超时、协议和大小限制下取得 HTTP 文本;不把网络执行放在 VM 线程。
fn fetch_text(url: &str, control: &Control) -> Completion {
if !valid_url(url) {
return Err("invalid HTTP(S) URL".into());
}
#[cfg(unix)]
let executable = "/usr/bin/curl";
#[cfg(not(unix))]
let executable = "curl.exe";
let mut child = Command::new(executable)
// --disable 放首项关闭 curlrc,--globoff 禁用 URL 展开,首次请求与重定向均限制 HTTP(S)。
// 限制重定向数、连接/总超时和下载大小;--compressed 后仍会用下面字节读取上限限制解压文本。
.args([
"--disable",
"--silent",
"--show-error",
"--globoff",
"--location",
"--max-redirs",
"5",
"--proto",
"=http,https",
"--proto-redir",
"=http,https",
"--max-time",
"30",
"--connect-timeout",
"10",
"--max-filesize",
"1048576",
"--compressed",
"--fail",
"--write-out",
"\n%{http_code}",
"--config",
"-",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|_| "HTTP transport unavailable: system curl is required".to_owned())?;
// 取走管道后把 Child 放入共享 Control,取消线程可随时 kill;Reaper 覆盖其后的所有提前返回。
let mut input = child.stdin.take().expect("piped stdin");
let output = child.stdout.take().expect("piped stdout");
match control.process.lock() {
Ok(mut slot) => *slot = Some(child),
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return Err("HTTP process lock poisoned".into());
}
}
let _reaper = Reaper(control);
if control.cancelled.load(Ordering::Acquire) {
return Err("request cancelled".into());
}
// URL stays out of process arguments. Reject controls before config escaping.
// URL 经 curl 配置的引号/反斜杠转义后走 stdin,避免出现在进程参数;这里不经过 shell。
let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
writeln!(input, "url = \"{escaped}\"").map_err(|_| "HTTP transport input failed".to_owned())?;
// 关闭 stdin 表示配置结束,否则 curl 可能继续等待输入,无法开始/完成请求。
drop(input);
let mut bytes = Vec::new();
output
// 预留结尾换行和三位状态码共 4 字节,再额外读 1 字节探测正文超限。
.take(MAX_TEXT as u64 + 5)
.read_to_end(&mut bytes)
.map_err(|_| "HTTP response read failed".to_owned())?;
if bytes.len() > MAX_TEXT + 4 {
return Err("HTTP text exceeds 1 MiB".into());
}
// 读完输出后用 try_wait 检查退出;等待间隔先释放 process 锁,让其他线程仍可取消进程。
let status = loop {
let mut slot = control
.process
.lock()
.map_err(|_| "HTTP process lock poisoned".to_owned())?;
let child = slot.as_mut().ok_or("HTTP process missing")?;
if let Some(status) = child
.try_wait()
.map_err(|_| "HTTP transport wait failed".to_owned())?
{
// try_wait 已回收退出状态,移除 Child 后 Reaper 析构不会再次 kill/wait 同一个进程。
slot.take();
break status;
}
drop(slot);
thread::sleep(Duration::from_millis(2));
};
if control.cancelled.load(Ordering::Acquire) {
return Err("request cancelled".into());
}
// --write-out 的末尾四字节应是换行加三位状态码;checked_sub 处理空/短输出,不直接倒数切片。
let code = bytes
.len()
.checked_sub(4)
.and_then(|offset| (bytes[offset] == b'\n').then_some(&bytes[offset + 1..]))
.and_then(|digits| std::str::from_utf8(digits).ok())
.and_then(|digits| digits.parse::<u16>().ok());
// 已知 HTTP 3xx/4xx/5xx 优先以 HTTP 错误报告,其他 curl 非零状态再映射为超时、大小或传输失败。
if let Some(code) = code
&& code >= 300
{
return Err(format!("HTTP {code}"));
}
if !status.success() {
return Err(match status.code() {
Some(28) => "HTTP request timed out".into(),
Some(63) => "HTTP text exceeds 1 MiB".into(),
Some(code) => format!("HTTP transport failed (curl {code})"),
None => "HTTP transport terminated".into(),
});
}
if !code.is_some_and(|code| (200..300).contains(&code)) {
return Err("HTTP response status unavailable".into());
}
// 确认 2xx 与成功退出后才剥去协议附加状态尾部,再严格解码 UTF-8 正文。
bytes.truncate(bytes.len() - 4);
String::from_utf8(bytes).map_err(|_| "HTTP text must be UTF-8".into())
}