Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased — 2026-10-31: I07 bounded initial XeLaTeX PDF preview

- Protect the **first-page** Poppler renderer (`tikzarium-core::preview`), previously missing time and allocation budgets despite B05 navigation and B06 PNG decoding protections: 45-second subprocess deadline with kill/wait; 64MiB compressed PNG read cap.
- Source and canonical compiled PDF are never modified by the guard. On missing/stalled/oversized raster, retain the valid PDF and report a preview-specific error instead of hanging indefinitely.
- Add five small Rust tests (portable and Unix mock-tool paths), separate physical Windows 10 and M1/M4 QA protocol, plan, insight. Existing full workspace CI exercises the tests; exact new-head results PENDING at authoring. No package capability changes.

## Unreleased — 2026-10-31: I06 source-saving integrity (draft until CI)

- Normal Build/Save of an **unchanged** TeX buffer now checks the bound file's bytes, then performs a **true no-op**: preserves file identity, symlink/hardlink relationship, timestamps and user content rather than replacing it with a temporary file.
Expand Down
11 changes: 11 additions & 0 deletions crates/tikzarium-core/QA_FIRST_PAGE_POPPLER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# I07 physical first-page PDF preview QA (NOT_RUN)

Use exact Rust app SHA, OS details, user-owned XeLaTeX/Poppler versions, TeX source SHA, compiled PDF SHA, screenshots and timestamps. Do not run destructive tests on real documents; duplicate sources first.

1. Valid 2-page XeLaTeX source: render first page automatically then navigate to page 2. Confirm distinct content, embedded fonts, original TeX and exact PDF untouched.
2. Test harness only: replace pdftoppm path with controlled mock that loops forever. Build should complete with a preview error after ≤45s, not indefinitely block; real PDF remains exportable. Verify no orphaned renderer child.
3. Test harness only: mock returns >64MiB compressed PNG; verify explicit size error, PDF intact and no huge Rust read into memory.
4. Missing pdftoppm: PDF still compiles/exports and missing renderer diagnosed.
5. Repeat on physical Win10 22H2 x64 and Mac M1/M4. Evaluate UI responsiveness, Ctrl/Cmd actions, error/status visibility, Retina scaling and Windows Unicode source names. B06 pixel limits and B05 page-navigation limits are independent.

Native case statuses all NOT_RUN until actual screenshot/log/hash evidence. Hosted Windows Server and hosted macOS ARM can only attest compile/unit/CLI contracts.
161 changes: 154 additions & 7 deletions crates/tikzarium-core/src/preview.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
//! Rasterize exact XeLaTeX PDF for the on-screen viewport.
//! Without a rasterizer the canonical PDF remains valid and exportable.
//! I07: first-page preview must never wait forever or read arbitrary PNG bytes.
use std::{
fs,
fs::{self, File},
io::{self, Read},
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
use thiserror::Error;

const FIRST_PAGE_TIMEOUT: Duration = Duration::from_secs(45);
const FIRST_PAGE_MAX_PNG_BYTES: u64 = 64 * 1024 * 1024;

#[derive(Debug, Clone)]
pub struct Preview {
pub png: Vec<u8>,
Expand All @@ -18,17 +25,33 @@ pub enum PreviewError {
ToolExit(Option<i32>),
#[error("PDF rasterizer missing: {0}")]
MissingTool(PathBuf),
#[error("PDF rasterizer timed out after {0:?}")]
Timeout(Duration),
#[error("PDF preview PNG exceeds budget: {bytes} bytes (limit {limit})")]
OversizedPng { bytes: u64, limit: u64 },
#[error("preview I/O: {0}")]
Io(#[from] std::io::Error),
Io(#[from] io::Error),
}

/// Rasterization is *optional* after successful XeLaTeX. A timeout or oversized
/// PNG returns a preview error rather than blocking the editor/build forever.
/// The PDF itself and the author's source are never modified.
pub fn render_first_page(pdftoppm: &Path, pdf: &Path) -> Result<Preview, PreviewError> {
render_first_page_with_limits(pdftoppm, pdf, FIRST_PAGE_TIMEOUT, FIRST_PAGE_MAX_PNG_BYTES)
}

fn render_first_page_with_limits(
pdftoppm: &Path,
pdf: &Path,
timeout: Duration,
png_limit: u64,
) -> Result<Preview, PreviewError> {
if !pdftoppm.is_file() {
return Err(PreviewError::MissingTool(pdftoppm.into()));
}
let temp = tempfile::tempdir()?;
let prefix = temp.path().join("page1");
let status = Command::new(pdftoppm)
let mut child = Command::new(pdftoppm)
.arg("-f")
.arg("1")
.arg("-singlefile")
Expand All @@ -37,13 +60,137 @@ pub fn render_first_page(pdftoppm: &Path, pdf: &Path) -> Result<Preview, Preview
.arg("-png")
.arg(pdf)
.arg(&prefix)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(PreviewError::ToolExit(status.code()));
.spawn()?;

let started = Instant::now();
let exit = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() >= timeout => {
let _ = child.kill();
let _ = child.wait();
return Err(PreviewError::Timeout(timeout));
}
Ok(None) => thread::sleep(Duration::from_millis(25)),
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(PreviewError::Io(error));
}
}
};
if !exit.success() {
return Err(PreviewError::ToolExit(exit.code()));
}
Ok(Preview {
png: fs::read(prefix.with_extension("png"))?,
png: read_bounded_png(&prefix.with_extension("png"), png_limit)?,
})
}

/// Bound memory twice: before opening by file metadata and during read in
/// case the output file changes after stat. The PNG pixel/decode limit is
/// enforced separately by B06's egui preview_image helper.
fn read_bounded_png(path: &Path, limit: u64) -> Result<Vec<u8>, PreviewError> {
let before = fs::metadata(path)?.len();
if before > limit {
return Err(PreviewError::OversizedPng {
bytes: before,
limit,
});
}
let mut png = Vec::new();
File::open(path)?
.take(limit.saturating_add(1))
.read_to_end(&mut png)?;
if png.len() as u64 > limit {
return Err(PreviewError::OversizedPng {
bytes: png.len() as u64,
limit,
});
}
Ok(png)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn png_read_is_bounded_and_does_not_rewrite_the_file() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("test.png");
fs::write(&png, b"PNG fixture").unwrap();
assert_eq!(read_bounded_png(&png, 11).unwrap(), b"PNG fixture");
assert!(matches!(
read_bounded_png(&png, 10),
Err(PreviewError::OversizedPng {
bytes: 11,
limit: 10
})
));
assert_eq!(fs::read(&png).unwrap(), b"PNG fixture");
}

#[test]
fn missing_pdf_renderer_is_a_controlled_error() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
render_first_page(&dir.path().join("missing-pdftoppm"), Path::new("image.pdf")),
Err(PreviewError::MissingTool(_))
));
}

#[cfg(unix)]
fn fake_poppler(body: &str) -> (tempfile::TempDir, PathBuf, PathBuf) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let tool = dir.path().join("fake-pdftoppm.sh");
let pdf = dir.path().join("untouched.pdf");
fs::write(&pdf, b"%PDF dummy - keep exact bytes").unwrap();
fs::write(&tool, format!("#!/bin/sh\n{body}\n")).unwrap();
let mut permissions = fs::metadata(&tool).unwrap().permissions();
permissions.set_mode(0o700);
fs::set_permissions(&tool, permissions).unwrap();
(dir, tool, pdf)
}

#[cfg(unix)]
#[test]
fn fake_poppler_success_preserves_pdf_source_and_returns_png() {
let (_dir, tool, pdf) = fake_poppler("for last; do :; done\nprintf 'PNG' > \"$last.png\"");
let original = fs::read(&pdf).unwrap();
let preview =
render_first_page_with_limits(&tool, &pdf, Duration::from_secs(2), 3).unwrap();
assert_eq!(preview.png, b"PNG");
assert_eq!(fs::read(&pdf).unwrap(), original);
assert!(matches!(
render_first_page_with_limits(&tool, &pdf, Duration::from_secs(2), 2),
Err(PreviewError::OversizedPng { bytes: 3, limit: 2 })
));
}

#[cfg(unix)]
#[test]
fn nonzero_renderer_exit_is_not_treated_as_a_valid_pdf_preview() {
let (_dir, tool, pdf) = fake_poppler("exit 9");
assert!(matches!(
render_first_page_with_limits(&tool, &pdf, Duration::from_secs(2), 100),
Err(PreviewError::ToolExit(Some(9)))
));
}

#[cfg(unix)]
#[test]
fn hung_first_page_renderer_is_killed_with_bounded_wall_time() {
let (_dir, tool, pdf) = fake_poppler("while :; do :; done");
let started = Instant::now();
assert!(matches!(
render_first_page_with_limits(&tool, &pdf, Duration::from_millis(150), 100),
Err(PreviewError::Timeout(_))
));
assert!(started.elapsed() < Duration::from_secs(5));
}
}
16 changes: 16 additions & 0 deletions insights/2026-10-31_I07_PREVIEW_TIMEOUT_QA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# I07 — first-page Poppler bound QA ledger

Date 2026-10-31. Base `c5c8c1576b6288e1d125d558d0d4872b321efb86` after B06 #29 integration. Existing `crates/tikzarium-core/src/preview.rs` performed `Command::status()` then `fs::read(...png)` with neither timeout nor file-size cap. B05 #25 is not merged at this baseline and only changes separate GUI page navigation; it cannot guard first-page preview. B06 #29 bounds pixel decode **after PNG bytes were already materialized**; it cannot prevent the initial unbounded process lifetime/read.

New `render_first_page_with_limits` uses kill+wait and 25ms polling on a 45-second production deadline, no pipes, metadata preflight plus capped read for 64MiB. Public render_first_page signature unchanged; on preview failure real PDF stays exportable. Test suite includes portable resource budget and missing-tool tests, Unix fake executable success/error/hang and exact PDF byte invariance.

| Gate | Status at authoring | Evidence |
| --- | --- | --- |
| Source-level fix | IMPLEMENTED_DRAFT | New isolated branch, no TeX input touched |
| Rust fmt and full workspace tests | PENDING | Exact-head GitHub hosted CI to inspect |
| Real XeLaTeX → PDF and Poppler | PENDING | Existing hosted Linux CI |
| D QA 3-host real XeLaTeX | PENDING | Exact-head workflow |
| Native Win10 x64 egui | NOT_RUN | hosted Windows Server != physical Win10 |
| Physical M1/M4 egui / Retina | NOT_RUN | hosted macOS ARM != physical Mac |

Known limits: no kill of possible spawned grandchildren; no OOM guarantee for Poppler's own process; no synchronous cancellation token integration, so preview may continue to its 45s maximum. Release and all-packages visual compatibility remain unclaimed. LuaTeX graphdrawing is not XeLaTeX-native.
15 changes: 15 additions & 0 deletions plans/2026-10-31_I07_BOUNDED_INITIAL_PREVIEW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# I07 — bounded first-page exact PDF rasterizer

2026-10-31. Independent narrow core fix on exact `main` `c5c8c1576b6288e1d125d558d0d4872b321efb86`. B05 #25 and B06 #29 use different code paths; B06 already merged. Core `render_first_page` still used blocking `Command::status()` with no timeout and `fs::read()` with no byte budget. Even though this rasterizer runs after successful XeLaTeX, an indefinitely hanging `pdftoppm` can keep the Build receiver unresolved, never allowing GUI cancel to complete. Large compressed PNG could exhaust process memory before B06 checks dimensions.

## Implementation
- Replace blocking command .status with direct child spawn + try_wait polling, 45s wall budget, kill+wait timeout, no shell, null input/stdout/stderr (no pipes).
- Reject PNG files above 64 MiB by metadata and bounded `Read::take` on actual bytes, preserving exact compiled PDF and original TeX. Preserve XeLaTeX as the source of truth; do not silently transform PDF.
- Keep public `render_first_page` signature; no A-owned `build.rs` or extension/GUI themes touched. On raster failure return PreviewError, which existing build logic stores in optional `artifact.preview` without invalidating valid PDF.
- Regression tests: portable size boundary, missing rasterizer, Unix successful mock preserving PDF, Unix failure, Unix hung worker killed promptly. Existing Rust workspace tests execute them on hosted Linux/macOS/Windows (Unix-only mock tests skip on Windows).
- Log exact PR head and hosted CI, do not claim physical Win10/M1/M4 GUI pass. Note B05 separately bounds page navigation and B06 limits pixel decode.

## Safety limit
This does not make cancellation instantaneous while previewing; a cancelled GUI build can still take up to 45s on the first-page renderer. A cancellation token/child-process registry belongs to a later A/B integration, after A/W05 source freshness semantics stabilize. No universal RSS cap; Poppler may consume its own memory before timeout. GPU/CPU preview image copies remain B06's partial policy. `extensions/PACKAGE_MATRIX.csv` is unchanged because no feature compatibility status changed.

Acceptance: exact-head CI Rust fmt/test/clippy, native host builds and real TeX/Poppler QA; physical Windows 10 and M1/M4 `NOT_RUN` pending native sessions. No release claim.
Loading