docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -215,7 +215,8 @@ mod tests {
|
||||
called: Default::default(),
|
||||
outcome: ok_diagram,
|
||||
};
|
||||
let src = "12345678"; // exactly 8 bytes
|
||||
// exactly 8 bytes
|
||||
let src = "12345678";
|
||||
let limits = RenderLimits {
|
||||
max_source_bytes: 8,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
//! Render [Mermaid](https://mermaid.js.org/) diagram source to a rasterized PNG,
|
||||
//! behind a swappable [`MermaidEngine`] trait.
|
||||
//!
|
||||
//! This crate is a self-contained, pure-library building block: it turns Mermaid
|
||||
//! diagram text into PNG bytes with no Node, no headless browser, and no network.
|
||||
//! It isolates the layout engine and the SVG raster stack behind our own audited
|
||||
//! boundary so the rest of the CLI can swap engines or fall back to a code block
|
||||
//! without caring how a diagram is produced.
|
||||
//!
|
||||
//! # Pipeline
|
||||
//!
|
||||
//! 1. A [`MermaidEngine`] turns Mermaid source into an SVG and rasterizes it.
|
||||
//! The default engine ([`PureRustEngine`]) uses the vendored, dagre-based
|
||||
//! `mermaid-to-svg` for layout, then [`rasterize`].
|
||||
//! 2. [`rasterize`] converts SVG to PNG with `resvg`/`usvg`/`tiny-skia`,
|
||||
//! configured with **no remote/file resolvers** and a **bundled font** so it
|
||||
//! is safe over untrusted input and deterministic across machines.
|
||||
//! Rendering needs no Node, no headless browser, and no network: a
|
||||
//! [`MermaidEngine`] lays a diagram out as SVG, then [`rasterize`] converts it
|
||||
//! to PNG with `resvg`/`usvg`/`tiny-skia`, configured with **no remote/file
|
||||
//! resolvers** and a **bundled font** so it is safe over untrusted input and
|
||||
//! deterministic across machines.
|
||||
//!
|
||||
//! # Untrusted input and crash isolation
|
||||
//!
|
||||
@@ -27,8 +18,7 @@
|
||||
//! pager renders each diagram in a short-lived child process (see
|
||||
//! [`run_with_timeout`] and the pager's `mermaid_worker`), so a panic or runaway
|
||||
//! render is contained to the child and the wall-clock timeout is a real,
|
||||
//! killable process kill. This crate provides both the in-process engine and the
|
||||
//! subprocess spawn/timeout/reap building blocks that child uses.
|
||||
//! killable process kill.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
@@ -58,10 +48,8 @@ pub use subprocess::{SubprocessError, run_with_timeout};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Which color scheme a diagram should be rendered for.
|
||||
///
|
||||
/// Mapped from the pager's theme by the caller; only the light/dark split is
|
||||
/// relevant to diagram rendering.
|
||||
/// Which color scheme a diagram should be rendered for. Callers collapse the
|
||||
/// pager's theme down to this light/dark split.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MermaidTheme {
|
||||
/// Light surfaces with dark text (e.g. `KigiDay`).
|
||||
@@ -71,18 +59,17 @@ pub enum MermaidTheme {
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Default opaque surface colors. Single source of truth, shared by the raster
|
||||
/// Single source of truth for the surface colors, shared by the raster
|
||||
/// background ([`MermaidTheme::surface_background`]) and the vendored engine's
|
||||
/// theme background (`pure::theme_for`, via [`Rgba::to_hex`]).
|
||||
pub(crate) const LIGHT_SURFACE: Rgba = Rgba::new(0xFA, 0xFA, 0xFA, 0xFF);
|
||||
pub(crate) const DARK_SURFACE: Rgba = Rgba::new(0x18, 0x18, 0x1B, 0xFF);
|
||||
|
||||
impl MermaidTheme {
|
||||
/// The default opaque surface color a diagram blends into for this theme.
|
||||
///
|
||||
/// Used as the raster background when the caller does not supply an explicit
|
||||
/// [`RenderParams::background`]; chosen to approximate a typical terminal
|
||||
/// scrollback surface so the PNG sits flush with the grid.
|
||||
/// The default opaque surface color a diagram blends into for this theme,
|
||||
/// used when the caller supplies no explicit [`RenderParams::background`].
|
||||
/// Chosen to approximate a typical terminal scrollback surface so the PNG
|
||||
/// sits flush with the grid.
|
||||
pub fn surface_background(self) -> Rgba {
|
||||
match self {
|
||||
MermaidTheme::Light => LIGHT_SURFACE,
|
||||
@@ -118,14 +105,10 @@ impl Rgba {
|
||||
|
||||
/// Parameters controlling a single diagram render.
|
||||
///
|
||||
/// Mirrors the sizing model: [`target_width_px`](Self::target_width_px)
|
||||
/// is the primary size driver (already HiDPI-oversampled by the caller),
|
||||
/// [`max_height_px`](Self::max_height_px) clamps tall diagrams, and
|
||||
/// [`scale`](Self::scale) is the fallback oversample used only when
|
||||
/// `target_width_px == 0`. [`min_width_px`](Self::min_width_px) raises the scale
|
||||
/// so small diagrams still rasterize wide enough for OS viewers. The default
|
||||
/// config is **target-width-driven** (`target_width_px` non-zero), so the default
|
||||
/// `scale` is inert.
|
||||
/// [`target_width_px`](Self::target_width_px) is the primary size driver
|
||||
/// (already HiDPI-oversampled by the caller) and [`scale`](Self::scale) is the
|
||||
/// fallback oversample consulted only when it is `0`. The default config is
|
||||
/// target-width-driven, so the default `scale` is inert.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct RenderParams {
|
||||
/// Color scheme to render for.
|
||||
@@ -211,7 +194,6 @@ mod tests {
|
||||
let light = MermaidTheme::Light.surface_background();
|
||||
let dark = MermaidTheme::Dark.surface_background();
|
||||
assert_ne!(light, dark, "light and dark must map to different surfaces");
|
||||
// Light surface is brighter than dark on every channel; both opaque.
|
||||
assert!(light.r > dark.r && light.g > dark.g && light.b > dark.b);
|
||||
assert_eq!(light.a, 0xFF);
|
||||
assert_eq!(dark.a, 0xFF);
|
||||
@@ -220,16 +202,12 @@ mod tests {
|
||||
#[test]
|
||||
fn rgba_to_hex_is_opaque_rrggbb() {
|
||||
assert_eq!(Rgba::new(0x12, 0xAB, 0xCD, 0xFF).to_hex(), "#12ABCD");
|
||||
// Alpha is ignored.
|
||||
assert_eq!(Rgba::new(0, 0, 0, 0).to_hex(), "#000000");
|
||||
// The shared dark surface const renders to the hex the dark theme uses.
|
||||
assert_eq!(DARK_SURFACE.to_hex(), "#18181B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_params_are_target_width_driven() {
|
||||
// Exercises the real default path: target_width_px (1024) drives output
|
||||
// width regardless of `scale`. Engine-agnostic, runs in the default build.
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50" viewBox="0 0 100 50"><rect width="10" height="10" fill="#0000ff"/></svg>"##;
|
||||
let out = rasterize(svg, &RenderParams::default()).expect("render");
|
||||
assert_eq!(
|
||||
@@ -245,7 +223,6 @@ mod tests {
|
||||
assert_send_sync(&engine);
|
||||
}
|
||||
|
||||
/// The default engine renders a real PNG and never panics on valid input.
|
||||
#[test]
|
||||
fn default_engine_renders_valid_input() {
|
||||
let engine = default_engine();
|
||||
|
||||
@@ -63,9 +63,9 @@ impl MmdcEngine {
|
||||
|
||||
impl MermaidEngine for MmdcEngine {
|
||||
fn render(&self, source: &str, params: &RenderParams) -> Result<RenderedDiagram, MermaidError> {
|
||||
// Environment/IO failures below are `Rasterize` (a render-pipeline
|
||||
// failure), not `Unsupported` (which connotes "this input/engine isn't
|
||||
// supported"); spawn failure stays `Unsupported` (engine unavailable).
|
||||
// Environment/IO failures below are `Rasterize` (the render pipeline
|
||||
// broke), not `Unsupported` — that arm is reserved for "no usable
|
||||
// engine", which is what a spawn failure means.
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("xai-mermaid-")
|
||||
.tempdir()
|
||||
@@ -73,8 +73,6 @@ impl MermaidEngine for MmdcEngine {
|
||||
let input = dir.path().join("diagram.mmd");
|
||||
let output = dir.path().join("diagram.svg");
|
||||
|
||||
// Create atomically with 0600 (no umask/chmod TOCTOU window). The parent
|
||||
// tempdir is already 0700.
|
||||
write_private(&input, source)
|
||||
.map_err(|e| MermaidError::Rasterize(format!("could not write source: {e}")))?;
|
||||
|
||||
@@ -94,7 +92,7 @@ impl MermaidEngine for MmdcEngine {
|
||||
// setsid/console detach via the sanctioned helper (never a raw pre_exec).
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
|
||||
// Source goes via the temp file, so no stdin payload.
|
||||
// No stdin payload: the source travels via the temp file.
|
||||
run_with_timeout(cmd, None, self.timeout).map_err(map_subprocess_error)?;
|
||||
|
||||
let svg = std::fs::read_to_string(&output).map_err(|e| {
|
||||
@@ -220,7 +218,6 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn fake_mmdc_zero_exit_without_output_is_layout_error() {
|
||||
// Exits 0 but writes nothing → the "no readable SVG output" Layout error.
|
||||
let (_dir, bin) = fake_mmdc("exit 0");
|
||||
let err = MmdcEngine::new(bin)
|
||||
.render("flowchart LR; A-->B", &RenderParams::default())
|
||||
@@ -231,8 +228,6 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn fake_mmdc_nonzero_exit_maps_to_layout() {
|
||||
// A non-zero exit from mmdc surfaces as a Layout error, distinct from a
|
||||
// timeout or a missing binary.
|
||||
let (_dir, bin) = fake_mmdc("exit 3");
|
||||
let err = MmdcEngine::new(bin)
|
||||
.render("flowchart LR; A-->B", &RenderParams::default())
|
||||
@@ -243,8 +238,8 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn with_timeout_is_honored() {
|
||||
// A fake that sleeps far longer than the configured timeout must time out
|
||||
// (and be reaped) quickly — proving with_timeout feeds run_with_timeout.
|
||||
// Returning well before the 30s sleep proves `with_timeout` reaches
|
||||
// `run_with_timeout` rather than being ignored.
|
||||
let (_dir, bin) = fake_mmdc("sleep 30");
|
||||
let start = Instant::now();
|
||||
let err = MmdcEngine::new(bin)
|
||||
|
||||
@@ -6,9 +6,6 @@ use mermaid_to_svg::{MermaidTheme as EngineTheme, render_mermaid_to_svg};
|
||||
use crate::{MermaidEngine, MermaidError, MermaidTheme, RenderParams, RenderedDiagram};
|
||||
|
||||
/// The default, offline, pure-Rust engine.
|
||||
///
|
||||
/// Uses the vendored dagre-based layout engine to produce an SVG, then
|
||||
/// rasterizes it with the crate's hardened [`crate::rasterize`] pipeline.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct PureRustEngine;
|
||||
|
||||
@@ -26,18 +23,17 @@ impl MermaidEngine for PureRustEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mermaid source -> SVG (the layout half). A free function (no engine state) so
|
||||
/// the SVG can be tested directly and reused by [`MermaidEngine::render`].
|
||||
/// Mermaid source -> SVG (the layout half). A free function, not a method, so
|
||||
/// the SVG can be asserted on directly in tests.
|
||||
///
|
||||
/// The engine returns an error for unparseable or unsupported diagram types; the
|
||||
/// caller degrades any error to the code-block fallback (see
|
||||
/// [`crate::render_checked`]).
|
||||
/// Errors here (unparseable source, unsupported diagram type) are not fatal: the
|
||||
/// caller degrades them to the code-block fallback via [`crate::render_checked`].
|
||||
fn build_svg(source: &str, theme: MermaidTheme) -> Result<String, MermaidError> {
|
||||
let engine_theme = theme_for(theme);
|
||||
render_mermaid_to_svg(source, Some(&engine_theme)).map_err(map_engine_error)
|
||||
}
|
||||
|
||||
/// Map the vendored engine's error taxonomy onto ours, preserving the
|
||||
/// Map the vendored engine's errors onto ours, preserving the
|
||||
/// parse/layout/unsupported split so observability stays honest.
|
||||
fn map_engine_error(e: mermaid_to_svg::MermaidError) -> MermaidError {
|
||||
use mermaid_to_svg::MermaidError as E;
|
||||
@@ -52,10 +48,10 @@ fn map_engine_error(e: mermaid_to_svg::MermaidError) -> MermaidError {
|
||||
|
||||
/// Map [`MermaidTheme`] to a vendored-engine [`EngineTheme`].
|
||||
///
|
||||
/// Only the diagram surface is overridden, to the crate's single-source-of-truth
|
||||
/// surface color ([`crate::LIGHT_SURFACE`] / [`crate::DARK_SURFACE`]) so the
|
||||
/// painted SVG background blends with the terminal scrollback surface the PNG
|
||||
/// sits on; the rest of each preset's palette is used as-is.
|
||||
/// Only the surface is overridden — to the crate's single-source-of-truth color
|
||||
/// ([`crate::LIGHT_SURFACE`] / [`crate::DARK_SURFACE`]) so the painted SVG
|
||||
/// background blends with the terminal scrollback the PNG sits on. The rest of
|
||||
/// each preset's palette is used as-is.
|
||||
fn theme_for(theme: MermaidTheme) -> EngineTheme {
|
||||
match theme {
|
||||
MermaidTheme::Light => {
|
||||
@@ -128,13 +124,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A cyclic flowchart whose back-edge (`Attempts -->|No| Enter`) routes back
|
||||
/// up into the cycle — the tricky case for flowchart edge routing. Every one
|
||||
/// of the eight edges must keep its arrowhead, and no node may be dropped by
|
||||
/// the cycle.
|
||||
/// The back-edge (`Attempts -->|No| Enter`) routes back up into the cycle,
|
||||
/// the tricky case for flowchart edge routing.
|
||||
#[test]
|
||||
fn cyclic_login_flow_renders_with_arrowheads() {
|
||||
// Eight directed edges; each must emit exactly one arrowhead marker.
|
||||
const EDGE_COUNT: usize = 8;
|
||||
let source = "flowchart TD\n\
|
||||
Start([User visits login page]) --> Enter[Enter username & password]\n\
|
||||
@@ -146,15 +139,13 @@ mod tests {
|
||||
Attempts -->|No| Enter\n\
|
||||
Validate -->|Yes| Session[Create session]";
|
||||
let svg = build_svg(source, MermaidTheme::Light).expect("cyclic flow renders");
|
||||
// Pin the invariant to the edges: exactly one `marker-end="url(#arrowhead)"`
|
||||
// per edge, so a dropped/detached back-edge arrowhead fails (a whole-doc
|
||||
// "contains arrow" substring check would pass even with one missing).
|
||||
// Count rather than substring-match: a whole-document "contains an
|
||||
// arrowhead" check would still pass with the back-edge arrowhead gone.
|
||||
let arrowheads = svg.matches(r#"marker-end="url(#arrowhead)""#).count();
|
||||
assert_eq!(
|
||||
arrowheads, EDGE_COUNT,
|
||||
"every flowchart edge must carry an arrowhead marker",
|
||||
);
|
||||
// All node labels survive layout (no node dropped by the cycle).
|
||||
for label in [
|
||||
"Enter username",
|
||||
"Submit credentials",
|
||||
@@ -169,8 +160,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn light_and_dark_render_to_different_pixels() {
|
||||
// Stronger than an SVG-string diff: render both themes at identical
|
||||
// params and assert the encoded pixels actually differ.
|
||||
let engine = PureRustEngine::new();
|
||||
let light = engine
|
||||
.render(
|
||||
@@ -203,8 +192,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn theme_for_overrides_surface_per_theme() {
|
||||
// The diagram background is the crate's surface single-source-of-truth so
|
||||
// the PNG blends with the terminal scrollback surface.
|
||||
assert_eq!(
|
||||
theme_for(MermaidTheme::Light).background,
|
||||
crate::LIGHT_SURFACE.to_hex()
|
||||
@@ -219,10 +206,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Untrusted input must never panic — `render_checked` would surface a panic
|
||||
/// as `MermaidError::Panic`, which we assert against. Unparseable input may
|
||||
/// legitimately return other errors (which degrade to the code-block
|
||||
/// fallback), but never a panic.
|
||||
/// Only `MermaidError::Panic` is forbidden: garbage input may legitimately
|
||||
/// return other errors, which degrade to the code-block fallback.
|
||||
#[test]
|
||||
fn garbage_input_never_panics() {
|
||||
let engine = PureRustEngine::new();
|
||||
@@ -251,7 +236,6 @@ mod tests {
|
||||
#[test]
|
||||
fn engine_error_taxonomy_maps_every_arm() {
|
||||
use mermaid_to_svg::MermaidError as E;
|
||||
// Parse family: malformed source, bad direction, bad node shape.
|
||||
for parse in [
|
||||
E::ParseError {
|
||||
line: 1,
|
||||
@@ -265,7 +249,6 @@ mod tests {
|
||||
"expected Parse mapping",
|
||||
);
|
||||
}
|
||||
// Layout family: dot generation + SVG render failures.
|
||||
for layout in [
|
||||
E::DotGenerationError("x".into()),
|
||||
E::RenderError("x".into()),
|
||||
@@ -275,7 +258,6 @@ mod tests {
|
||||
"expected Layout mapping",
|
||||
);
|
||||
}
|
||||
// Unsupported diagram type is its own category.
|
||||
assert!(matches!(
|
||||
map_engine_error(E::UnsupportedDiagramType("x".into())),
|
||||
MermaidError::Unsupported(_)
|
||||
|
||||
@@ -320,7 +320,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn opaque_background_fills_empty_region() {
|
||||
let mut p = params(0, 10_000); // 1x => 100x50 so pixel coords are exact
|
||||
// 1x => 100x50 so pixel coords are exact
|
||||
let mut p = params(0, 10_000);
|
||||
p.background = Some(Rgba::new(255, 0, 0, 255));
|
||||
let out = rasterize(SVG_100X50, &p).expect("rasterize");
|
||||
let img = image::load_from_memory(&out.png)
|
||||
@@ -440,7 +441,8 @@ mod tests {
|
||||
#[test]
|
||||
fn non_finite_or_non_positive_scale_clamps_to_valid_png() {
|
||||
for bad_scale in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
|
||||
let mut p = params(0, 10_000); // target_width_px == 0 => scale path
|
||||
// target_width_px == 0 => scale path
|
||||
let mut p = params(0, 10_000);
|
||||
p.scale = bad_scale;
|
||||
let out = rasterize(SVG_100X50, &p)
|
||||
.unwrap_or_else(|e| panic!("scale {bad_scale} should clamp to 1.0, got {e:?}"));
|
||||
@@ -490,7 +492,8 @@ mod tests {
|
||||
// point at the bundled face the glyphs are silently dropped and node
|
||||
// labels render blank. Black text on white: assert dark pixels exist.
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60" viewBox="0 0 200 60"><text x="10" y="38" font-family="Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif" font-size="28" fill="#000000">Hello</text></svg>"##;
|
||||
let mut p = params(0, 10_000); // scale 1.0 => 200x60
|
||||
// scale 1.0 => 200x60
|
||||
let mut p = params(0, 10_000);
|
||||
p.background = Some(Rgba::new(255, 255, 255, 255));
|
||||
let out = rasterize(svg, &p).expect("rasterize");
|
||||
let img = image::load_from_memory(&out.png)
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
//! to a wall-clock budget, and reap the whole process group on a breach.
|
||||
//!
|
||||
//! Used by both the optional [`crate::MmdcEngine`] (which shells out to
|
||||
//! `mmdc`/headless-Chromium) and the pager's out-of-process render child (a
|
||||
//! short-lived re-exec of the pager that renders one diagram in isolation). The
|
||||
//! `mmdc`/headless-Chromium) and the pager's out-of-process render child. The
|
||||
//! timeout is a *real* process kill, not a soft signal: a panic under
|
||||
//! `panic = "abort"` or a runaway render in the child is contained because the
|
||||
//! parent kills and reaps it.
|
||||
@@ -57,14 +56,10 @@ pub fn run_with_timeout(
|
||||
) -> Result<(), SubprocessError> {
|
||||
let mut child = spawn_with_etxtbsy_retry(&mut cmd).map_err(SubprocessError::Spawn)?;
|
||||
|
||||
// Feed stdin from a scoped thread so a child that stops reading can't wedge
|
||||
// a `write_all` of a large (up to the source-size cap) payload and deadlock
|
||||
// the wait below. On timeout we kill the child, which EOF/EPIPEs the writer.
|
||||
let stdin = child.stdin.take();
|
||||
|
||||
// A payload with no piped stdin would be silently dropped (the caller forgot
|
||||
// `cmd.stdin(Stdio::piped())`). Both in-tree callers pipe correctly; flag the
|
||||
// foot-gun loudly in debug and at least log it in release.
|
||||
// A payload with no piped stdin is silently dropped, so flag that caller
|
||||
// mistake loudly in debug and at least log it in release.
|
||||
if stdin_payload.is_some() && stdin.is_none() {
|
||||
tracing::warn!(
|
||||
target: "mermaid",
|
||||
@@ -75,11 +70,14 @@ pub fn run_with_timeout(
|
||||
"run_with_timeout: stdin_payload supplied but cmd.stdin is not piped (payload dropped)"
|
||||
);
|
||||
}
|
||||
// `scope` joins the writer before returning, so the writer must always be
|
||||
// able to finish: a child that stops reading is killed by `wait_and_reap`,
|
||||
// which EOF/EPIPEs the pending `write_all`.
|
||||
std::thread::scope(|scope| {
|
||||
if let (Some(mut sink), Some(payload)) = (stdin, stdin_payload) {
|
||||
scope.spawn(move || {
|
||||
use std::io::Write as _;
|
||||
// Errors are expected if the child exits/dies first; ignore them.
|
||||
// Expected to fail if the child exits first; nothing to report.
|
||||
let _ = sink.write_all(payload);
|
||||
// Dropping `sink` closes the pipe so the child observes EOF.
|
||||
});
|
||||
@@ -96,8 +94,7 @@ pub fn run_with_timeout(
|
||||
/// is close-on-exec but only closes at the child's own `execve`, leaving a
|
||||
/// fork→execve window during which our `execve` of a freshly-written binary can
|
||||
/// race. It is transient and clears within milliseconds, so retry a few times
|
||||
/// with a short backoff. (No-op on the steady-state path; only the failing
|
||||
/// transient case changes behaviour.)
|
||||
/// with a short backoff.
|
||||
fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result<Child> {
|
||||
const MAX_ATTEMPTS: u32 = 5;
|
||||
let mut attempt = 0;
|
||||
@@ -121,14 +118,13 @@ fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result<Child> {
|
||||
/// that spawned grandchildren can't orphan them.
|
||||
fn wait_and_reap(child: &mut Child, timeout: Duration) -> Result<(), SubprocessError> {
|
||||
match child.wait_timeout(timeout) {
|
||||
// `wait_timeout` already reaped the direct child on these two branches,
|
||||
// but it was its own detached group leader: SIGKILL the group's pgid so
|
||||
// any grandchildren (e.g. an opt-in MmdcEngine's headless Chromium) are
|
||||
// torn down regardless of exit code. The pgid stays valid while a
|
||||
// grandchild is alive (the case that matters); for the grandchild-less
|
||||
// render child the leader is already gone, so killpg is a harmless no-op
|
||||
// (ESRCH). The full `reap()` is unneeded — the direct child is already
|
||||
// reaped, so `child.kill()`/`wait()` would be redundant.
|
||||
// On these two branches `wait_timeout` already reaped the direct child,
|
||||
// so `reap()` would be redundant — but the child was its own detached
|
||||
// group leader, so still SIGKILL the pgid to tear down grandchildren
|
||||
// (e.g. an opt-in MmdcEngine's headless Chromium) regardless of exit
|
||||
// code. The pgid stays valid while a grandchild is alive, the case that
|
||||
// matters; with no grandchildren the leader is gone and killpg is a
|
||||
// harmless ESRCH no-op.
|
||||
Ok(Some(status)) if status.success() => {
|
||||
reap_process_group(child);
|
||||
Ok(())
|
||||
@@ -141,8 +137,8 @@ fn wait_and_reap(child: &mut Child, timeout: Duration) -> Result<(), SubprocessE
|
||||
reap(child);
|
||||
Err(SubprocessError::Timeout)
|
||||
}
|
||||
// waitpid failed; the child may still be running, so reap it too — the
|
||||
// same teardown as the timeout branch (don't leak the child tree).
|
||||
// waitpid failed, so the child may still be running: same teardown as
|
||||
// the timeout branch rather than leaking the child tree.
|
||||
Err(e) => {
|
||||
reap(child);
|
||||
Err(SubprocessError::Wait(e))
|
||||
@@ -227,11 +223,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A large stdin payload (bigger than any OS pipe buffer) must be delivered
|
||||
/// through `run_with_timeout`'s own scoped writer without deadlocking the
|
||||
/// wait — the whole reason the writer is a scoped thread. We point `cat` at a
|
||||
/// file (its stdout) so we can prove every byte was consumed, and assert the
|
||||
/// call returns `Ok(())` promptly rather than hitting the timeout.
|
||||
/// A payload larger than any OS pipe buffer, the case the scoped writer
|
||||
/// exists for. `cat`'s stdout goes to a file so the drained byte count
|
||||
/// proves the whole payload was delivered and consumed.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn large_stdin_payload_is_delivered_without_deadlock() {
|
||||
@@ -256,8 +250,6 @@ mod tests {
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"must return after the drain, not after the full timeout",
|
||||
);
|
||||
// `cat` copies all of stdin to the file → proves the whole payload was
|
||||
// both delivered and consumed via the real scoped writer.
|
||||
let drained = std::fs::metadata(&sink).expect("sink metadata").len();
|
||||
assert_eq!(
|
||||
drained,
|
||||
@@ -266,7 +258,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `reap` actually terminates the spawned process group.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reap_terminates_the_process() {
|
||||
@@ -278,7 +269,7 @@ mod tests {
|
||||
|
||||
reap(&mut child);
|
||||
|
||||
// After SIGKILL + wait, the pid no longer names a live process.
|
||||
// Signal 0 is an existence probe: ESRCH means the pid is gone.
|
||||
assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
@@ -294,12 +285,9 @@ mod tests {
|
||||
assert!(matches!(r, Err(SubprocessError::Spawn(_))), "got {r:?}");
|
||||
}
|
||||
|
||||
/// The dropped-stdin-payload guard fires when a caller passes a payload but
|
||||
/// forgets `cmd.stdin(Stdio::piped())`: the `debug_assert!` turns that silent
|
||||
/// foot-gun into a hard failure. Gated on `debug_assertions` because that is
|
||||
/// exactly when the assert is active (release keeps only the `warn`). `true`
|
||||
/// exits at once; `detached` sets stdin to null (not piped), so the payload
|
||||
/// would be dropped and the guard must catch it.
|
||||
/// `detached` sets stdin to null rather than piped, so the payload would be
|
||||
/// silently dropped and the `debug_assert!` guard must catch it. Gated on
|
||||
/// `debug_assertions` because release builds keep only the `warn`.
|
||||
#[cfg(all(unix, debug_assertions))]
|
||||
#[test]
|
||||
#[should_panic(expected = "stdin_payload supplied but cmd.stdin is not piped")]
|
||||
|
||||
@@ -76,7 +76,7 @@ fn sequence_diagram_with_activations_renders_to_png() {
|
||||
|
||||
/// Regression: a class diagram using quoted cardinalities, stereotypes,
|
||||
/// generics, and the full relation set must render instead of erroring
|
||||
/// (previously failed with "Unrecognized classDiagram line" on
|
||||
/// (earlier failed with "Unrecognized classDiagram line" on
|
||||
/// `Owner "1" o-- "0..*" Animal`).
|
||||
#[test]
|
||||
fn class_diagram_with_cardinalities_renders() {
|
||||
@@ -149,7 +149,7 @@ fn long_identifier_node_labels_survive_intact_in_svg() {
|
||||
|
||||
/// A categorical-x-axis xychart with two `line` series must render to a decodable
|
||||
/// PNG through the full `[Open Image]` path (source -> SVG -> raster), on both
|
||||
/// themes. The categorical x-axis (no `-->`) previously failed to open.
|
||||
/// themes. The categorical x-axis (no `-->`) earlier failed to open.
|
||||
#[test]
|
||||
fn categorical_xychart_with_two_series_renders_to_png() {
|
||||
const SOURCE: &str = "xychart-beta\n \
|
||||
|
||||
Reference in New Issue
Block a user