nichlink_plugin_host/process.rs
1//! Process-isolated plugin execution with a hard per-call deadline.
2//! 带单次调用硬超时的进程隔离插件执行。
3
4use std::{
5 fs,
6 io::Write,
7 path::{Path, PathBuf},
8 sync::mpsc::{self, TryRecvError},
9 thread,
10 time::{Duration, Instant},
11};
12
13use nichlink_run_method::{PluginAdapter, VerifiedPluginArtifact};
14use tempfile::{Builder, TempPath};
15
16use crate::{HostError, PluginInstance};
17
18#[path = "process/child.rs"]
19mod child;
20
21use child::{POLL_INTERVAL, drain_to_eof, kill_and_reap, read_frame, read_stderr, spawn_staged};
22
23/// Limits for one isolated process call.
24/// 单次隔离进程调用的限制。
25#[derive(Clone, Copy, Debug)]
26pub struct ProcessLimits {
27 /// Wall-clock deadline for one call; on expiry the host kills the child.
28 /// 单次调用的挂钟超时;到期即由宿主终止子进程。
29 pub timeout: Duration,
30 /// Largest request payload accepted, in bytes.
31 /// 接受的最大请求负载字节数。
32 pub max_input_bytes: usize,
33 /// Largest response payload accepted, in bytes. Checked against the length
34 /// the child declares, before the host allocates the buffer.
35 /// 接受的最大响应负载字节数。以子进程声明的长度为准,在宿主分配缓冲区之前检查。
36 pub max_output_bytes: usize,
37 /// Whether the child inherits this host's environment.
38 /// 子进程是否继承本宿主的环境。
39 ///
40 /// The default is `true`, which is what a host that has always run its plugin
41 /// this way expects. Setting it to `false` clears the environment instead, so
42 /// an untrusted plugin cannot read the host's tokens, credentials, or
43 /// configuration out of it; a host that needs to pass something specific adds
44 /// it with [`ProcessProgram::environment`], and the plugin executable itself
45 /// still starts because it is run by absolute path.
46 /// 默认是 `true`,也就是一直这样运行插件的宿主所期望的行为。设为 `false` 时会清空环境,
47 /// 使不受信任的插件无法从中读到宿主的令牌、凭据或配置;需要传特定变量的宿主用
48 /// [`ProcessProgram::environment`] 显式添加,而插件可执行文件本身仍能启动,因为它以绝对
49 /// 路径执行。
50 pub inherit_env: bool,
51}
52
53impl Default for ProcessLimits {
54 fn default() -> Self {
55 Self {
56 timeout: Duration::from_secs(2),
57 max_input_bytes: 1024 * 1024,
58 max_output_bytes: 1024 * 1024,
59 inherit_env: true,
60 }
61 }
62}
63
64/// Executable, fixed arguments, and child environment for an isolated plugin.
65/// 隔离插件使用的程序、固定参数与子进程环境。
66#[derive(Clone, Debug)]
67pub struct ProcessProgram {
68 executable: PathBuf,
69 arguments: Vec<String>,
70 environment: Vec<(String, String)>,
71 current_dir: Option<PathBuf>,
72}
73
74impl ProcessProgram {
75 /// Start a program specification with no fixed arguments.
76 /// 以无固定参数开始描述一个程序。
77 pub fn new(executable: impl Into<PathBuf>) -> Self {
78 Self {
79 executable: executable.into(),
80 arguments: Vec::new(),
81 environment: Vec::new(),
82 current_dir: None,
83 }
84 }
85
86 /// Append one fixed argument, passed before every operation name.
87 /// 追加一个固定参数,它排在每个操作名之前。
88 pub fn argument(mut self, argument: impl Into<String>) -> Self {
89 self.arguments.push(argument.into());
90 self
91 }
92
93 /// Set one environment variable for the child.
94 /// 为子进程设置一个环境变量。
95 ///
96 /// This is the companion of [`ProcessLimits::inherit_env`]: with the
97 /// environment cleared, these are the only variables the child sees, so a
98 /// host passes what the plugin genuinely needs instead of handing over
99 /// everything it has.
100 /// 这是 [`ProcessLimits::inherit_env`] 的配套:清空环境后,子进程只看到这里设置的变量,
101 /// 因此宿主传的是插件真正需要的东西,而不是把自己拥有的一切都交出去。
102 pub fn environment(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
103 self.environment.push((key.into(), value.into()));
104 self
105 }
106
107 /// Run the child in `directory` instead of the host's working directory.
108 /// 让子进程在 `directory` 中运行,而不是宿主的工作目录。
109 pub fn current_dir(mut self, directory: impl Into<PathBuf>) -> Self {
110 self.current_dir = Some(directory.into());
111 self
112 }
113
114 /// The configured executable path, before staging.
115 /// 配置的可执行文件路径,尚未暂存。
116 pub fn executable(&self) -> &Path {
117 &self.executable
118 }
119}
120
121/// Process-isolated plugin loader.
122/// 进程隔离插件加载器。
123#[derive(Clone, Copy, Debug, Default)]
124pub struct ProcessBackend {
125 limits: ProcessLimits,
126}
127
128impl ProcessBackend {
129 /// Build a backend that applies `limits` to every loaded instance.
130 /// 构造一个对每个已加载实例施加 `limits` 的后端。
131 pub const fn new(limits: ProcessLimits) -> Self {
132 Self { limits }
133 }
134
135 /// Stage the binary privately and require it to equal the artifact bytes.
136 /// The returned instance runs that staged copy, so later edits to the original path
137 /// cannot change what executes.
138 /// 把二进制暂存到私有位置并要求它与工件字节相等。返回的实例运行该暂存副本,
139 /// 因此之后改动原路径不会改变实际执行的内容。
140 pub fn load(
141 &self,
142 artifact: VerifiedPluginArtifact,
143 program: ProcessProgram,
144 ) -> Result<ProcessInstance, HostError> {
145 let metadata = std::fs::metadata(program.executable())?;
146 if !metadata.is_file() {
147 return Err(HostError::InvalidArtifact(format!(
148 "{} is not a file",
149 program.executable().display()
150 )));
151 }
152 let (_, verified_bytes) = artifact.into_parts();
153 // The length the filesystem reports decides before anything is read: the
154 // comparison below used to be the first use of the size, so a file whose
155 // digest could not possibly match — a 2 GiB sparse file, say — was read
156 // into memory in full before the mismatch could refuse it. Once the
157 // lengths agree, reading it costs exactly what the verified bytes
158 // already cost.
159 // 由文件系统报告的长度在读取之前先做判断:下面那次比较过去是第一次用到尺寸,因此一个
160 // 摘要不可能匹配的文件——比如 2 GiB 的稀疏文件——会被整份读进内存后才被拒绝。长度一致
161 // 之后,读取的代价与已持有的已验证字节相同。
162 if metadata.len() != verified_bytes.len() as u64 {
163 return Err(HostError::InvalidArtifact(
164 "process executable differs from the verified bytes".to_owned(),
165 ));
166 }
167 if fs::read(program.executable())? != verified_bytes {
168 return Err(HostError::InvalidArtifact(
169 "process executable differs from the verified bytes".to_owned(),
170 ));
171 }
172 let (program, staged_artifact) = stage_program(program, &verified_bytes)?;
173 Ok(ProcessInstance {
174 program,
175 _staged_artifact: staged_artifact,
176 limits: self.limits,
177 })
178 }
179}
180
181/// A process plugin. Each call gets a fresh child and a hard deadline.
182/// 进程插件。每次调用使用独立子进程和硬超时。
183pub struct ProcessInstance {
184 program: ProcessProgram,
185 _staged_artifact: TempPath,
186 limits: ProcessLimits,
187}
188
189fn stage_program(
190 program: ProcessProgram,
191 bytes: &[u8],
192) -> Result<(ProcessProgram, TempPath), HostError> {
193 let suffix = program
194 .executable
195 .extension()
196 .and_then(|extension| extension.to_str())
197 .map_or_else(String::new, |extension| format!(".{extension}"));
198 let mut staged = Builder::new()
199 .prefix("nichlink-plugin-")
200 .suffix(&suffix)
201 .tempfile()?;
202 staged.write_all(bytes)?;
203 staged.flush()?;
204 set_executable(staged.path())?;
205 let path = staged.into_temp_path();
206 Ok((
207 ProcessProgram {
208 executable: path.to_path_buf(),
209 arguments: program.arguments,
210 // Staging moves the executable, not how the child is run: the
211 // environment and working directory the host chose travel with it.
212 // 暂存搬的是可执行文件,而不是子进程的运行方式:宿主选定的环境与工作目录随它一起走。
213 environment: program.environment,
214 current_dir: program.current_dir,
215 },
216 path,
217 ))
218}
219
220#[cfg(unix)]
221fn set_executable(path: &Path) -> Result<(), HostError> {
222 use std::os::unix::fs::PermissionsExt;
223
224 fs::set_permissions(path, fs::Permissions::from_mode(0o500))?;
225 Ok(())
226}
227
228#[cfg(not(unix))]
229fn set_executable(_: &Path) -> Result<(), HostError> {
230 Ok(())
231}
232
233impl PluginInstance for ProcessInstance {
234 fn adapter(&self) -> PluginAdapter {
235 PluginAdapter::Process
236 }
237
238 /// Run one operation in a fresh child process.
239 /// 在全新的子进程中执行一次操作。
240 fn call(&self, operation: &str, input: &[u8]) -> Result<Vec<u8>, HostError> {
241 // Why the direct implementation is wrong, and where the boundary is:
242 // 直白实现为什么是错的,以及边界在哪里:
243 //
244 // The direct shape is "write stdin, poll `try_wait`, then read stdout".
245 // That shape has two unbounded blocks. Both were measured against this
246 // crate before this comment was written, and both are pinned by tests in
247 // `plugin-host/tests/fault_matrix.rs`:
248 // 直白写法是"写 stdin、轮询 `try_wait`、再读 stdout"。它有两处无界阻塞。两处都
249 // 在写下这段注释之前对本 crate 实测过,并都由 `plugin-host/tests/fault_matrix.rs`
250 // 的测试钉住:
251 //
252 // 1. A pipe holds only about 64 KiB. A poll loop that never drains stdout
253 // lets the child block inside `write`, so it never exits, so the host
254 // kills a healthy child and reports `Timeout`. Measured: a 65_536-byte
255 // frame succeeded and a 65_537-byte frame timed out, while
256 // `max_output_bytes` claimed 1 MiB. The real ceiling was the pipe
257 // buffer, and the reported failure had the wrong kind.
258 // 1. 管道只有约 64 KiB。不排空 stdout 的轮询循环会让子进程阻塞在 `write` 里,
259 // 永不退出,于是宿主杀掉一个健康的子进程并报 `Timeout`。实测:65_536 字节的
260 // 帧成功、65_537 字节的帧超时,而 `max_output_bytes` 声称 1 MiB。真实上限是
261 // 管道缓冲,且报告出来的失败种类是错的。
262 // 2. Writing stdin happened before the deadline was armed, so a child that
263 // does not read stdin pinned the caller with no timeout at all.
264 // Measured: a 1 MiB input (exactly `max_input_bytes`) to a child that
265 // never reads blocked for that child's whole lifetime.
266 // 2. 写 stdin 发生在超时启动之前,因此不读 stdin 的子进程会把调用方无限期钉住。
267 // 实测:1 MiB 输入(正好等于 `max_input_bytes`)写给一个从不读 stdin 的子
268 // 进程,阻塞了整个子进程生存期。
269 //
270 // Both are pipe-capacity problems, not timeout problems, so the fix is to
271 // stop using the pipes as synchronization points: input is written from
272 // its own thread, stdout and stderr are drained from their own threads,
273 // and the deadline bounds the whole call. The declared limits become the
274 // real limits.
275 // 两者都是管道容量问题而不是超时问题,因此修法是让管道不再承担同步职责:输入在
276 // 自己的线程里写,stdout 与 stderr 各自有线程排空,超时覆盖整个调用。声明的限制
277 // 由此成为真实的限制。
278 //
279 // Residual boundary, stated rather than hidden: `Child::kill` kills only
280 // the direct child. A plugin that forks a grandchild inheriting the pipes
281 // can keep one open; the call still returns at the deadline, but that
282 // call's detached writer or reader thread can stay blocked until the
283 // grandchild exits. Killing the whole process group would need `libc`,
284 // which this crate does not depend on.
285 // 仍然存在的边界,明说而不隐藏:`Child::kill` 只杀直接子进程。插件若派生继承了
286 // 管道的孙进程,该孙进程可以让管道保持打开;调用仍会在超时点返回,但这次调用的
287 // 写入或读取线程可能一直阻塞到孙进程退出。要连进程组一起杀就需要 `libc`,而本
288 // crate 没有该依赖。
289 validate_operation(operation)?;
290 if input.len() > self.limits.max_input_bytes || input.len() > u32::MAX as usize {
291 return Err(HostError::Limit(format!(
292 "input is {} bytes; limit is {}",
293 input.len(),
294 self.limits.max_input_bytes
295 )));
296 }
297 let mut child = spawn_staged(&self.program, self.limits, operation)?;
298
299 // One frame, built once: the 4-byte little-endian length prefix followed
300 // by the payload. Moving it into the writer thread is also what lets the
301 // caller's slice end with this call.
302 // 一次构造完整帧:4 字节小端长度前缀加负载。把它移动进写入线程,也正是调用方的
303 // 切片可以随这次调用结束而失效的原因。
304 let mut request = Vec::with_capacity(4 + input.len());
305 request.extend_from_slice(&(input.len() as u32).to_le_bytes());
306 request.extend_from_slice(input);
307
308 let mut stdin = child
309 .stdin
310 .take()
311 .ok_or_else(|| HostError::Process("child stdin was unavailable".to_owned()))?;
312 // The writer runs on its own thread so a child that never reads stdin
313 // cannot pin the caller. Once the child dies the pending write fails with
314 // a broken pipe and this thread ends.
315 // 写入放在自己的线程上,不读 stdin 的子进程因此无法钉住调用方。子进程死后挂起的
316 // 写入会以 broken pipe 失败,该线程随之结束。
317 thread::spawn(move || {
318 let _ = stdin.write_all(&request);
319 // Closing stdin is what tells a reading child the request ended.
320 // 关闭 stdin 是告诉正在读取的子进程"请求结束"的方式。
321 drop(stdin);
322 });
323
324 let mut stdout = child
325 .stdout
326 .take()
327 .ok_or_else(|| HostError::Process("child stdout was unavailable".to_owned()))?;
328 let max_output = self.limits.max_output_bytes;
329 let (frames, received) = mpsc::channel();
330 // Draining stdout for the child's whole life is what removes the pipe
331 // buffer from the contract. `read_frame` rejects an over-limit declared
332 // length before it allocates, so the cap also bounds memory.
333 // 在整个子进程生存期内排空 stdout,正是把管道缓冲从契约里移除的那一步。
334 // `read_frame` 在分配之前就拒绝超过上限的声明长度,因此该上限同时约束内存。
335 thread::spawn(move || {
336 // The frame leaves before the rest of stdout is drained, so a child
337 // that answers and then keeps talking still delivers its answer at
338 // once; draining is what keeps that child from blocking on a full
339 // pipe while the host waits for it to exit. Reading one frame and
340 // stopping was the bug: the child blocked, the host killed it at the
341 // deadline, and a delivered answer was reported as a timeout.
342 // 帧在排空 stdout 其余部分之前送出,因此先作答、后继续说话的子进程仍会立刻交付
343 // 答案;排空正是让那个子进程不会在宿主等它退出时阻塞在满管道上的东西。只读一帧就
344 // 停下曾是缺陷:子进程阻塞、宿主在超时点杀掉它,而一个已经送达的答案被报成超时。
345 let frame = read_frame(&mut stdout, max_output);
346 let _ = frames.send(frame);
347 drain_to_eof(&mut stdout);
348 });
349
350 let mut stderr = child
351 .stderr
352 .take()
353 .ok_or_else(|| HostError::Process("child stderr was unavailable".to_owned()))?;
354 let (messages, message_text) = mpsc::channel();
355 // stderr is drained for the same reason, and the thread keeps reading to
356 // EOF after the retained prefix fills so that no child can block on it.
357 // stderr 因同样原因被排空;保留的前缀写满之后线程仍继续读到 EOF,因此没有子进程
358 // 会因它阻塞。
359 thread::spawn(move || {
360 let _ = messages.send(read_stderr(&mut stderr));
361 });
362
363 let deadline = Instant::now() + self.limits.timeout;
364 let mut frame: Option<Vec<u8>> = None;
365 let mut refusal: Option<HostError> = None;
366 let status = loop {
367 if frame.is_none() && refusal.is_none() {
368 match received.try_recv() {
369 Ok(Ok(bytes)) => frame = Some(bytes),
370 // An over-limit declared length is final: the reader has
371 // stopped reading, so waiting for a child that may now be
372 // blocked on a full pipe would degrade `Limit` into a
373 // misleading `Timeout`.
374 // 超过上限的声明长度是最终结论:读取线程已经停止读取,此时去等一个可能
375 // 正阻塞在满管道上的子进程,只会把 `Limit` 降级成误导性的 `Timeout`。
376 Ok(Err(error @ HostError::Limit(_))) => {
377 kill_and_reap(&mut child);
378 return Err(error);
379 }
380 // Any other reader failure is only remembered, because the
381 // usual cause is a child that wrote no frame at all: it is
382 // about to be reported as `Process` with its stderr, and a
383 // broken frame would be the wrong diagnosis.
384 // 其他读取失败只被记下,因为常见原因是子进程根本没写帧:它马上会被以
385 // `Process` 连同 stderr 上报,而"坏帧"会是错误的诊断。
386 Ok(Err(error)) => refusal = Some(error),
387 Err(TryRecvError::Empty) => {}
388 Err(TryRecvError::Disconnected) => {
389 refusal = Some(HostError::Process(
390 "the child stdout reader stopped".to_owned(),
391 ));
392 }
393 }
394 }
395 if let Some(status) = child.try_wait()? {
396 break status;
397 }
398 if Instant::now() >= deadline {
399 kill_and_reap(&mut child);
400 return Err(HostError::Timeout);
401 }
402 thread::sleep(POLL_INTERVAL);
403 };
404 if !status.success() {
405 let detail = message_text
406 .recv_timeout(deadline.saturating_duration_since(Instant::now()))
407 .unwrap_or_default();
408 let detail = detail.trim();
409 return Err(HostError::Process(if detail.is_empty() {
410 format!("child exited with {status}")
411 } else {
412 detail.to_owned()
413 }));
414 }
415 if let Some(bytes) = frame {
416 return Ok(bytes);
417 }
418 if let Some(error) = refusal {
419 return Err(error);
420 }
421 // The child is gone, so the reader is at EOF and returns without further
422 // blocking. The wait can expire only when a grandchild still holds the
423 // write end open, and then the deadline is the honest answer.
424 // 子进程已消失,读取线程已到 EOF,不会再阻塞。只有当孙进程仍持有写端时这个等待
425 // 才会到期,此时返回超时才是诚实的答案。
426 received
427 .recv_timeout(deadline.saturating_duration_since(Instant::now()))
428 .map_err(|_| HostError::Timeout)?
429 }
430}
431
432fn validate_operation(operation: &str) -> Result<(), HostError> {
433 if nichlink_run_method::validate_operation_name(operation).is_err() {
434 return Err(HostError::InvalidOperation(operation.to_owned()));
435 }
436 Ok(())
437}