Merge branch 'main' into sonar/analysis-scope

This commit is contained in:
FredApps authored and GitHub committed 2026-11-10 18:23:19 +01:00
commit 37f2017664
45 files changed
+4613 -206

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ that variable set.
```sh
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
cargo test --workspace
cargo xtask ci # what every commit must pass
cargo xtask ci # what every commit must pass (dev-profile checks and tests)
```
The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces
Generated
+142 -18
View File
@@ -1422,6 +1422,7 @@ dependencies = [
name = "effectcraft"
version = "0.23.1"
dependencies = [
"chrono",
"cpal",
"craft-update",
"effectcraft-engine",
@@ -1439,6 +1440,7 @@ dependencies = [
"rfd",
"serde",
"serde_json",
"sysinfo",
"winit",
"winresource",
"zbus",
@@ -3068,7 +3070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.5",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -3668,7 +3670,7 @@ dependencies = [
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -3814,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -4120,6 +4122,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -4434,6 +4445,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -4664,7 +4685,7 @@ dependencies = [
"libc",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -6071,6 +6092,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "sysinfo"
version = "0.37.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.61.3",
]
[[package]]
name = "tag_ptr"
version = "0.1.0"
@@ -7224,16 +7259,38 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future",
"windows-numerics",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.61.2",
]
[[package]]
@@ -7255,6 +7312,19 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -7263,9 +7333,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading 0.1.0",
]
[[package]]
@@ -7275,8 +7356,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link",
"windows-threading",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -7301,12 +7382,28 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
@@ -7314,7 +7411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -7323,9 +7420,9 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings",
"windows-strings 0.5.1",
]
[[package]]
@@ -7337,13 +7434,31 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
@@ -7352,7 +7467,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -7388,7 +7503,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -7422,13 +7537,22 @@ dependencies = [
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
+60 -1
View File
@@ -31,6 +31,7 @@
//! effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors] [--mobilesam mobile_sam.pt] [--fixes] [--clip FRAMES --truth MASKS] [--json] Roto Brush trackers on synthetic clips with known mattes
//! (classical engine, MobileSAM and SAM 2.1 (any installed size) when installed; IoU, flicker, re-appearance, seconds per frame)
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
//! effectcraft-cli replay SCRIPT.jsonl [--keep-going] replay a repro script (docs/agents.md)
//! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
@@ -97,7 +98,7 @@ fn stdout_broken() -> bool {
STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe)
}
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|replay|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
exec <command-id> [--params JSON] run one engine command (exec --list: list them, as `commands`)
@@ -138,6 +139,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
(app.project, comps, layers, properties…); prints writeLn
output and the result; errors exit 1 with file:line:col
replay SCRIPT.jsonl [--keep-going] replay a repro script headless (journal.export, crash repros):
per-step ok/error and the first failure; exits 1 when a step
fails, an expectation is not met or a command panics
mcp [--autosave | --bridge PORT] MCP server (JSON-RPC over stdio); --autosave preserves unsaved headless work
[--automation-read-root DIR] [--automation-write-root DIR]
confine the agent's file access: reads under a read or write root,
@@ -222,6 +226,7 @@ const FLAGS: &[&str] = &[
"--small",
"--adv3d",
"--autosave",
"--keep-going",
"--progress",
];
@@ -552,6 +557,59 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
Ok(())
}
/// `replay SCRIPT.jsonl`: run a repro script against a headless session (its start line picks
/// the project) and report every step. Exits 1 when the replay fails.
fn replay_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
use effectcraft_engine::replay;
let Some(file) = args.pos.first() else { return usage_err("replay needs a .jsonl script") };
if args.opt("--bridge").is_some() || args.project.is_some() || args.flag("--demo") || args.flag("--empty") {
return usage_err("replay runs headless from the script's start line ({\"start\": \"new\" | \"demo\" | {\"project\": path}})");
}
use std::io::Read;
let path = std::path::Path::new(file);
let input = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?;
// Bound the read itself: metadata can be stale or describe a pipe rather than its contents.
let mut script = String::new();
input.take(replay::MAX_SCRIPT_BYTES as u64 + 1).read_to_string(&mut script).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?;
if script.len() > replay::MAX_SCRIPT_BYTES {
return Err(Failure::Error(format!("{file} is larger than {} MB", replay::MAX_SCRIPT_BYTES >> 20)));
}
let mut s = session(args)?;
let opts = replay::Options { keep_going: args.flag("--keep-going"), base_dir: path.parent().map(std::path::Path::to_path_buf) };
let report = replay::run(&mut s, &script, &opts);
if json_out {
emit(&serde_json::to_value(&report).unwrap_or_else(|e| json!({"error": e.to_string()})), true);
} else {
for st in &report.steps {
let what = st.command.as_deref().unwrap_or(st.kind);
match (&st.error, st.ok) {
(None, _) => say!("line {:>4} ok {what}", st.line),
(Some(e), true) => say!("line {:>4} ok {what} (tolerated error: {e})", st.line),
(Some(e), false) => say!("line {:>4} FAIL {what}: {e}", st.line),
}
}
}
let Some(first) = &report.first_failure else {
if !json_out {
say!("replay ok: {} steps", report.steps.len());
}
return Ok(());
};
let msg = format!(
"failed at line {} ({}){}: {}{}",
first.line,
first.command.as_deref().unwrap_or(first.kind),
if report.panicked { ", a command panicked" } else { "" },
first.error.as_deref().unwrap_or(""),
if report.skipped > 0 { format!(" ({} later steps not run)", report.skipped) } else { String::new() }
);
if json_out {
note!("effectcraft-cli replay: {msg}");
std::process::exit(1);
}
Err(Failure::Error(msg))
}
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
@@ -614,6 +672,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
"commands" => list_commands_cmd(args, json_out)?,
"exec" if args.flag("--list") => list_commands_cmd(args, json_out)?,
"script" => script_cmd(args, json_out)?,
"replay" => replay_cmd(args, json_out)?,
"exec" => {
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {
+36
View File
@@ -1223,3 +1223,39 @@ fn render_exr_compression_and_depth() {
assert!(reply["error"].as_str().unwrap().contains("exrBitDepth: half|float"), "{reply}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn replay_reports_steps_and_the_first_failure() {
let repro = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/repros/comp-zero-frame-rate.jsonl");
let v = ok_json(&["replay", repro.to_str().unwrap()]);
assert_eq!(v["ok"], true, "{v}");
assert!(v["steps"].as_array().unwrap().len() > 5);
let bad = tmp("bad-repro.jsonl");
std::fs::write(
&bad,
"{\"start\": \"new\"}\n{\"command\": \"comp.new\"}\n{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/width\", \"equals\": 1}}\nnot json\n",
)
.unwrap();
let (code, v) = run_json(&["replay", bad.to_str().unwrap()]);
assert_eq!(code, 1);
assert_eq!(v["ok"], false);
assert_eq!(v["first_failure"]["line"], 3, "{v}");
assert_eq!(v["skipped"], 1);
let out = bin().args(["replay", bad.to_str().unwrap(), "--keep-going"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("line 2 ok comp.new") && text.contains("line 4 FAIL invalid: not JSON"), "{text}");
assert!(String::from_utf8_lossy(&out.stderr).contains("failed at line 3 (comp.info)"));
// The start line picks the project.
assert_eq!(bin().args(["replay", bad.to_str().unwrap(), "--demo"]).output().unwrap().status.code(), Some(2));
}
/// Text editors can prefix an exported JSON-lines repro with a UTF-8 BOM.
#[test]
fn replay_accepts_a_utf8_bom() {
let path = tmp("bom-repro.jsonl");
std::fs::write(&path, "\u{feff}{\"command\":\"comp.new\"}\n").unwrap();
let (code, report) = run_json(&["replay", path.to_str().unwrap()]);
assert_eq!(code, 0, "{report}");
assert_eq!(report["ok"], true);
}
+3
View File
@@ -25,6 +25,9 @@ rfd = { workspace = true }
pollster = { workspace = true }
cpal = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
# MIT; safe process handles and target-only memory observation for the crash helper.
sysinfo = { version = "=0.37.2", default-features = false, features = ["system"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Native GNOME/Wayland controls without a bundled title font.
+264
View File
@@ -0,0 +1,264 @@
//! Bounded observations made outside the failed GUI. Measurements are evidence, not a cause.
use std::io::{self, Write};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant, SystemTime};
use sysinfo::{MemoryRefreshKind, Pid, ProcessRefreshKind, ProcessesToUpdate, System};
pub fn utc() -> String {
chrono::DateTime::<chrono::Utc>::from(SystemTime::now()).to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
#[derive(Default)]
struct Memory {
samples: u64,
resident_peak: u64,
virtual_peak: u64,
available_min: Option<u64>,
total: u64,
last_at: Option<String>,
last_uptime_secs: Option<u64>,
}
impl Memory {
fn sample(&mut self, resident: u64, virtual_bytes: u64, available: u64, total: u64) {
self.samples = self.samples.saturating_add(1);
self.resident_peak = self.resident_peak.max(resident);
self.virtual_peak = self.virtual_peak.max(virtual_bytes);
// Zero total means the platform could not supply physical-memory information.
if total != 0 {
self.available_min = Some(self.available_min.map_or(available, |old| old.min(available)));
self.total = total;
}
self.last_at = Some(utc());
}
}
pub struct Observation {
pid: Pid,
system: System,
birth: Option<u64>,
started: Instant,
started_at: String,
detected_at: Option<String>,
last_sample: Instant,
memory: Memory,
// Only Windows can retrieve an unrelated process's ExitStatus through a retained handle.
exit: Receiver<Option<std::process::ExitStatus>>,
status: Option<std::process::ExitStatus>,
alive: Option<bool>,
}
fn target_system(pid: Pid) -> System {
let mut system = System::new();
system.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), false, ProcessRefreshKind::nothing().with_memory());
system
}
impl Observation {
/// Acquire the Windows handle before acknowledging reporter readiness, so even an immediate
/// GUI abort leaves an observable process object. Never enumerate commands or environments.
pub fn start(pid: u32) -> Self {
let pid = Pid::from_u32(pid);
let system = target_system(pid);
let birth = system.process(pid).map(|p| p.start_time());
let (send, exit) = mpsc::sync_channel(1);
#[cfg(windows)]
{
let waiting = target_system(pid);
let _ = std::thread::Builder::new().name("ec-exit-status".into()).spawn(move || {
let status = waiting.process(pid).and_then(|p| p.wait());
let _ = send.send(status);
});
}
#[cfg(not(windows))]
drop(send);
let now = Instant::now();
let mut result = Self {
pid,
system,
birth,
started: now,
started_at: utc(),
detected_at: None,
last_sample: now,
memory: Memory::default(),
exit,
status: None,
alive: None,
};
result.sample();
result
}
fn sample(&mut self) {
self.system.refresh_processes_specifics(ProcessesToUpdate::Some(&[self.pid]), false, ProcessRefreshKind::nothing().with_memory());
self.system.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram());
let process = self.system.process(self.pid).filter(|p| Some(p.start_time()) == self.birth && p.exists());
self.alive = self.birth.map(|_| process.is_some());
if let Some(process) = process {
self.memory.last_uptime_secs = Some(process.run_time());
self.memory.sample(process.memory(), process.virtual_memory(), self.system.available_memory(), self.system.total_memory());
}
if let Ok(status) = self.exit.try_recv() {
self.status = status;
}
self.last_sample = Instant::now();
}
pub fn tick(&mut self) {
if self.last_sample.elapsed() >= Duration::from_secs(1) {
self.sample();
}
}
/// Pipe closure can precede the OS signalling process termination. Bound that grace period;
/// a broken pipe or early reporter drop must never be called a confirmed process exit.
pub fn finish(&mut self) {
self.detected_at = Some(utc());
if self.status.is_none()
&& let Ok(status) = self.exit.recv_timeout(Duration::from_millis(750))
{
self.status = status;
}
self.sample();
}
pub fn confirmed_exit(&self) -> bool {
self.status.is_some()
}
pub fn write(&self, report: &mut impl Write) -> io::Result<()> {
writeln!(
report,
"Detection UTC: {}\nObserver started UTC: {}\nObserved duration: {} ms",
self.detected_at.as_deref().unwrap_or("not captured"),
self.started_at,
self.started.elapsed().as_millis()
)?;
writeln!(
report,
"Process observation: {}",
if self.confirmed_exit() {
"process termination observed"
} else if self.alive == Some(true) {
"GUI still present when the control channel ended"
} else {
"target absent from refresh or process identity/status unavailable (not proof of termination)"
}
)?;
match &self.status {
Some(status) => {
writeln!(report, "OS exit status: {status}")?;
#[cfg(windows)]
if let Some(code) = status.code() {
write_windows_status(report, code as u32)?;
}
}
None => writeln!(report, "OS exit status: unavailable (not inferred from the control pipe)")?,
}
let memory = &self.memory;
writeln!(report, "\nMemory evidence (1 s sampling; sampled maxima, not OS peaks):\nTarget-present sampling attempts: {}", memory.samples)?;
if let Some(uptime) = memory.last_uptime_secs {
writeln!(report, "GUI uptime at last target-present observation: {uptime} s")?;
}
#[cfg(windows)]
let virtual_label = "Peak sampled private committed bytes";
#[cfg(not(windows))]
let virtual_label = "Peak sampled virtual bytes";
if memory.samples != 0 {
writeln!(
report,
"Last target sampling attempt UTC: {}\nPeak sampled resident bytes: {}\n{virtual_label}: {}",
memory.last_at.as_deref().unwrap_or("unavailable"),
memory.resident_peak,
memory.virtual_peak
)?;
}
if let Some(available) = memory.available_min {
writeln!(report, "Minimum sampled system available RAM bytes: {available}\nSystem total RAM bytes at last sample: {}", memory.total)?;
}
writeln!(
report,
"Samples can miss short spikes or retain stale/zero readings when an OS query fails; per-field success is not exposed by sysinfo. Windows private commit is not GPU VRAM; on other platforms virtual memory is not physical RAM. System commit limit/usage and GPU memory budgets were not collected. No memory-pressure threshold establishes the cause of this incident.\nThe memory observer does not collect process command lines, environments, documents, native dumps or system event logs. The existing session-log tail below may contain paths or other logged details; review it before sharing."
)
}
}
#[cfg(any(windows, test))]
fn write_windows_status(report: &mut impl Write, code: u32) -> io::Result<()> {
let meaning = match code {
0xc0000005 => "STATUS_ACCESS_VIOLATION",
0xc0000017 => "STATUS_NO_MEMORY",
0xc000012d => "STATUS_COMMITMENT_LIMIT",
0xc0000374 => "STATUS_HEAP_CORRUPTION",
0xc0000409 => "STATUS_STACK_BUFFER_OVERRUN / fast fail",
0x40000015 => "STATUS_FATAL_APP_EXIT",
_ => "unclassified",
};
writeln!(
report,
"Windows status bits: 0x{code:08X} ({meaning})\nThese are process exit bits, not a verified exception record. Software can set an exit code explicitly; an access violation does not identify the faulting module or prove a driver crash."
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_target_without_exit_status_does_not_confirm_termination() {
let (send, exit) = mpsc::sync_channel(1);
drop(send);
let now = Instant::now();
let observation = Observation {
pid: Pid::from_u32(1),
system: System::new(),
birth: Some(1),
started: now,
started_at: utc(),
detected_at: Some(utc()),
last_sample: now,
memory: Memory::default(),
exit,
status: None,
alive: Some(false),
};
assert!(!observation.confirmed_exit());
let mut report = Vec::new();
observation.write(&mut report).unwrap();
let report = String::from_utf8(report).unwrap();
assert!(report.contains("not proof of termination"));
assert!(!report.contains("process termination observed"));
}
#[test]
fn memory_high_water_marks_are_bounded_and_not_a_causal_diagnosis() {
let mut memory = Memory::default();
memory.sample(40, 80, 30, 100);
memory.sample(20, 90, 50, 100);
assert_eq!((memory.samples, memory.resident_peak, memory.virtual_peak, memory.available_min), (2, 40, 90, Some(30)));
memory.sample(0, 0, 0, 0);
assert_eq!(memory.available_min, Some(30));
}
#[test]
fn windows_status_bits_preserve_unsigned_codes_without_claiming_a_driver_crash() {
for (code, name) in
[(0xc0000005, "STATUS_ACCESS_VIOLATION"), (0xc0000017, "STATUS_NO_MEMORY"), (0xc000012d, "STATUS_COMMITMENT_LIMIT"), (42, "unclassified")]
{
let mut text = Vec::new();
write_windows_status(&mut text, code).unwrap();
let text = String::from_utf8(text).unwrap();
assert!(text.contains(name));
assert!(text.contains(&format!("0x{code:08X}")));
assert!(text.contains("not a verified exception record"));
}
}
#[test]
fn utc_timestamps_are_parseable_and_zero_offset() {
let time = chrono::DateTime::parse_from_rfc3339(&utc()).unwrap();
assert_eq!(time.offset().local_minus_utc(), 0);
}
}
+253 -47
View File
@@ -2,23 +2,31 @@
//!
//! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a
//! helper which never loads a project, font database or GPU. Only an explicit marker after
//! normal teardown suppresses the report. EOF alone identifies an unexpected exit, not its
//! cause. System-wide exhaustion can still prevent the helper or its native dialog working.
//! normal teardown suppresses the report. EOF alone identifies a lost control channel, not
//! a process exit or its cause. System-wide exhaustion can still prevent the helper or its native dialog working.
use std::collections::VecDeque;
use std::ffi::{OsStr, OsString};
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[path = "crash_observation.rs"]
mod observation;
const FLAG: &str = "--internal-crash-reporter";
const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n";
const LOG_BYTES: usize = 64 * 1024;
const HEADER_BYTES: usize = 128;
const READY: &[u8] = b"READY\n";
const EVENT_BYTES: usize = 128;
const EVENT_COUNT: usize = 32;
/// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes
/// the pipe and reports an unexpected exit; there is intentionally no clean-exit Drop hook.
/// the pipe and reports the incident; there is intentionally no clean-exit Drop hook.
pub struct Reporter {
pipe: ChildStdin,
child: Child,
@@ -72,6 +80,20 @@ impl Shutdown {
Ok(())
}
/// Keep only fixed-vocabulary, bounded lifecycle facts outside the GUI. Callers emit each
/// category once; their combined payload fits well below the smallest OS pipe buffer.
pub fn record(&self, event: &'static str) -> io::Result<()> {
if event.len() > 100 || event.contains(['\n', '\r']) {
return Err(io::Error::other("invalid diagnostic observation"));
}
let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?;
if let Some(reporter) = slot.as_mut() {
writeln!(reporter.pipe, "EVENT {event}")?;
reporter.pipe.flush()?;
}
Ok(())
}
/// Consume the reporter exactly once, after the applicable platform teardown boundary.
pub fn finish(&self) -> io::Result<()> {
let reporter = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?.take();
@@ -105,12 +127,18 @@ pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) ->
log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()),
reports.as_os_str().to_owned(),
OsString::from(if interactive { "1" } else { "0" }),
OsString::from(std::process::id().to_string()),
];
spawn(&exe, &args)
}
fn spawn(exe: &Path, args: &[OsString]) -> io::Result<Reporter> {
let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::null()).spawn()?;
let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?;
if let Err(error) = ready(&mut child) {
let _ = child.kill();
let _ = finish_child(&mut child, Duration::from_millis(100));
return Err(error);
}
match child.stdin.take() {
Some(pipe) => Ok(Reporter { pipe, child }),
None => {
@@ -121,6 +149,36 @@ fn spawn(exe: &Path, args: &[OsString]) -> io::Result<Reporter> {
}
}
/// A fixed acknowledgement means the helper holds the process observation before heavy GUI
/// work begins. A suspended helper cannot hold startup indefinitely.
fn ready(child: &mut Child) -> io::Result<()> {
let mut output = child.stdout.take().ok_or_else(|| io::Error::other("crash reporter has no readiness pipe"))?;
let (send, receive) = mpsc::sync_channel(1);
std::thread::Builder::new().name("ec-reporter-ready".into()).spawn(move || {
let mut bytes = [0; READY.len()];
#[cfg(not(test))]
let result =
output.read_exact(&mut bytes).and_then(|()| if bytes == READY { Ok(()) } else { Err(io::Error::other("invalid reporter acknowledgement")) });
// libtest prints its bounded banner before invoking the helper role.
#[cfg(test)]
let result = (|| {
for _ in 0..1024 {
bytes.rotate_left(1);
output.read_exact(&mut bytes[READY.len() - 1..])?;
if bytes == READY {
return Ok(());
}
}
Err(io::Error::other("reporter acknowledgement missing"))
})();
let _ = send.send(result);
// Keep the pipe open for libtest's trailing result (and future helper output), without
// storing it. Dropping it at READY would turn a clean helper exit into BrokenPipe.
let _ = io::copy(&mut output, &mut io::sink());
})?;
receive.recv_timeout(Duration::from_secs(3)).map_err(|_| io::Error::other("crash reporter readiness timed out"))?
}
/// Handle the internal mode before logging, argument parsing, updates or GUI initialization.
/// The callback is a native dialog on this helper's main thread, not on the failed GUI's.
pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool {
@@ -128,7 +186,7 @@ pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool {
return false;
}
let parsed = match args {
[_, id, log, reports, interactive] => id
[_, id, log, reports, interactive, pid] => id
.to_str()
.filter(|id| valid_id(id))
.zip(match interactive.to_str() {
@@ -136,27 +194,61 @@ pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool {
Some("1") => Some(true),
_ => None,
})
.map(|(id, interactive)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive)),
.zip(pid.to_str().and_then(|p| p.parse::<u32>().ok()).filter(|pid| *pid != 0))
.map(|((id, interactive), pid)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive, pid)),
_ => None,
};
let Some((id, log, reports, interactive)) = parsed else {
let Some((id, log, reports, interactive, pid)) = parsed else {
eprintln!("EffectCraft crash reporter: invalid internal arguments");
return true;
};
// Keep the exact file open while the GUI is alive: a later rotation must not substitute
// another instance's log. If rotation won the startup race, the session marker rejects it.
let mut log = SessionLog::open(log, id);
match clean_exit(&mut io::stdin().lock()) {
Ok(true) => {}
result => {
let failure = result.err().map(|e| e.to_string());
let saved = write_report(reports, id, &mut log, failure.as_deref());
let description = description(&saved);
eprintln!("{description}");
if interactive {
show(&description);
let mut observation = observation::Observation::start(pid);
// A GUI that dies during helper startup can already have closed this read end. Keep
// watching its control pipe and save the report rather than losing that early failure.
let _ = io::stdout().write_all(READY).and_then(|()| io::stdout().flush());
// The reader has one bounded queue; the monitor never waits on a live pipe, so it can
// sample the target even when no log or lifecycle message is emitted.
let (send, receive) = mpsc::sync_channel(1);
let reader = std::thread::Builder::new().name("ec-reporter-pipe".into()).spawn(move || {
let mut input = io::stdin().lock();
loop {
let message = read_message(&mut input);
let terminal = !matches!(message, Ok(Message::Event(_)));
if send.send(message).is_err() || terminal {
break;
}
}
});
let mut events = VecDeque::with_capacity(EVENT_COUNT);
let failure = if reader.is_err() {
Some("control reader could not start".to_owned())
} else {
loop {
observation.tick();
match receive.recv_timeout(Duration::from_millis(250)) {
Ok(Ok(Message::Clean)) => return true,
Ok(Ok(Message::Event(event))) => {
if events.len() == EVENT_COUNT {
events.pop_front();
}
events.push_back(format!("{} {event}", observation::utc()));
}
Ok(Ok(Message::Ended)) => break None,
Ok(Err(error)) => break Some(error.to_string()),
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break Some("control reader disconnected".to_owned()),
}
}
};
observation.finish();
let saved = write_report(reports, id, &mut log, failure.as_deref(), &observation, &events);
let description = description(&saved, observation.confirmed_exit());
eprintln!("{description}");
if interactive {
show(&description);
}
true
}
@@ -165,20 +257,43 @@ fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-')
}
/// Fixed-size comparison: arbitrary/truncated input never grows a buffer or counts as clean.
fn clean_exit(input: &mut impl Read) -> io::Result<bool> {
let mut marker = [0u8; CLEAN.len()];
let mut filled = 0;
while filled < marker.len() {
let remaining = marker.get_mut(filled..).ok_or_else(|| io::Error::other("invalid control marker offset"))?;
match input.read(remaining) {
Ok(0) => return Ok(false),
Ok(n) => filled += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
enum Message {
Clean,
Event(String),
Ended,
}
/// Every line and the retained event ring are bounded, including malformed control input.
fn read_message(input: &mut impl Read) -> io::Result<Message> {
let mut line = [0u8; EVENT_BYTES];
for i in 0..line.len() {
let byte = line.get_mut(i).ok_or_else(|| io::Error::other("invalid control offset"))?;
loop {
match input.read(std::slice::from_mut(byte)) {
Ok(0) => return Ok(Message::Ended),
Ok(_) => break,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
if *byte == b'\n' {
let text = line.get(..=i).ok_or_else(|| io::Error::other("invalid control line"))?;
if text == CLEAN {
return Ok(Message::Clean);
}
if let Some(event) = text.strip_prefix(b"EVENT ") {
let event = std::str::from_utf8(event).map_err(|_| io::Error::other("non-UTF8 diagnostic observation"))?;
return Ok(Message::Event(event.trim_end().to_owned()));
}
return Ok(Message::Ended);
}
}
Ok(marker == CLEAN)
Err(io::Error::other("control line exceeded its fixed limit"))
}
#[cfg(test)]
fn clean_exit(input: &mut impl Read) -> io::Result<bool> {
Ok(matches!(read_message(input)?, Message::Clean))
}
enum SessionLog {
@@ -228,7 +343,14 @@ impl SessionLog {
}
}
fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Option<&str>) -> io::Result<PathBuf> {
fn write_report(
reports: &Path,
id: &str,
log: &mut SessionLog,
pipe_error: Option<&str>,
observation: &observation::Observation,
events: &VecDeque<String>,
) -> io::Result<PathBuf> {
if !valid_id(id) {
return Err(io::Error::other("invalid report identity"));
}
@@ -244,8 +366,13 @@ fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Opti
)?;
writeln!(
report,
"The GUI's control pipe ended without its clean-exit marker.\nThe exit status and exact cause are not available to this reporter.\nMemory exhaustion, a native crash or external termination are possible.\nThis report was saved locally; it was not sent anywhere."
"The GUI control channel ended without its clean-exit marker.\nThe exact cause is not established. GPU fallback, memory pressure and process exit are separate observations, not a proven causal chain.\nThis report was saved locally; it was not sent anywhere."
)?;
observation.write(&mut report)?;
writeln!(report, "\nRetained lifecycle observations (last {EVENT_COUNT}; UTC is helper receipt time):")?;
for event in events {
writeln!(report, "{event}")?;
}
if let Some(error) = pipe_error {
writeln!(report, "Control pipe error: {error}")?;
}
@@ -256,13 +383,15 @@ fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Opti
Ok(path)
}
fn description(saved: &io::Result<PathBuf>) -> String {
fn description(saved: &io::Result<PathBuf>, confirmed_exit: bool) -> String {
let diagnostic = match saved {
Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()),
Err(error) => format!("A diagnostic report could not be saved: {error}"),
};
let incident =
if confirmed_exit { "EffectCraft stopped unexpectedly." } else { "EffectCraft lost its diagnostic control channel; process exit is not confirmed." };
format!(
"EffectCraft stopped unexpectedly.\n\n{diagnostic}\n\nThe exact cause is unknown. Reopen EffectCraft to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere."
"{incident}\n\n{diagnostic}\n\nThe exact cause is unknown. If EffectCraft has closed, reopen it to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere."
)
}
@@ -276,6 +405,10 @@ mod tests {
static NEXT: AtomicUsize = AtomicUsize::new(0);
const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE";
fn test_report(dir: &Path, id: &str, log: &mut SessionLog) -> io::Result<PathBuf> {
write_report(dir, id, log, None, &observation::Observation::start(std::process::id()), &VecDeque::new())
}
fn folder() -> PathBuf {
let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&p).unwrap();
@@ -283,7 +416,7 @@ mod tests {
}
fn test_name(name: &str) -> String {
// Also usable as a standalone std-only rustc test while the app's build cache is busy.
// Also usable from an isolated harness with the reporter dependencies.
let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest);
format!("{prefix}::{name}")
}
@@ -299,10 +432,12 @@ mod tests {
.env("EC_REPORT_DIR", dir)
.env("EC_REPORT_ID", id)
.env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" })
.env("EC_REPORT_PID", std::process::id().to_string())
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stdout(Stdio::piped())
.spawn()
.unwrap();
ready(&mut child).unwrap();
Reporter { pipe: child.stdin.take().unwrap(), child }
}
@@ -321,7 +456,7 @@ mod tests {
for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] {
assert!(!clean_exit(&mut input).unwrap());
}
assert!(!clean_exit(&mut &vec![b'x'; 100_000][..]).unwrap());
assert!(clean_exit(&mut &vec![b'x'; 100_000][..]).is_err());
}
#[test]
@@ -336,9 +471,9 @@ mod tests {
let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap();
old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap();
old.write_all(b"\nGPU context retired\n").unwrap();
let report = write_report(&dir, id, &mut log, None).unwrap();
let report = test_report(&dir, id, &mut log).unwrap();
let text = std::fs::read_to_string(report).unwrap();
assert!(text.len() < LOG_BYTES + 2048);
assert!(text.len() < LOG_BYTES + 4096);
assert!(text.contains("GPU context retired"));
assert!(!text.contains("OTHER SESSION"));
std::fs::remove_dir_all(dir).unwrap();
@@ -350,7 +485,7 @@ mod tests {
let path = dir.join("log");
std::fs::write(&path, "private other session").unwrap();
for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] {
let report = write_report(&dir, id, &mut SessionLog::open(source, id), None).unwrap();
let report = test_report(&dir, id, &mut SessionLog::open(source, id)).unwrap();
let text = std::fs::read_to_string(report).unwrap();
assert!(text.contains("Recent log unavailable"));
assert!(!text.contains("private other session"));
@@ -365,7 +500,7 @@ mod tests {
std::fs::write(&path, "crash-report session 1-2\noriginal session\n").unwrap();
let mut log = SessionLog::open(Some(&path), "1-2");
std::fs::write(&path, "crash-report session 3-4\nprivate other session\n").unwrap();
let report = write_report(&dir, "1-2", &mut log, None).unwrap();
let report = test_report(&dir, "1-2", &mut log).unwrap();
let text = std::fs::read_to_string(report).unwrap();
assert!(text.contains("Recent log unavailable"));
assert!(!text.contains("private other session"));
@@ -388,13 +523,13 @@ mod tests {
fn report_write_failure_is_described_and_existing_report_is_not_overwritten() {
let dir = folder();
let mut log = SessionLog::Unavailable("no log".into());
let first = write_report(&dir, "2-1", &mut log, None).unwrap();
let first = test_report(&dir, "2-1", &mut log).unwrap();
let before = std::fs::read(&first).unwrap();
let result = write_report(&dir, "2-1", &mut log, None);
let result = test_report(&dir, "2-1", &mut log);
assert!(result.is_err());
assert!(description(&result).contains("could not be saved"));
assert!(description(&result, false).contains("could not be saved"));
assert_eq!(std::fs::read(first).unwrap(), before);
assert!(write_report(&dir, "../escape", &mut log, None).is_err());
assert!(test_report(&dir, "../escape", &mut log).is_err());
std::fs::remove_dir_all(dir).unwrap();
}
@@ -470,7 +605,10 @@ mod tests {
assert!(!status.success());
wait_file(&dir.join("ready-4-1"));
let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap();
assert!(text.contains("exact cause are not available"));
assert!(text.contains("exact cause is not established"));
assert!(text.contains("Detection UTC:"));
#[cfg(windows)]
assert!(text.contains("Windows status bits:"));
std::fs::remove_dir_all(dir).unwrap();
}
@@ -531,15 +669,70 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn lost_pipe_keeps_bounded_lifecycle_evidence_without_claiming_process_exit() {
let dir = folder();
let reporter = Shutdown::default();
reporter.install(helper(&dir, "9-1")).unwrap();
for _ in 0..40 {
reporter.record("GPU observation: wgpu validation error").unwrap();
}
drop(reporter);
wait_file(&dir.join("ready-9-1"));
let text = std::fs::read_to_string(dir.join("crash-9-1.txt")).unwrap();
assert!(text.contains("GUI still present"));
assert!(!text.contains("process termination observed"));
assert_eq!(text.matches("GPU observation: wgpu validation error").count(), EVENT_COUNT);
assert!(text.contains("Target-present sampling attempts:"));
assert!(text.len() < 8192);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn clean_exit_after_lifecycle_events_does_not_write_a_report() {
let dir = folder();
let reporter = Shutdown::default();
reporter.install(helper(&dir, "9-2")).unwrap();
reporter.record("Backend selection source: default").unwrap();
reporter.finish().unwrap();
wait_file(&dir.join("ready-9-2"));
assert!(!dir.join("crash-9-2.txt").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
#[cfg(windows)]
fn windows_report_preserves_real_unsigned_exit_bits() {
let dir = folder();
let status = Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
.env(ROLE, "windows-status")
.env("EC_REPORT_DIR", &dir)
.stdout(Stdio::null())
.status()
.unwrap();
assert_eq!(status.code().map(|code| code as u32), Some(0xc0000017));
wait_file(&dir.join("ready-9-3"));
let text = std::fs::read_to_string(dir.join("crash-9-3.txt")).unwrap();
assert!(text.contains("Windows status bits: 0xC0000017 (STATUS_NO_MEMORY)"));
assert!(text.contains("not a verified exception record"));
assert!(text.contains("process termination observed"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn internal_mode_rejects_invalid_arguments_without_launching_the_app() {
assert!(!maybe_run(&[], |_| panic!("no dialog")));
assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog")));
assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog")));
let args = [FLAG, "../escape", "", "", "1"].map(OsString::from);
let args = [FLAG, "../escape", "", "", "1", "1"].map(OsString::from);
assert!(maybe_run(&args, |_| panic!("no dialog")));
let args = [FLAG, "1-1", "", "", "invalid"].map(OsString::from);
let args = [FLAG, "1-1", "", "", "invalid", "1"].map(OsString::from);
assert!(maybe_run(&args, |_| panic!("no dialog")));
for pid in ["0", "not-a-pid", "4294967296"] {
let args = [FLAG, "1-1", "", "", "0", pid].map(OsString::from);
assert!(maybe_run(&args, |_| panic!("no dialog")));
}
}
#[test]
@@ -556,7 +749,14 @@ mod tests {
let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap());
let id = std::env::var("EC_REPORT_ID").unwrap();
let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap();
let args = [OsString::from(FLAG), OsString::from(&id), OsString::new(), dir.as_os_str().to_owned(), OsString::from(&interactive)];
let args = [
OsString::from(FLAG),
OsString::from(&id),
OsString::new(),
dir.as_os_str().to_owned(),
OsString::from(&interactive),
std::env::var_os("EC_REPORT_PID").unwrap(),
];
assert!(maybe_run(&args, |description| {
assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog");
assert!(dir.join(format!("crash-{id}.txt")).exists());
@@ -577,6 +777,12 @@ mod tests {
after_teardown(Some(&reporter.clone()), || {}).unwrap();
std::process::exit(0); // Models macOS not returning from its native event loop.
}
if role == "windows-status" {
let _reporter = helper(&dir, "9-3");
// The real OS status is intentionally set, not actual memory exhaustion. The
// report must preserve this distinction rather than claiming a verified exception.
std::process::exit(0xc0000017u32 as i32);
}
assert_eq!(role, "abort");
let _reporter = helper(&dir, "4-1");
std::process::abort();
+147
View File
@@ -0,0 +1,147 @@
//! Fixed-vocabulary observations retained by the crash helper independently of the log tail.
//! Driver messages and environment text can contain private data; never forward either here.
use std::ffi::OsStr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use crate::crash_report::Shutdown;
pub fn record(reporter: &Shutdown, event: &'static str) {
log::info!("diagnostic observation: {event}");
if let Err(error) = reporter.record(event) {
log::warn!("could not retain diagnostic observation: {error}");
}
}
/// Mirror only wgpu's documented names/aliases, without calling its parser: that parser
/// logs unknown tokens verbatim. Unknown, non-Unicode and oversized values remain private.
pub fn backend_override_events(value: &OsStr) -> Vec<&'static str> {
let Some(value) = value.to_str().filter(|value| value.len() <= 4096) else {
return vec!["WGPU_BACKEND: non-Unicode or oversized value omitted"];
};
let mut selected = [false; 6];
let mut unknown = false;
for token in value.split(',').map(str::trim) {
let aliases: &[&[&str]] = &[&["vulkan", "vk"], &["dx12", "d3d12"], &["metal", "mtl"], &["opengl", "gles", "gl"], &["webgpu"], &["noop"]];
if let Some(index) = aliases.iter().position(|names| names.iter().any(|name| token.eq_ignore_ascii_case(name))) {
if let Some(flag) = selected.get_mut(index) {
*flag = true;
}
} else {
unknown = true;
}
}
let events = [
"WGPU_BACKEND requests: vulkan",
"WGPU_BACKEND requests: dx12",
"WGPU_BACKEND requests: metal",
"WGPU_BACKEND requests: gl",
"WGPU_BACKEND requests: webgpu",
"WGPU_BACKEND requests: noop",
];
let mut result: Vec<_> = selected.into_iter().zip(events).filter_map(|(present, event)| present.then_some(event)).collect();
if unknown {
result.push("WGPU_BACKEND: unrecognized or empty tokens omitted");
}
result
}
/// At most eight events per device, even when a driver floods its callbacks. A wgpu OOM
/// observation is not proof of system RAM exhaustion; device loss is not a driver-crash verdict.
#[derive(Clone)]
pub struct GpuObservations {
seen: Arc<AtomicU8>,
reporter: Shutdown,
}
impl GpuObservations {
pub fn new(reporter: &Shutdown) -> Self {
Self { seen: Arc::new(AtomicU8::new(0)), reporter: reporter.clone() }
}
pub fn uncaptured(&self, error: &eframe::wgpu::Error) {
let (bit, event) = match error {
eframe::wgpu::Error::OutOfMemory { .. } => (1, "GPU observation: wgpu out-of-memory error (not proof of system RAM exhaustion)"),
eframe::wgpu::Error::Validation { .. } => (2, "GPU observation: wgpu validation error"),
eframe::wgpu::Error::Internal { .. } => (4, "GPU observation: wgpu internal error"),
};
self.once(bit, event);
}
pub fn scoped(&self, error: effectcraft_ui_egui::gpu_failure::ScopedError) {
use effectcraft_ui_egui::gpu_failure::ScopedError;
let (bit, event) = match error {
ScopedError::OutOfMemory => (32, "GPU scoped observation: out-of-memory (not proof of system RAM exhaustion)"),
ScopedError::Internal => (64, "GPU scoped observation: internal error (not confirmed out-of-memory)"),
ScopedError::Validation => (128, "GPU scoped observation: validation error"),
};
self.once(bit, event);
}
pub fn device_lost(&self, reason: eframe::wgpu::DeviceLostReason) {
match reason {
eframe::wgpu::DeviceLostReason::Unknown => self.once(8, "GPU observation: device lost, reason unknown (not proof of a driver crash)"),
eframe::wgpu::DeviceLostReason::Destroyed => self.once(16, "GPU observation: device-lost callback, explicit device destruction"),
};
}
fn once(&self, bit: u8, event: &'static str) -> bool {
if self.seen.fetch_or(bit, Ordering::Relaxed) & bit != 0 {
return false;
}
record(&self.reporter, event);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn override_names_are_normalized_deduplicated_and_private() {
let events = backend_override_events(OsStr::new(" Vk, VULKAN, d3d12, MTL, gles, webgpu, noop, private-token"));
assert_eq!(events.len(), 7);
assert_eq!(events.first(), Some(&"WGPU_BACKEND requests: vulkan"));
assert!(!events.join(" ").contains("private-token"));
assert!(events.iter().all(|event| event.len() <= 100));
assert_eq!(backend_override_events(OsStr::new("")).len(), 1);
assert_eq!(backend_override_events(OsStr::new(&"x".repeat(4097))), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]);
}
#[test]
fn gpu_categories_are_latched_independently() {
let observations = GpuObservations::new(&Shutdown::default());
observations.uncaptured(&eframe::wgpu::Error::OutOfMemory { source: Box::new(std::io::Error::other("private driver detail")) });
observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown);
observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown);
assert_eq!(observations.seen.load(Ordering::Relaxed), 9);
observations.device_lost(eframe::wgpu::DeviceLostReason::Destroyed);
observations.uncaptured(&eframe::wgpu::Error::Validation {
source: Box::new(std::io::Error::other("private driver detail")),
description: "private validation detail".into(),
});
observations.uncaptured(&eframe::wgpu::Error::Internal {
source: Box::new(std::io::Error::other("private driver detail")),
description: "private internal detail".into(),
});
assert_eq!(observations.seen.load(Ordering::Relaxed), 31);
use effectcraft_ui_egui::gpu_failure::ScopedError;
observations.scoped(ScopedError::Internal);
assert_eq!(observations.seen.load(Ordering::Relaxed), 95, "internal scope must not set scoped OOM bit");
observations.scoped(ScopedError::OutOfMemory);
observations.scoped(ScopedError::Validation);
observations.scoped(ScopedError::OutOfMemory);
assert_eq!(observations.seen.load(Ordering::Relaxed), 255);
assert!(!observations.once(1, "GPU observation: duplicate"));
assert!(!observations.once(8, "GPU observation: duplicate"));
}
#[test]
#[cfg(unix)]
fn non_unicode_override_is_not_copied() {
use std::os::unix::ffi::OsStrExt;
assert_eq!(backend_override_events(OsStr::from_bytes(b"private\xff")), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]);
}
}
+326 -15
View File
@@ -8,9 +8,9 @@
//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A
//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those
//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still
//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile
//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to
//! Automatic ([`opengl_failed`]) rather than failing every launch.
//! wins over both. A separate guard file is locked while its launch runs, so a second copy opened
//! meanwhile isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics
//! back to Automatic ([`opengl_failed`]) rather than failing every launch.
use std::fs::File;
use std::path::{Path, PathBuf};
@@ -21,6 +21,46 @@ use eframe::wgpu::Backends;
/// Left in the settings folder from a launch until its window has settled.
const MARKER: &str = "launch-pending";
const LOCK: &str = "launch.lock";
/// Keep this file in place: unlinking a locked file lets a second inode acquire a new lock.
fn acquire_guard(dir: &Path) -> Option<File> {
let file = match std::fs::create_dir_all(dir)
.and_then(|()| std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)))
{
Ok(file) => file,
Err(e) => {
log::warn!("launch guard unavailable: {e}");
return None;
}
};
match file.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => return None,
Err(e) => {
log::warn!("launch guard lock unavailable: {e}");
return None;
}
}
// Older versions lock the marker itself. Probe only the lock: Windows also prevents
// reads through another handle while it is held. An unreadable marker is not a stale one.
match File::open(dir.join(MARKER)) {
Ok(marker) => match marker.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => return None,
Err(e) => {
log::warn!("launch marker lock unavailable: {e}");
return None;
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
log::warn!("launch marker unavailable: {e}");
return None;
}
}
Some(file)
}
/// How long the window keeps drawing after its first frame before the launch counts as made.
/// A driver that fails as the window is shown and its surface re-created does so within the
@@ -28,16 +68,39 @@ const MARKER: &str = "launch-pending";
/// switch back in Settings.
pub const SETTLE: Duration = Duration::from_secs(3);
/// Why this launch requested its window graphics APIs. An interrupted startup is an
/// observation, not proof that a driver caused the previous exit.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum BackendSource {
#[default]
Default,
SavedPreference,
InterruptedStartup,
EnvironmentOverride,
}
impl BackendSource {
pub fn event(self) -> &'static str {
match self {
Self::Default => "backend source: default",
Self::SavedPreference => "backend source: saved OpenGL preference",
Self::InterruptedStartup => "backend source: previous interrupted startup; OpenGL fallback",
Self::EnvironmentOverride => "backend source: WGPU_BACKEND present",
}
}
}
/// This launch's window graphics.
#[derive(Debug, Default)]
pub struct Launch {
/// The graphics APIs the window may use (`None`: eframe's default).
pub backends: Option<Backends>,
pub source: BackendSource,
/// Switched to OpenGL because the previous launch stopped as its window opened: the message
/// to show.
pub notice: Option<String>,
marker: Option<PathBuf>,
/// Holds the marker's lock until the launch settles (or the process ends).
/// Holds the separate guard file's lock until the launch settles (or the process ends).
lock: Option<File>,
/// When the window drew its first frame, until the launch settles.
first_frame: Option<Instant>,
@@ -48,14 +111,22 @@ impl Launch {
/// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this
/// launch's marker. `env_override`: `WGPU_BACKEND` is set.
pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch {
let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() };
if env_override {
return Launch { source: BackendSource::EnvironmentOverride, ..Launch::default() };
}
let Some(dir) = dir else { return Launch::default() };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
let marker = dir.join(MARKER);
// Locked by another launch that is still opening its window: not a crash.
let held = File::open(&marker).is_ok_and(|f| matches!(f.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
let Some(lock) = acquire_guard(dir) else {
let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL);
let source = if backends.is_some() { BackendSource::SavedPreference } else { BackendSource::Default };
return Launch { backends, source, ..Launch::default() };
};
// Read again while owning the guard: another launch may have just changed preferences.
prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
let mut notice = None;
if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
if marker.exists() && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
prefs.startup.window_graphics = "gl".into();
match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) {
Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"),
@@ -69,15 +140,21 @@ impl Launch {
);
}
let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL);
let (marker, lock) = match std::fs::create_dir_all(dir).and_then(|()| File::create(&marker)) {
// Where locks aren't available the guard works as before.
Ok(f) => (Some(marker), f.try_lock().is_ok().then_some(f)),
let source = if notice.is_some() {
BackendSource::InterruptedStartup
} else if backends.is_some() {
BackendSource::SavedPreference
} else {
BackendSource::Default
};
let marker = match File::create(&marker) {
Ok(_) => Some(marker),
Err(e) => {
log::warn!("launch marker: {e}");
(None, None)
None
}
};
Launch { backends, notice, marker, lock, first_frame: None }
Launch { backends, source, notice, marker, lock: Some(lock), first_frame: None }
}
/// The window drew a frame. Until the launch has settled, returns how long that still takes
@@ -104,18 +181,19 @@ impl Launch {
/// The launch made it: release and remove its marker.
fn settled(&mut self) {
self.lock = None;
if let Some(m) = self.marker.take()
&& let Err(e) = std::fs::remove_file(&m)
{
log::warn!("launch marker: {e}");
}
self.lock = None;
}
}
/// The window couldn't open with OpenGL (an error, not a crash): back to Automatic, and no marker.
pub fn opengl_failed(dir: Option<&Path>) {
let Some(dir) = dir else { return };
let Some(_lock) = acquire_guard(dir) else { return };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
prefs.startup.window_graphics = "auto".into();
@@ -142,6 +220,7 @@ mod tests {
// may still crash as the window is shown); frames for `SETTLE` do.
let mut l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (None, false));
assert_eq!(l.source, BackendSource::Default);
assert!(dir.join(MARKER).exists());
let t0 = Instant::now();
assert_eq!(l.frame_drawn_at(t0), Some(SETTLE));
@@ -159,14 +238,18 @@ mod tests {
} else {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
assert_eq!(l.source, BackendSource::InterruptedStartup);
assert_eq!(window_graphics(&dir), "gl", "saved in Settings");
l.exited();
let l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice");
assert_eq!(l.source, BackendSource::SavedPreference);
}
// `WGPU_BACKEND` wins, and leaves no marker.
let _ = std::fs::remove_file(dir.join(MARKER));
assert_eq!(Launch::begin(Some(&dir), true).backends, None);
let overridden = Launch::begin(Some(&dir), true);
assert_eq!(overridden.backends, None);
assert_eq!(overridden.source, BackendSource::EnvironmentOverride);
assert!(!dir.join(MARKER).exists());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -217,4 +300,232 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A fresh settings folder for one test.
fn fresh_dir(case: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Window Graphics saved as OpenGL.
fn save_opengl(dir: &Path) {
let mut prefs = Prefs::default();
prefs.startup.window_graphics = "gl".into();
std::fs::write(dir.join(PREFS_FILE), prefs.to_json()).unwrap();
}
#[test]
fn a_second_copy_reports_the_saved_backend_without_owning_the_guard() {
let dir = fresh_dir("second-source");
save_opengl(&dir);
let mut first = Launch::begin(Some(&dir), false);
let mut second = Launch::begin(Some(&dir), false);
assert!(second.lock.is_none());
assert_eq!((second.backends, second.source), (Some(Backends::GL), BackendSource::SavedPreference));
second.exited();
first.exited();
let _ = std::fs::remove_dir_all(dir);
}
/// A second copy that quits normally while the first is still opening doesn't remove the first
/// one's marker (#477).
#[test]
fn a_second_copy_quitting_leaves_the_running_launchs_marker() {
let dir = fresh_dir("second-quits");
let mut first = Launch::begin(Some(&dir), false);
let mut second = Launch::begin(Some(&dir), false);
second.exited();
let kept = dir.join(MARKER).exists();
first.exited();
let _ = std::fs::remove_dir_all(&dir);
assert!(kept, "the second copy removed the running launch's marker");
}
/// A second copy whose OpenGL window fails doesn't remove a running launch's marker.
#[test]
fn failed_opengl_in_a_second_copy_leaves_the_running_launchs_marker() {
let dir = fresh_dir("second-gl-fails");
let mut first = Launch::begin(Some(&dir), false);
let second = Launch::begin(Some(&dir), false);
opengl_failed(Some(&dir));
let kept = dir.join(MARKER).exists();
drop(second);
first.exited();
let _ = std::fs::remove_dir_all(&dir);
assert!(kept, "OpenGL cleanup removed another running launch's marker");
}
/// With `WGPU_BACKEND` set, a stale marker and a saved OpenGL choice are left as they are.
#[test]
fn wgpu_backend_leaves_a_stale_marker_and_saved_opengl_alone() {
let dir = fresh_dir("env-override");
save_opengl(&dir);
std::fs::write(dir.join(MARKER), b"stale-marker").unwrap();
let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
let mut l = Launch::begin(Some(&dir), true);
assert_eq!(l.backends, None);
assert!(l.notice.is_none());
l.exited();
assert!(dir.join(MARKER).exists());
assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
let _ = std::fs::remove_dir_all(&dir);
}
/// After OpenGL fails to open a window, the next launch is back on Automatic, without a notice.
#[test]
fn failed_opengl_puts_the_next_launch_back_on_automatic() {
let dir = fresh_dir("gl-recovers");
save_opengl(&dir);
let l = Launch::begin(Some(&dir), false);
assert_eq!(l.backends, Some(Backends::GL));
drop(l);
opengl_failed(Some(&dir));
assert!(!dir.join(MARKER).exists());
assert_eq!(Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics, "auto");
let mut l = Launch::begin(Some(&dir), false);
assert_eq!(l.backends, None);
assert!(l.notice.is_none());
l.exited();
let _ = std::fs::remove_dir_all(&dir);
}
/// The guard file is never removed or replaced: unlinking a locked file would let the next
/// launch lock a new one while the old lock is still held.
#[cfg(unix)]
#[test]
fn the_guard_file_stays_the_same_file_across_launches() {
use std::os::unix::fs::MetadataExt;
let dir = fresh_dir("guard-identity");
let mut a = Launch::begin(Some(&dir), false);
let before = std::fs::metadata(dir.join(LOCK)).unwrap();
let t0 = Instant::now();
assert_eq!(a.frame_drawn_at(t0), Some(SETTLE));
assert_eq!(a.frame_drawn_at(t0 + SETTLE), None);
assert!(!dir.join(MARKER).exists());
let after = std::fs::metadata(dir.join(LOCK)).unwrap();
assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino()));
let mut b = Launch::begin(Some(&dir), false);
assert!(b.lock.is_some());
let reused = std::fs::metadata(dir.join(LOCK)).unwrap();
assert_eq!((before.dev(), before.ino()), (reused.dev(), reused.ino()));
b.exited();
let _ = std::fs::remove_dir_all(dir);
}
/// A second copy's frames and quit neither settle nor unlock the running launch.
#[test]
fn a_second_copys_frames_dont_settle_the_running_launch() {
let dir = fresh_dir("second-frames");
let mut a = Launch::begin(Some(&dir), false);
std::fs::write(dir.join(MARKER), b"owner-marker").unwrap();
let mut b = Launch::begin(Some(&dir), false);
assert!(b.lock.is_none());
assert!(b.marker.is_none());
let t0 = Instant::now();
assert_eq!(b.frame_drawn_at(t0), None);
assert_eq!(b.frame_drawn_at(t0 + SETTLE), None);
b.exited();
assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"owner-marker");
let probe = File::open(dir.join(LOCK)).unwrap();
assert!(matches!(probe.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
a.exited();
let _ = std::fs::remove_dir_all(dir);
}
/// Older versions lock the marker itself: while one runs, a new launch changes nothing, and
/// once it has stopped without settling the next launch still switches to OpenGL.
#[test]
fn a_running_older_version_holding_the_marker_is_left_alone() {
let dir = fresh_dir("older-version");
std::fs::write(dir.join(MARKER), b"old-owner").unwrap();
std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
let old = File::open(dir.join(MARKER)).unwrap();
old.try_lock().unwrap();
let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
let mut b = Launch::begin(Some(&dir), false);
assert!(b.lock.is_none());
assert!(b.marker.is_none());
assert!(b.backends.is_none());
assert!(b.notice.is_none());
b.exited();
opengl_failed(Some(&dir));
assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
// Windows locks forbid reading through another handle. Inspect the contents only after
// releasing the old owner's lock, but before recovery can truncate the marker.
drop(old);
assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner");
let mut recovered = Launch::begin(Some(&dir), false);
if cfg!(not(target_os = "macos")) {
assert_eq!(recovered.backends, Some(Backends::GL));
}
recovered.exited();
let _ = std::fs::remove_dir_all(dir);
}
/// A Windows sharing violation is not evidence that a marker is stale or absent.
#[cfg(windows)]
#[test]
fn an_unopenable_marker_leaves_settings_and_marker_alone() {
use std::os::windows::fs::OpenOptionsExt;
let dir = fresh_dir("marker-unopenable");
std::fs::write(dir.join(MARKER), b"old-owner").unwrap();
std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
let old = std::fs::OpenOptions::new().read(true).write(true).share_mode(0).open(dir.join(MARKER)).unwrap();
let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
let mut launch = Launch::begin(Some(&dir), false);
assert!(launch.lock.is_none());
assert!(launch.marker.is_none());
assert!(launch.backends.is_none());
assert!(launch.notice.is_none());
launch.exited();
opengl_failed(Some(&dir));
assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
drop(old);
assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner");
let _ = std::fs::remove_dir_all(dir);
}
/// A guard file that can't be opened leaves Window Graphics and the marker as they are.
#[test]
fn an_unusable_guard_file_leaves_settings_and_marker_alone() {
let dir = fresh_dir("guard-unusable");
std::fs::create_dir(dir.join(LOCK)).unwrap();
std::fs::write(dir.join(MARKER), b"stale-marker").unwrap();
std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
let mut l = Launch::begin(Some(&dir), false);
assert!(l.backends.is_none());
assert!(l.notice.is_none());
assert!(l.lock.is_none());
assert!(l.marker.is_none());
l.exited();
opengl_failed(Some(&dir));
assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"stale-marker");
let _ = std::fs::remove_dir_all(dir);
}
/// OpenGL cleanup from elsewhere waits until the running launch has let go; then it resets
/// Window Graphics to Automatic and removes the marker.
#[test]
fn failed_opengl_cleanup_waits_for_the_running_launch() {
let dir = fresh_dir("gl-cleanup-waits");
save_opengl(&dir);
let launch = Launch::begin(Some(&dir), false);
assert_eq!(launch.backends, Some(Backends::GL));
assert!(launch.lock.is_some());
let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
opengl_failed(Some(&dir));
assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
assert!(dir.join(MARKER).exists(), "cleanup elsewhere can't remove a running launch's marker");
drop(launch);
opengl_failed(Some(&dir));
let after = Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics;
assert_eq!(after, "auto", "once the launch has let go, failed OpenGL resets Window Graphics");
assert!(!dir.join(MARKER).exists());
let _ = std::fs::remove_dir_all(dir);
}
}
+31 -5
View File
@@ -23,6 +23,7 @@ mod apple_events;
mod audio_out;
mod control_server;
mod crash_report;
mod diagnostic_state;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -183,7 +184,7 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) {
fn main() -> eframe::Result {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if crash_report::maybe_run(&args, |description| {
rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft stopped unexpectedly").set_description(description).show();
rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft diagnostic report").set_description(description).show();
}) {
return Ok(());
}
@@ -246,8 +247,15 @@ fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result {
#[cfg(target_os = "linux")]
appimage::integrate_from_env(ICON_PNG);
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
// stopped as its window opened (a crashing graphics driver, #243).
let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
// stopped as its window opened (the marker alone does not establish a cause).
let backend_override = std::env::var_os("WGPU_BACKEND");
let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), backend_override.is_some());
diagnostic_state::record(reporter, launch.source.event());
if let Some(value) = backend_override {
for event in diagnostic_state::backend_override_events(&value) {
diagnostic_state::record(reporter, event);
}
}
let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
let notice = launch.notice.take();
let options = eframe::NativeOptions {
@@ -281,16 +289,33 @@ fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result {
Box::new(move |cc| {
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
match &cc.wgpu_render_state {
Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
None => log::info!("graphics: no wgpu device"),
Some(rs) => {
let info = rs.adapter.get_info();
log::info!("graphics: {info:?}");
let event = match info.backend {
eframe::wgpu::Backend::Noop => "actual GPU backend: noop",
eframe::wgpu::Backend::Vulkan => "actual GPU backend: vulkan",
eframe::wgpu::Backend::Metal => "actual GPU backend: metal",
eframe::wgpu::Backend::Dx12 => "actual GPU backend: dx12",
eframe::wgpu::Backend::Gl => "actual GPU backend: gl",
eframe::wgpu::Backend::BrowserWebGpu => "actual GPU backend: webgpu",
};
diagnostic_state::record(&desktop_reporter, event);
}
None => diagnostic_state::record(&desktop_reporter, "actual GPU backend: no wgpu device"),
}
// This executable owns the shared device's handlers. Install them before
// EffectCraft's compositor pipelines are built, without changing handlers from
// inside Gpu::new. eframe has already constructed its presentation renderer here.
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
let observations = diagnostic_state::GpuObservations::new(&desktop_reporter);
let scoped_observed = observations.clone();
gpu_failures.set_scoped_error_observer(move |error| scoped_observed.scoped(error));
if let Some(rs) = &cc.wgpu_render_state {
let errors_observed = observations.clone();
let errors = gpu_failures.clone();
rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
errors_observed.uncaptured(&error);
let message = format!("uncaptured GPU error: {error}");
if errors.report(&message, false) {
log::error!("{message}");
@@ -298,6 +323,7 @@ fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result {
}));
let lost = gpu_failures.clone();
rs.device.set_device_lost_callback(move |reason, message| {
observations.device_lost(reason);
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
});
}
+7
View File
@@ -143,6 +143,8 @@ const WRITES: &[(&str, &[&str])] = &[
("shortcuts.export", &["path"]),
("renderQueue.add", &["output", "path"]),
("file.exportTimeline", &["path"]),
("journal.export", &["path"]),
("help.exportRepro", &["path"]),
// `path` / `output` may also be a file-name template, as for render.preRender.
("file.createProxy", &["path", "output"]),
];
@@ -280,6 +282,11 @@ impl Confinement {
if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given {
return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined")));
}
// The default repro destination is the settings store, not an automation write root.
// Require a single explicit path so missing, null or array parameters cannot bypass it.
if id == "help.exportRepro" && params.get("path").and_then(Value::as_str).is_none() {
return Err(Error::BadArgs("`help.exportRepro`: give `path` inside an automation write root while file access is confined".into()));
}
let mut lookups = vec![];
// An output the agent did not name is derived from where the project sits: the Default
// Output Folder preference when that is set, beside the project file otherwise — and the
+25
View File
@@ -1865,3 +1865,28 @@ fn automation_roots_check_relinked_footage_again() {
assert!(!(px[0] > 200 && px[3] > 200), "the link pointed outside afterwards was read: {px:?}");
let _ = std::fs::remove_dir_all(&base);
}
/// Exporting a repro must not write to the settings store outside the automation roots.
#[test]
fn confined_repro_export_requires_an_explicit_writable_path() {
use crate::confine::Confinement;
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
use std::sync::Arc;
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(MemoryConfig::default());
let session = Session { config: Some(store.clone()), ..Default::default() };
let mut s = McpServer::new(Backend::headless(session));
s.backend().confine(Confinement::new(&[], &[dir.path().to_path_buf()]).unwrap());
for params in [json!({}), json!({"path": null}), json!({"path": []}), json!({"path": [dir.path().join("repro.jsonl")]})] {
let (content, error) = call(&mut s, "execute_command", json!({"command": "help.exportRepro", "params": params}));
assert!(error, "{content:?}");
assert!(store.list("Repros").is_empty(), "implicit settings-store writes must be refused");
}
let path = dir.path().join("repro.jsonl");
call_json(&mut s, "execute_command", json!({"command": "help.exportRepro", "params": {"path": path}}));
assert!(path.is_file());
let journal = call_json(&mut s, "execute_command", json!({"command": "journal.export"}));
assert!(journal["script"].is_string(), "in-memory exports need no write root");
assert!(store.list("Repros").is_empty());
}
+2
View File
@@ -31,6 +31,7 @@ pub(crate) mod frame_export;
mod frontend;
mod help;
mod import_project;
mod journal;
mod key_labels;
mod key_transform;
mod keys_more;
@@ -202,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(nav_more::specs());
v.extend(render_queue::specs());
v.extend(help::specs());
v.extend(journal::specs());
v.extend(query::specs());
v.extend(batch::specs());
v.extend(layer_menu::specs());
+53
View File
@@ -0,0 +1,53 @@
//! The command journal as a replay script (see [`crate::replay`]): `journal.export` for agents
//! and `help.exportRepro` for bug reports. Neither is journaled.
use serde_json::{Value, json};
use super::{CommandSpec, always, str_p};
use crate::{Result, Session, replay};
/// `journal.export {path?}`: the script itself, or written to `path`.
fn export(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = str_p(p, "path") {
let (path, steps) = replay::save_repro(s, Some(path))?;
return Ok(json!({"path": path, "steps": steps}));
}
let script = replay::export(s, None);
Ok(json!({"steps": script.lines().count().saturating_sub(1), "script": script}))
}
/// `help.exportRepro {path?}`: save the steps so far for a bug report (default: the settings
/// folder's `Repros`).
fn export_repro(s: &mut Session, p: &Value) -> Result<Value> {
let (path, steps) = replay::save_repro(s, str_p(p, "path"))?;
Ok(json!({"path": path, "steps": steps}))
}
const fn spec(id: &'static str, label: &'static str, params: &'static str, run: super::Run) -> CommandSpec {
CommandSpec { id, label, menu: &[], shortcut: None, alt_shortcuts: &[], params, enabled: always, run, journal: false }
}
pub fn specs() -> Vec<CommandSpec> {
#[allow(unused_mut)]
let mut v = vec![
spec(
"journal.export",
"Export Command Journal",
"{path? (write the script there)} → {script, steps} or {path, steps}: the commands run since the project was last opened, as a replay script (docs/agents.md, Repro scripts)",
export,
),
spec(
"help.exportRepro",
"Save Bug Repro...",
"{path? (default: Repros in the settings folder)} → {path, steps}: the steps since the project was opened, for a bug report (commands and parameters only)",
export_repro,
),
];
// A command with a bug, for the crash-repro tests.
#[cfg(test)]
v.push(spec("test.panic", "Panic (test)", "{why?}", |_, p| {
let why = str_p(p, "why").unwrap_or("test").to_string();
std::panic::panic_any(why)
}));
v
}
+1
View File
@@ -21,6 +21,7 @@ pub mod menus;
mod output_guard;
pub mod preview;
pub mod render_queue;
pub mod replay;
pub mod scriptui;
mod settings;
mod system;
+2 -1
View File
@@ -159,7 +159,8 @@ pub struct PreviewPreset {
pub pre_roll: f64,
pub post_roll: f64,
pub play_from: PlayFrom,
/// Frames per second; `None` = Auto (the comp's rate).
/// Composition frames played per second; `None` = Auto (the comp's rate).
/// An override changes preview speed, including the audio's speed and pitch.
pub frame_rate: Option<f64>,
/// Frames skipped between the frames played (0 = every frame).
pub skip: u32,
+748
View File
@@ -0,0 +1,748 @@
//! Replay scripts: a bug or crash as a list of commands an agent (or a test) can run again.
//!
//! The session's command journal ([`Session::journal`]) becomes a JSON-lines script
//! (`journal.export`, `help.exportRepro`, and automatically when the last-resort guard catches a
//! panic, see [`after_command`]); `effectcraft-cli replay script.jsonl` runs one headless and
//! `crates/host/tests/it/repros.rs` runs every `tests/repros/*.jsonl` as a regression test.
//!
//! Format (one JSON object per line; blank lines and lines starting with `//` or `#` are
//! comments):
//!
//! ```text
//! {"start": "new" | "demo" | {"project": "path.ecproj"}, "allow_errors"?: bool} optional, first
//! {"command": "comp.new", "params": {...}, "expect_error"?: true | "substring"}
//! {"expect": {"query": "prop.get", "params": {...}, "pointer"?: "/value", "equals"?: ...}}
//! {"expect_no_error": true}
//! ```
//!
//! A command must succeed unless it says `expect_error` (then it must fail, with that text in
//! the message when it is a string), or the start line says `allow_errors` (errors are reported
//! but tolerated; crash repros say so, since after the fix the crashing step may fail cleanly).
//! A panic always fails. `expect` runs a command (normally a query) and compares the value at
//! the JSON `pointer` of its result with `equals` (numbers within 1e-6); `expect_no_error`
//! checks that the previous command succeeded. Other keys (`version`, `app`, `note`, `crash`…)
//! are information for people. Relative `project` paths are relative to the script.
//!
//! Privacy: a repro holds command ids and their parameters only, never project or media file
//! contents. Parameters can include file paths, layer names and text typed into text layers.
use std::cell::Cell;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::{Map, Value, json};
use crate::{EngineError, Result, Session, guard};
/// The format version written into exported headers.
pub const VERSION: u64 = 1;
/// Largest script [`run`] accepts (bytes).
pub const MAX_SCRIPT_BYTES: usize = 64 << 20;
/// Most lines [`run`] reads.
pub const MAX_LINES: usize = 100_000;
/// Exported scripts stay below this size: the oldest steps are dropped (and counted).
const MAX_EXPORT_BYTES: usize = 32 << 20;
/// Folder of saved repros in the settings store (next to `Logs`).
pub const REPRO_DIR: &str = "Repros";
/// Automatic crash repros kept (the oldest are removed).
const KEEP_CRASH_REPROS: usize = 20;
/// Commands that replace the whole project: an export starts at the last one.
const PROJECT_STARTS: &[&str] = &["file.newProject", "file.open", "file.openDemoProject"];
/// Error and value texts in reports are cut to this many characters.
const MAX_TEXT: usize = 600;
// ---------- nesting and crash capture (Session::execute) ----------
thread_local! {
/// Commands running on this thread: (all, journaled ones).
static DEPTH: Cell<(u32, u32)> = const { Cell::new((0, 0)) };
/// A replay is running on this thread (its panics are reported, not saved as repros).
static REPLAYING: Cell<bool> = const { Cell::new(false) };
}
/// One running command (see [`Session::execute`]): commands it runs itself (a script's steps)
/// aren't journaled again, and only the outermost command saves a crash repro.
pub struct Nesting {
outer: (u32, u32),
}
impl Nesting {
/// Record nesting so commands dispatched by a script are not exported twice.
pub fn enter(journaled: bool) -> Nesting {
let outer = DEPTH.with(Cell::get);
DEPTH.with(|d| d.set((outer.0.saturating_add(1), outer.1.saturating_add(u32::from(journaled)))));
Nesting { outer }
}
/// Run by a journaled command (which replays it).
pub fn inside_journaled(&self) -> bool {
self.outer.1 > 0
}
/// Whether this command is responsible for capturing a nested panic.
pub fn outermost(&self) -> bool {
self.outer.0 == 0
}
}
impl Drop for Nesting {
fn drop(&mut self) {
DEPTH.with(|d| d.set(self.outer));
}
}
/// After the outermost command: when the guard caught a panic while it ran (`caught` is
/// [`guard::caught`] from before), save the journal plus this command as a crash repro in the
/// settings store and name it in the error.
pub fn after_command(s: &Session, id: &str, params: &Value, nesting: &Nesting, caught: u64, r: Result<Value>) -> Result<Value> {
if !nesting.outermost() || guard::caught() == caught || REPLAYING.with(Cell::get) {
return r;
}
let error = match &r {
Err(e) => e.to_string(),
Ok(_) => "a step it ran failed with an internal error".to_string(),
};
let saved = save_crash_repro(s, id, params, &error);
match (r, saved) {
(Err(EngineError::Other(m)), Some(path)) => Err(EngineError::Other(format!("{m} (the steps that led here were saved to {path})"))),
(r, _) => r,
}
}
/// Wall-clock seconds used only to label exported repros.
fn unix_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// `Repros/<prefix>-<seconds>-<command>.jsonl`.
fn repro_name(prefix: &str, id: &str) -> String {
let id: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' }).take(60).collect();
format!("{REPRO_DIR}/{prefix}-{:012}-{id}.jsonl", unix_secs())
}
/// Where a store entry is on disk (or its name in stores without a folder).
fn store_path(store: &dyn crate::config::ConfigStore, name: &str) -> String {
store.dir().map(|d| d.join(name).display().to_string()).unwrap_or_else(|| name.to_string())
}
/// Persist the failing command with its preceding journal and prune older crash repros.
fn save_crash_repro(s: &Session, id: &str, params: &Value, error: &str) -> Option<String> {
let store = s.config.clone()?;
let mut text = export(s, Some(json!({"command": id, "error": cut(error)})));
text.push_str(&command_line(id, params));
text.push('\n');
let name = repro_name("crash", id);
if let Err(e) = store.write(&name, &text) {
log::error!("cannot save the crash repro {name}: {e}");
return None;
}
// Keep the newest few (names sort by time).
let mut old: Vec<String> = store.list(REPRO_DIR).into_iter().filter(|n| n.starts_with("crash-")).collect();
old.sort();
let extra = old.len().saturating_sub(KEEP_CRASH_REPROS);
for n in old.into_iter().take(extra) {
let _ = store.remove(&format!("{REPRO_DIR}/{n}"));
}
let path = store_path(store.as_ref(), &name);
log::error!("crash repro saved to {path}");
Some(path)
}
/// Save the journal as a repro: at `path` (through the session's file services), else in the
/// settings store's `Repros` folder. Returns where it went and how many steps it holds.
pub fn save_repro(s: &Session, path: Option<&str>) -> Result<(String, usize)> {
let text = export(s, None);
let steps = text.lines().count().saturating_sub(1);
if let Some(path) = path {
s.services.write_file(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
return Ok((path.to_string(), steps));
}
let store = s.config.clone().ok_or_else(|| EngineError::Other("there is no settings folder to save the repro in; pass `path`".into()))?;
let name = repro_name("repro", "session");
store.write(&name, &text).map_err(|e| EngineError::Other(format!("cannot save {name}: {e}")))?;
Ok((store_path(store.as_ref(), &name), steps))
}
// ---------- export ----------
/// Serialize one command without including an empty parameter object.
fn command_line(id: &str, params: &Value) -> String {
let mut o = Map::new();
o.insert("command".into(), json!(id));
if !(params.is_null() || params.as_object().is_some_and(Map::is_empty)) {
o.insert("params".into(), params.clone());
}
Value::Object(o).to_string()
}
/// Saving writes the user's files and doesn't change the project: left out of repros.
fn skipped_in_export(id: &str) -> bool {
id.starts_with("file.save") || id.starts_with("file.increment")
}
/// The journal as a replay script: a start line, then the steps since the project was last
/// replaced (`file.newProject` / `file.open` / `file.openDemoProject`; successful Revert records a resolved `file.open`). `crash` (the failing
/// command and its error) marks a crash repro, which tolerates errors on replay.
pub fn export(s: &Session, crash: Option<Value>) -> String {
let from = s.journal.iter().rposition(|(id, _)| PROJECT_STARTS.contains(&id.as_str())).unwrap_or(0);
let mut lines: Vec<String> =
s.journal.get(from..).unwrap_or_default().iter().filter(|(id, _)| !skipped_in_export(id)).map(|(id, p)| command_line(id, p)).collect();
let mut size: usize = lines.iter().map(|l| l.len().saturating_add(1)).sum();
let mut dropped = 0usize;
while size > MAX_EXPORT_BYTES && !lines.is_empty() {
size = size.saturating_sub(lines.remove(0).len().saturating_add(1));
dropped += 1;
}
let mut head = Map::new();
head.insert("start".into(), json!("new"));
head.insert("version".into(), json!(VERSION));
head.insert("app".into(), json!(format!("EffectCraft {}", env!("CARGO_PKG_VERSION"))));
head.insert("os".into(), json!(std::env::consts::OS));
head.insert("exported".into(), json!(unix_secs()));
if let Some(c) = crash {
head.insert("allow_errors".into(), json!(true));
head.insert("crash".into(), c);
}
if dropped > 0 {
head.insert("dropped".into(), json!(dropped));
}
head.insert("note".into(), json!("commands and parameters only (no file contents); parameters can include file paths and text"));
let mut out = Value::Object(head).to_string();
out.push('\n');
for l in lines {
out.push_str(&l);
out.push('\n');
}
out
}
// ---------- parsing ----------
#[derive(Clone, Debug, PartialEq)]
enum Start {
New,
Demo,
Project(String),
}
#[derive(Clone, Debug)]
enum Step {
Start(Start),
Command {
id: String,
params: Value,
expect_error: Option<Option<String>>,
},
Expect {
query: String,
params: Value,
pointer: Option<String>,
equals: Option<Value>,
},
ExpectNoError,
/// A line that isn't a valid step (reported as a failure).
Invalid(String),
}
fn params_of(v: Option<&Value>) -> std::result::Result<Value, String> {
match v {
None | Some(Value::Null) => Ok(json!({})),
Some(p @ Value::Object(_)) => Ok(p.clone()),
Some(_) => Err("`params` must be an object".into()),
}
}
/// Decode the supported initial project states.
fn parse_start(v: &Value) -> std::result::Result<Start, String> {
match v {
Value::String(s) if s == "new" => Ok(Start::New),
Value::String(s) if s == "demo" => Ok(Start::Demo),
Value::Object(o) => match o.get("project").and_then(Value::as_str) {
Some(p) if !p.trim().is_empty() => Ok(Start::Project(p.to_string())),
_ => Err("`start` object needs a `project` path".into()),
},
_ => Err("`start` must be \"new\", \"demo\" or {\"project\": path}".into()),
}
}
/// One line → a step (`None`: a blank or comment line).
fn parse_line(text: &str, first: bool, allow_errors: &mut bool) -> Option<Step> {
let t = text.trim();
if t.is_empty() || t.starts_with("//") || t.starts_with('#') {
return None;
}
let v: Value = match serde_json::from_str(t) {
Ok(v) => v,
Err(e) => return Some(Step::Invalid(format!("not JSON: {e}"))),
};
let Some(o) = v.as_object() else { return Some(Step::Invalid("a line must be a JSON object".into())) };
let step = if let Some(st) = o.get("start") {
if !first {
return Some(Step::Invalid("the `start` line must come first".into()));
}
*allow_errors = o.get("allow_errors").and_then(Value::as_bool).unwrap_or(false);
parse_start(st).map(Step::Start)
} else if let Some(c) = o.get("command") {
parse_command(c, o)
} else if let Some(e) = o.get("expect") {
parse_expect(e)
} else if o.get("expect_no_error").and_then(Value::as_bool) == Some(true) {
Ok(Step::ExpectNoError)
} else if o.len() == 1 && o.contains_key("comment") {
return None;
} else {
Err("expected a `start`, `command`, `expect` or `expect_no_error` line".to_string())
};
Some(step.unwrap_or_else(Step::Invalid))
}
/// Parse one command and its optional expected error without executing it.
fn parse_command(c: &Value, o: &Map<String, Value>) -> std::result::Result<Step, String> {
let id = c.as_str().filter(|s| !s.is_empty()).ok_or("`command` must be a command id")?;
let expect_error = match o.get("expect_error") {
None | Some(Value::Null | Value::Bool(false)) => None,
Some(Value::Bool(true)) => Some(None),
Some(Value::String(s)) => Some(Some(s.clone())),
Some(_) => return Err("`expect_error` must be true or a text the error contains".into()),
};
Ok(Step::Command { id: id.to_string(), params: params_of(o.get("params"))?, expect_error })
}
/// Parse a query assertion, retaining the distinction between no expected value and JSON null.
fn parse_expect(e: &Value) -> std::result::Result<Step, String> {
let query = e.get("query").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("`expect` needs a `query` command id")?;
let pointer = e.get("pointer").map(|p| p.as_str().map(str::to_string).ok_or("`pointer` must be a JSON pointer such as /value")).transpose()?;
Ok(Step::Expect { query: query.to_string(), params: params_of(e.get("params"))?, pointer, equals: e.get("equals").cloned() })
}
// ---------- running ----------
/// How to run a script.
#[derive(Clone, Debug, Default)]
pub struct Options {
/// Keep running after a failed step (default: stop at the first failure).
pub keep_going: bool,
/// Folder relative `start.project` paths are resolved against (the script's).
pub base_dir: Option<PathBuf>,
}
/// What happened to one line.
#[derive(Clone, Debug, Serialize)]
pub struct StepReport {
/// 1-based line number (0: the implicit start).
pub line: usize,
/// `start`, `command`, `expect`, `expect_no_error` or `invalid`.
pub kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub panicked: bool,
}
/// The outcome of [`run`].
#[derive(Clone, Debug, Default, Serialize)]
pub struct Report {
pub ok: bool,
/// A step panicked (caught by the last-resort guard): always a failure.
pub panicked: bool,
pub steps: Vec<StepReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_failure: Option<StepReport>,
/// Steps not run after the first failure.
pub skipped: usize,
}
/// Cut a message to [`MAX_TEXT`] characters.
fn cut(s: &str) -> String {
match s.char_indices().nth(MAX_TEXT) {
Some((i, _)) => format!("{}…", s.get(..i).unwrap_or(s)),
None => s.to_string(),
}
}
/// Run one command the way the app does, noting whether it panicked.
fn exec(s: &mut Session, id: &str, params: Value) -> (Result<Value>, bool) {
let before = guard::caught();
let r = guard::guarded(&format!("replay of `{id}`"), || s.execute(id, params));
s.events.clear();
(r, guard::caught() != before)
}
/// Numbers within 1e-6 (relative above 1), everything else exactly.
fn approx_eq(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0),
_ => x == y,
},
(Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| approx_eq(a, b)),
(Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, a)| y.get(k).is_some_and(|b| approx_eq(a, b))),
_ => a == b,
}
}
/// Marks this thread as replaying while alive.
struct Replaying(bool);
impl Replaying {
/// Suppress automatic crash exports for the duration of a replay.
fn enter() -> Replaying {
Replaying(REPLAYING.with(|r| r.replace(true)))
}
}
impl Drop for Replaying {
fn drop(&mut self) {
REPLAYING.with(|r| r.set(self.0));
}
}
/// Create or open the initial project and retain whether its command panicked.
fn start_step(s: &mut Session, start: &Start, base: Option<&Path>) -> (String, Result<Value>, bool) {
match start {
Start::New => {
let (r, p) = exec(s, "file.newProject", json!({}));
("file.newProject".into(), r, p)
}
Start::Demo => {
let (r, p) = exec(s, "file.openDemoProject", json!({}));
("file.openDemoProject".into(), r, p)
}
Start::Project(path) => {
let full = match base {
Some(b) if Path::new(path).is_relative() => b.join(path).display().to_string(),
_ => path.clone(),
};
let (r, p) = exec(s, "file.open", json!({"path": full}));
("file.open".into(), r, p)
}
}
}
/// Compare a query result and produce a bounded explanation when it fails.
fn expectation_error(r: Result<Value>, panicked: bool, pointer: Option<&str>, equals: Option<&Value>) -> Option<String> {
let value = match r {
Ok(v) => v,
Err(e) => return Some(cut(&format!("{}{e}", if panicked { "panicked: " } else { "" }))),
};
let got = match pointer {
Some(p) => value.pointer(p),
None => Some(&value),
};
match (got, equals) {
(None, _) => Some(cut(&format!("no value at {} in {value}", pointer.unwrap_or("")))),
(Some(got), Some(want)) if !approx_eq(got, want) => Some(cut(&format!("expected {want}, got {got}"))),
_ => None,
}
}
/// Execute one parsed line and remember whether the last command failed for `expect_no_error`.
fn run_step(s: &mut Session, line: usize, step: Step, opts: &Options, allow_errors: bool, last_command_failed: &mut Option<bool>) -> StepReport {
let mut sr = StepReport { line, kind: "command", command: None, ok: true, error: None, panicked: false };
match step {
Step::Invalid(m) => {
sr.kind = "invalid";
sr.ok = false;
sr.error = Some(m);
}
Step::Start(start) => {
sr.kind = "start";
let (id, r, panicked) = start_step(s, &start, opts.base_dir.as_deref());
sr.command = Some(id);
sr.panicked = panicked;
sr.ok = !panicked;
if let Err(e) = r {
sr.ok = false;
sr.error = Some(cut(&e.to_string()));
}
}
Step::Command { id, params, expect_error } => {
let (r, panicked) = exec(s, &id, params);
sr.command = Some(id);
sr.panicked = panicked;
*last_command_failed = Some(r.is_err());
let err = r.err().map(|e| e.to_string());
let (ok, error) = match (err, expect_error) {
(e, _) if panicked => (false, Some(format!("panicked: {}", e.unwrap_or_else(|| "a step failed with an internal error".into())))),
(None, None) => (true, None),
(None, Some(_)) => (false, Some("expected an error, but the command succeeded".into())),
(Some(e), Some(Some(want))) if !e.contains(want.as_str()) => (false, Some(format!("expected an error containing {want:?}, got: {e}"))),
(Some(_), Some(_)) => (true, None),
(Some(e), None) => (allow_errors, Some(e)),
};
sr.ok = ok;
sr.error = error.as_deref().map(cut);
}
Step::Expect { query, params, pointer, equals } => {
sr.kind = "expect";
let (r, panicked) = exec(s, &query, params);
sr.command = Some(query);
sr.panicked = panicked;
sr.error = expectation_error(r, panicked, pointer.as_deref(), equals.as_ref());
sr.ok = sr.error.is_none() && !panicked;
}
Step::ExpectNoError => {
sr.kind = "expect_no_error";
match last_command_failed {
Some(false) => {}
Some(true) => {
sr.ok = false;
sr.error = Some("the previous command failed".into());
}
None => {
sr.ok = false;
sr.error = Some("no command before this line".into());
}
}
}
}
sr
}
/// Run a replay script against `s`. Never panics: malformed lines are failed steps.
pub fn run(s: &mut Session, script: &str, opts: &Options) -> Report {
let _replaying = Replaying::enter();
let mut report = Report { ok: true, ..Default::default() };
let fail = |report: &mut Report, sr: StepReport| {
report.ok = false;
report.panicked |= sr.panicked;
if report.first_failure.is_none() {
report.first_failure = Some(sr.clone());
}
report.steps.push(sr);
};
if script.len() > MAX_SCRIPT_BYTES {
let msg = format!("the script is larger than {} MB", MAX_SCRIPT_BYTES >> 20);
fail(&mut report, StepReport { line: 0, kind: "invalid", command: None, ok: false, error: Some(msg), panicked: false });
return report;
}
// Hand-edited UTF-8 scripts may start with a BOM, especially on Windows.
let script = script.strip_prefix('\u{feff}').unwrap_or(script);
let mut allow_errors = false;
let mut steps: Vec<(usize, Step)> = vec![];
for (i, line) in script.lines().enumerate().take(MAX_LINES) {
if let Some(step) = parse_line(line, steps.is_empty(), &mut allow_errors) {
steps.push((i + 1, step));
}
}
if script.lines().nth(MAX_LINES).is_some() {
steps.push((MAX_LINES + 1, Step::Invalid(format!("the script has more than {MAX_LINES} lines; the rest was not read"))));
}
if !matches!(steps.first(), Some((_, Step::Start(_)) | (_, Step::Invalid(_)))) {
steps.insert(0, (0, Step::Start(Start::New)));
}
let mut last_command_failed: Option<bool> = None;
let mut stopped = false;
for (line, step) in steps {
if stopped {
report.skipped += 1;
continue;
}
let sr = run_step(s, line, step, opts, allow_errors, &mut last_command_failed);
if sr.ok {
report.steps.push(sr);
} else {
fail(&mut report, sr);
stopped = !opts.keep_going;
}
}
report
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use serde_json::json;
use super::{Options, run};
use crate::Session;
use crate::config::{ConfigStore, MemoryConfig};
fn replay(script: &str) -> super::Report {
run(&mut Session::new(), script, &Options::default())
}
#[test]
fn steps_expectations_and_failures_are_reported() {
let r = replay(
r#"{"start": "new"}
// a comment
{"command": "comp.new", "params": {"name": "A", "width": 320, "height": 180, "frameRate": 30, "duration": 2}}
{"expect_no_error": true}
{"command": "comp.new", "params": {"frameRate": 0}, "expect_error": "frame"}
{"expect": {"query": "comp.info", "pointer": "/width", "equals": 320}}
"#,
);
assert!(r.ok, "{r:?}");
assert_eq!(r.steps.len(), 5);
let r = replay(
"{\"command\": \"comp.new\"}\n{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/width\", \"equals\": 1}}\n{\"command\": \"comp.new\"}",
);
assert!(!r.ok && !r.panicked && r.skipped == 1, "{r:?}");
assert_eq!(r.first_failure.as_ref().map(|f| f.line), Some(2));
// An unexpected success, an unknown command.
assert!(!replay(r#"{"command": "comp.new", "expect_error": true}"#).ok);
assert!(!replay(r#"{"command": "no.such.command"}"#).ok);
// Tolerated errors.
let r = replay("{\"start\": \"new\", \"allow_errors\": true}\n{\"command\": \"no.such.command\"}\n{\"expect_no_error\": true}");
assert!(!r.ok && r.first_failure.as_ref().map(|f| f.kind) == Some("expect_no_error"), "{r:?}");
}
/// A UTF-8 BOM belongs to the text file, not the first JSON step.
#[test]
fn utf8_bom_preserves_explicit_and_implicit_start_lines() {
for body in ["{\"start\":\"new\"}\n{\"command\":\"comp.new\"}", "{\"command\":\"comp.new\"}"] {
let script = format!("\u{feff}{body}");
let report = replay(&script);
assert!(report.ok, "{report:?}");
assert_eq!(report.steps.last().unwrap().line, body.lines().count());
}
}
#[test]
fn malformed_lines_are_failures_not_crashes() {
for bad in [
"not json",
"[1, 2]",
"{\"command\": 5}",
"{\"command\": \"comp.new\", \"params\": [1]}",
"{\"command\": \"comp.new\", \"expect_error\": 3}",
"{\"expect\": {}}",
"{\"expect\": {\"query\": \"comp.info\", \"pointer\": 4}}",
"{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/nope\"}}",
"{\"start\": 7}",
"{\"start\": {\"project\": \"\"}}",
"{\"command\": \"comp.new\"}\n{\"start\": \"new\"}",
"{\"what\": 1}",
"{\"expect_no_error\": true}",
"{\"start\": {\"project\": \"/no/such/file.ecproj\"}}",
] {
let r = replay(bad);
assert!(!r.ok && !r.panicked, "{bad}: {r:?}");
}
let r = replay(&format!("{{\"command\": \"{}\"}}", "\u{1F600}".repeat(1000)));
assert!(!r.ok && r.first_failure.and_then(|f| f.error).is_some_and(|e| e.ends_with('…')));
assert!(replay("").ok, "an empty script is a new project");
}
#[test]
fn the_journal_exports_and_replays() {
let mut s = Session::new();
s.execute("comp.new", json!({"name": "Old"})).unwrap();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000", "name": "Red"})).unwrap();
s.execute("layer.newSolid", json!({"color": "#00ff00", "name": "Green"})).unwrap();
// A query isn't journaled; neither is saving.
s.execute("comp.info", json!({})).unwrap();
let text = super::export(&s, None);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 4, "{text}");
assert!(lines[0].contains("\"start\":\"new\"") && lines[1].contains("file.openDemoProject"), "starts at the last project open: {text}");
let mut again = Session::new();
let r = run(&mut again, &text, &Options::default());
assert!(r.ok, "{r:?}");
let layers = |s: &Session| s.active_comp().map(|c| c.layers.len());
assert_eq!(layers(&again), layers(&s));
// journal.export returns it; with `path` it writes the file.
let v = s.execute("journal.export", json!({})).unwrap();
assert_eq!(v["steps"], 3);
assert_eq!(v["script"].as_str().unwrap().lines().skip(1).collect::<Vec<_>>(), lines[1..]);
let dir = std::env::temp_dir().join(format!("ec-replay-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("j.jsonl");
s.execute("journal.export", json!({"path": path.to_str().unwrap()})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("layer.newSolid"));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(s.journal.iter().filter(|(id, _)| id.starts_with("journal.")).count(), 0);
}
#[test]
fn help_export_repro_saves_into_the_settings_store() {
let mut s = Session::new();
assert!(s.execute("help.exportRepro", json!({})).is_err(), "no settings folder and no path");
let store = Arc::new(MemoryConfig::default());
s.config = Some(store.clone());
s.execute("comp.new", json!({})).unwrap();
let v = s.execute("help.exportRepro", json!({})).unwrap();
assert_eq!(v["steps"], 1);
let saved = store.list(super::REPRO_DIR);
assert_eq!(saved.len(), 1, "{saved:?}");
assert!(store.read(v["path"].as_str().unwrap()).unwrap().contains("comp.new"));
}
#[test]
fn a_panicking_command_saves_a_crash_repro_and_replays_as_a_failure() {
let mut s = Session::new();
let store = Arc::new(MemoryConfig::default());
s.config = Some(store.clone());
s.execute("comp.new", json!({"name": "C"})).unwrap();
// `test.panic` stands in for a command with a bug (see `commands::journal`).
let e = s.execute("test.panic", json!({"why": "boom"})).unwrap_err().to_string();
assert!(e.contains("boom") && e.contains("Repros/crash-"), "{e}");
let names = store.list(super::REPRO_DIR);
assert_eq!(names.len(), 1);
let text = store.read(&format!("{}/{}", super::REPRO_DIR, names[0])).unwrap();
assert!(text.lines().next().unwrap().contains("\"crash\""), "{text}");
assert!(text.lines().last().unwrap().contains("test.panic"), "{text}");
// The replay fails on the panic (and doesn't save another repro).
let r = run(&mut Session::new(), &text, &Options::default());
assert!(!r.ok && r.panicked, "{r:?}");
assert_eq!(r.first_failure.as_ref().and_then(|f| f.command.as_deref()), Some("test.panic"));
// A failing replay inside a session with a store saves nothing more.
let mut t = Session::new();
t.config = Some(store.clone());
assert!(!run(&mut t, &text, &Options::default()).ok);
assert_eq!(store.list(super::REPRO_DIR).len(), 1);
// Only the newest crash repros are kept.
for i in 0..30 {
store.write(&format!("{}/crash-{i:012}-x.jsonl", super::REPRO_DIR), "{}").unwrap();
}
let _ = s.execute("test.panic", json!({}));
assert_eq!(store.list(super::REPRO_DIR).len(), super::KEEP_CRASH_REPROS);
}
#[test]
fn commands_run_by_a_journaled_command_are_not_journaled_twice() {
let mut s = Session::new();
s.execute("comp.new", json!({})).unwrap();
// engine.batch isn't journaled: its steps are.
s.execute("engine.batch", json!({"steps": [{"command": "layer.newNull"}, {"command": "layer.newNull"}]})).unwrap();
assert_eq!(s.journal.iter().filter(|(id, _)| id == "layer.newNull").count(), 2);
// A journaled command's own steps replay with it.
s.execute("file.runScript", json!({"steps": [{"command": "layer.newNull"}]})).unwrap();
let ids: Vec<&str> = s.journal.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["comp.new", "layer.newNull", "layer.newNull", "file.runScript"]);
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod revert_tests {
use super::{Options, export, run};
use crate::Session;
use serde_json::json;
/// Saving is omitted, so a successful Revert must keep the file it read as an open boundary.
#[test]
fn save_as_then_revert_exports_a_replayable_open() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("saved.ecproj");
let mut s = Session::new();
s.execute("comp.new", json!({"name": "Saved"})).unwrap();
s.execute("file.saveAs", json!({"path": path})).unwrap();
s.execute("layer.newNull", json!({})).unwrap();
s.execute("file.revert", json!({})).unwrap();
s.execute("layer.newNull", json!({"name": "After Revert"})).unwrap();
let script = export(&s, None);
assert!(!script.contains("file.save") && !script.contains("file.revert"), "{script}");
assert!(script.lines().nth(1).unwrap().contains("file.open"), "{script}");
let saved = std::fs::read(&path).unwrap();
let mut again = Session::new();
let report = run(&mut again, &script, &Options::default());
assert!(report.ok, "{report:?}");
assert_eq!(again.path, s.path);
assert_eq!(again.active_comp().unwrap().layers.len(), s.active_comp().unwrap().layers.len());
assert_eq!(saved, std::fs::read(path).unwrap(), "replay must not rewrite the saved project");
}
}
+13 -3
View File
@@ -22,10 +22,20 @@ impl Session {
}
}
// Last-resort guard: a panicking command reports an error instead of crashing the app.
let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, &params))?;
let nesting = crate::replay::Nesting::enter(spec.journal);
let caught = guard::caught();
let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, &params));
let r = crate::replay::after_command(self, id, &params, &nesting, caught, r)?;
self.learn_observe(id, &params);
if spec.journal {
self.journal.push((id.to_string(), params));
if spec.journal && !nesting.inside_journaled() {
// Revert and Open Recent resolve a file the parameters may not name. Store
// the successful read as an explicit open boundary while its resolved path is known.
let entry = if matches!(id, "file.revert" | "file.openRecent") {
self.path.as_ref().map(|path| ("file.open".to_string(), serde_json::json!({"path": path})))
} else {
None
};
self.journal.push(entry.unwrap_or_else(|| (id.to_string(), params)));
if self.journal.len() > 10_000 {
self.journal.drain(..1000);
}
+14
View File
@@ -4,14 +4,28 @@
//! panics leaves the project as it was. This is a safety net, not a substitute for returning
//! errors (AGENTS.md, "Never crash").
use std::cell::Cell;
use crate::{EngineError, Result};
thread_local! {
/// Panics [`guarded`] caught on this thread (see [`caught`]).
static CAUGHT: Cell<u64> = const { Cell::new(0) };
}
/// How many panics [`guarded`] has caught on this thread so far: compare two readings to learn
/// whether a call panicked (crash repros, `effectcraft-cli replay`).
pub fn caught() -> u64 {
CAUGHT.with(Cell::get)
}
/// Run `f`; a panic inside it becomes an error naming `what`. The panic itself is logged by
/// the panic hook ([`crate::logging::install_panic_hook`]).
pub fn guarded<T>(what: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(r) => r,
Err(payload) => {
CAUGHT.with(|c| c.set(c.get().saturating_add(1)));
let msg = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
+102
View File
@@ -0,0 +1,102 @@
//! Per-compositor, fixed-category observations from error scopes. No driver strings escape.
#[cfg(any(test, not(target_arch = "wasm32")))]
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, OnceLock};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScopedError {
OutOfMemory,
Internal,
Validation,
}
pub type ScopedErrorObserver = Arc<dyn Fn(ScopedError) + Send + Sync>;
#[derive(Default)]
pub(crate) struct ScopedObservations {
observer: OnceLock<ScopedErrorObserver>,
#[cfg(any(test, not(target_arch = "wasm32")))]
seen: AtomicU8,
}
impl ScopedObservations {
/// Register before rendering. Shared by all clones of the compositor, never global.
pub(crate) fn install(&self, observer: ScopedErrorObserver) -> bool {
self.observer.set(observer).is_ok()
}
#[cfg(any(test, not(target_arch = "wasm32")))]
pub(crate) fn report_scopes(&self, oom: bool, internal: bool, validation: bool) {
for (present, category) in [(oom, ScopedError::OutOfMemory), (internal, ScopedError::Internal), (validation, ScopedError::Validation)] {
if present {
self.report(category);
}
}
}
#[cfg(any(test, not(target_arch = "wasm32")))]
fn report(&self, error: ScopedError) {
let Some(observer) = self.observer.get() else { return };
let bit = match error {
ScopedError::OutOfMemory => 1,
ScopedError::Internal => 2,
ScopedError::Validation => 4,
};
if self.seen.fetch_or(bit, Ordering::Relaxed) & bit != 0 {
return;
}
// Diagnostics must not turn a recoverable GPU frame error into an application panic.
#[cfg(not(target_arch = "wasm32"))]
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| observer(error))).is_err() {
log::warn!("GPU scoped-error observer panicked");
}
#[cfg(target_arch = "wasm32")]
observer(error);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn categories_are_independent_bounded_and_not_global() {
let observations = ScopedObservations::default();
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let copy = seen.clone();
assert!(observations.install(Arc::new(move |error| copy.lock().unwrap().push(error))));
assert!(!observations.install(Arc::new(|_| panic!("must not replace observer"))));
for _ in 0..100 {
observations.report(ScopedError::Internal);
observations.report(ScopedError::Validation);
observations.report(ScopedError::OutOfMemory);
}
ScopedObservations::default().report(ScopedError::OutOfMemory);
assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation, ScopedError::OutOfMemory]);
}
#[test]
fn internal_and_validation_scopes_do_not_claim_out_of_memory() {
let observations = ScopedObservations::default();
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let copy = seen.clone();
observations.install(Arc::new(move |error| copy.lock().unwrap().push(error)));
observations.report_scopes(false, false, false);
assert!(seen.lock().unwrap().is_empty());
observations.report_scopes(false, true, true);
assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation]);
observations.report_scopes(true, true, true);
assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation, ScopedError::OutOfMemory]);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn observer_panic_does_not_escape_frame_recovery() {
let observations = ScopedObservations::default();
observations.install(Arc::new(|_| panic!("synthetic observer panic")));
observations.report(ScopedError::OutOfMemory);
observations.report(ScopedError::Internal);
assert_eq!(observations.seen.load(Ordering::Relaxed), 3);
}
}
+14 -1
View File
@@ -58,6 +58,7 @@ mod bokeh;
mod classic3d;
mod context;
pub mod deferred;
mod diagnostics;
mod effects;
mod fx_color;
mod fx_depth;
@@ -91,6 +92,7 @@ mod yuv;
use std::sync::Arc;
pub use context::{GpuContext, GpuImage, TransferStats};
pub use diagnostics::{ScopedError, ScopedErrorObserver};
use effectcraft_effects::Buf;
use effectcraft_project::ItemId;
use effectcraft_raster::Image;
@@ -126,6 +128,7 @@ impl std::error::Error for FrameError {}
/// The GPU compositor (cheap to clone; one device shared by all clones).
#[derive(Clone)]
pub struct Gpu {
scoped_observations: Arc<diagnostics::ScopedObservations>,
ctx: Arc<GpuContext>,
/// Backend::Auto's per-comp CPU / GPU timings.
auto: Arc<effectcraft_render::AutoPick>,
@@ -162,7 +165,7 @@ impl Gpu {
}
pub fn from_context(ctx: GpuContext) -> Gpu {
Gpu { ctx: Arc::new(ctx), auto: Default::default(), declined: Default::default() }
Gpu { ctx: Arc::new(ctx), auto: Default::default(), declined: Default::default(), scoped_observations: Default::default() }
}
/// A device of its own without blocking, with deferred readbacks (a browser worker, see
@@ -173,6 +176,13 @@ impl Gpu {
Ok(Gpu::from_context(ctx))
}
/// Install a host's fixed-category observer before rendering. It is called at most once
/// per category across all clones, outside device/renderer locks. Returns false if already
/// registered. The observer must remain brief and must not perform GPU work.
pub fn set_scoped_error_observer(&self, observer: ScopedErrorObserver) -> bool {
self.scoped_observations.install(observer)
}
/// [`Gpu::request_deferred`] blocking (tests of the browser worker's path, natively).
#[cfg(not(target_arch = "wasm32"))]
pub fn headless_deferred() -> Option<Gpu> {
@@ -316,6 +326,9 @@ impl Gpu {
let validation = pollster::block_on(validation.pop());
let internal = pollster::block_on(internal.pop());
let oom = pollster::block_on(oom.pop());
// Preserve the typed scope observations before FrameError::Memory combines OOM
// and internal errors. An internal error alone is not evidence of actual OOM.
self.scoped_observations.report_scopes(oom.is_some(), internal.is_some(), validation.is_some());
let memory = oom.is_some() || internal.is_some();
// The allocation failure first: a validation error is often its consequence.
let errors: Vec<String> = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect();
+2
View File
@@ -15,3 +15,5 @@ mod timeline_interop;
mod vector_import;
mod wav_markers;
mod webm_metadata;
mod repros;
+31
View File
@@ -0,0 +1,31 @@
//! Regression tests as data: every `tests/repros/*.jsonl` (repository root) is a replay script
//! (`effectcraft_engine::replay`, docs/agents.md "Repro scripts") run against a fully wired
//! headless session, like `effectcraft-cli replay`. A crash fix's regression test is a `.jsonl`
//! dropped there: it must replay without a panic and meet its expectations.
use std::path::{Path, PathBuf};
use effectcraft_engine::replay;
fn repros_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/repros")
}
#[test]
fn every_repro_replays_cleanly() {
let mut files: Vec<PathBuf> =
std::fs::read_dir(repros_dir()).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "jsonl")).collect();
files.sort();
assert!(!files.is_empty(), "no repros in {}", repros_dir().display());
let mut failures = vec![];
for f in &files {
let script = std::fs::read_to_string(f).unwrap();
let mut s = effectcraft_host::session();
let opts = replay::Options { keep_going: false, base_dir: f.parent().map(Path::to_path_buf) };
let r = replay::run(&mut s, &script, &opts);
if !r.ok {
failures.push(format!("{}: {}", f.display(), serde_json::to_string(&r.first_failure).unwrap()));
}
}
assert!(failures.is_empty(), "{} of {} repros failed:\n{}", failures.len(), files.len(), failures.join("\n"));
}
+6
View File
@@ -44,6 +44,9 @@ impl EffectcraftApp {
pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) {
if let Some(gpu) = &self.gpu {
bridge.forward_to(gpu.context().device_failure_notifier());
if let Some(observer) = bridge.scoped_error_observer() {
gpu.set_scoped_error_observer(observer);
}
}
self.gpu_failures = Some(bridge);
}
@@ -257,6 +260,9 @@ impl EffectcraftApp {
Ok(Ok(g)) => {
if let Some(bridge) = &self.gpu_failures {
bridge.forward_to(g.context().device_failure_notifier());
if let Some(observer) = bridge.scoped_error_observer() {
g.set_scoped_error_observer(observer);
}
if bridge.failure().is_some() {
return;
}
+15 -6
View File
@@ -40,6 +40,7 @@ impl EffectcraftApp {
restore_max,
audio_frame: (!plan.video).then_some(plan.first),
audio_held: false,
silent_project: None,
starting: false,
starting_since: now,
first_cached: None,
@@ -56,12 +57,10 @@ impl EffectcraftApp {
}
}
/// The preview includes audio at the comp's frame rate and the comp has something audible.
fn audio_plays(&self) -> bool {
/// The preview includes audio and the comp has something audible.
pub(super) fn audio_plays(&self) -> bool {
let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false };
let Some(c) = self.session.project.comp(cid) else { return false };
// A preview at another frame rate plays slower or faster than real time: silent.
pl.audio && (pl.fps - c.frame_rate.as_f64()).abs() <= 0.01 && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid)
pl.audio && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid)
}
/// Start audio preview from comp time `t` when [`Self::audio_plays`] and an output device
@@ -75,7 +74,17 @@ impl EffectcraftApp {
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
let mix = self.session.prefs.audio.preview_sample_rate;
let fr = c.frame_rate;
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, fr.tick_of(pl.start), fr.tick_of(pl.end + 1), pl.looping, mix) {
match audio::AudioPlayback::start_at_speed(
dev,
self.render_source(),
cid,
t,
fr.tick_of(pl.start),
fr.tick_of(pl.end + 1),
pl.looping,
mix,
pl.fps / fr.as_f64(),
) {
Ok(a) => self.audio = Some(a),
Err(e) => log::warn!("audio preview: {e}"),
}
+31 -3
View File
@@ -1,4 +1,4 @@
use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, frames};
use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, frames};
/// Seconds without a project change before paused prefetch resumes (see
/// [`EffectcraftApp::editing`]).
@@ -82,6 +82,7 @@ impl EffectcraftApp {
let step = plan.map_or(1, |p| p.step);
let fit_ahead = self.frames_that_fit(&c, scale) - 1;
self.prefetch_playback(&PlaybackPass { cur: lead, ..pass }, fit_ahead);
self.wake_silent_preview();
self.pump_meters(ctx, now);
if !self.playback.playing {
self.cache_when_idle(ctx, &c, &series, now, input_active, (wa, wb));
@@ -201,6 +202,10 @@ impl EffectcraftApp {
/// one second. With no source but an active meter, drive the meter to silence and request a repaint.
fn pump_meters(&mut self, ctx: &egui::Context, now: f64) {
if let Some(a) = &mut self.audio {
// An edit while playing (mute, unmute, solo, levels): the sound follows it (#2104).
if a.project().is_some_and(|p| !std::sync::Arc::ptr_eq(&p, &self.session.project)) {
a.follow_project(self.session.project.clone());
}
a.pump();
self.meter.update(a.feed.take_peaks(), now);
} else if let Some(s) = &mut self.scrub {
@@ -259,6 +264,29 @@ impl EffectcraftApp {
false
}
/// A silent preview whose project was edited so that it has sound now (a layer unmuted while
/// playing, #2104): video previews wait for cached frames; audio-only previews start from
/// their moving audio frame, not the stationary timeline. Checked once per project edit.
fn wake_silent_preview(&mut self) {
let pb = &self.playback;
if self.audio.is_some() || !pb.playing || pb.caching || pb.starting || pb.audio_held {
return;
}
if pb.silent_project.as_ref().is_some_and(|p| std::sync::Arc::ptr_eq(p, &self.session.project)) {
return;
}
self.playback.silent_project = Some(self.session.project.clone());
if !self.audio_plays() {
return;
}
if self.playback.plan.is_some_and(|p| p.video) {
self.playback.audio_held = true;
} else if let Some(c) = self.session.active_comp() {
let frame = self.playback.audio_frame.unwrap_or(self.playback.start_frame);
self.start_audio(c.frame_rate.tick_of(frame));
}
}
/// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the
/// prefetch's `ahead` when the silent preview keeps real time, else the rest of the preview.
fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) {
@@ -293,7 +321,7 @@ impl EffectcraftApp {
return true;
}
let snap = |f: i64| wa + (f - wa).div_euclid(step) * step;
let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb));
let target = snap(fr.frame_at(a.clock_time()).clamp(wa, wb));
if !video {
self.playback.audio_frame = Some(target);
} else if self.frames.is_cached(&FrameKey { frame: target, ..series }) {
@@ -322,7 +350,7 @@ impl EffectcraftApp {
fn sound_frame(&self, fr: effectcraft_engine::time::FrameRate, wa: i64, wb: i64, step: i64) -> Option<i64> {
let a = self.audio.as_ref()?;
let step = step.max(1);
Some(wa + (audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb) - wa).div_euclid(step) * step)
Some(wa + (fr.frame_at(a.clock_time()).clamp(wa, wb) - wa).div_euclid(step) * step)
}
/// The last cached frame after the current one and before `target`, in play order: what a
+356 -34
View File
@@ -10,7 +10,7 @@
//! **A/V sync:** while audio plays, the audio clock drives playback. The clock is the number of
//! sample frames the device has actually consumed (underruns do not advance it) minus the
//! device's output latency, mapped through the work-area loop ([`clock_sample`]); the viewer
//! shows the comp frame at that sample ([`frame_of_sample`]), dropping video frames rather
//! shows the comp frame at that time ([`AudioPlayback::clock_time`]), dropping video frames rather
//! than drifting.
//!
//! The feed also tracks per-channel peaks for the Audio panel's VU meters ([`Meter`]).
@@ -18,16 +18,19 @@
//! **Scrubbing:** Ctrl/Cmd-dragging the current-time indicator plays a short snippet of the mix
//! at each new frame ([`AudioScrub`], `playback.scrubAudio`), as in After Effects.
use std::collections::VecDeque;
use std::collections::{HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use effectcraft_engine::project::ItemId;
use effectcraft_engine::project::{GroupKind, ItemId, ItemKind, Layer, LayerSource, Project, PropGroup};
use effectcraft_engine::time::{FrameRate, Tick};
use crate::frames::RenderSource;
mod preview_rate;
use preview_rate::PreviewRate;
/// An audio output (implemented by the native app with cpal).
pub trait AudioDevice: Send {
/// Device sample rate in Hz.
@@ -88,9 +91,11 @@ impl AudioFeed {
pk[i & 1] = pk[i & 1].max(s.abs());
}
real = n;
// Queue removal and the clock advance are one handoff: an edit must not restart
// behind samples already handed to the device.
self.consumed.fetch_add((real / 2) as u64, Ordering::Relaxed);
}
out[real..].iter_mut().for_each(|o| *o = 0.0);
self.consumed.fetch_add((real / 2) as u64, Ordering::Relaxed);
for (c, v) in pk.into_iter().enumerate() {
// Rust 1.99 renamed `fetch_update` to `try_update`; keep the old name while the
// workspace supports Rust 1.95 (`rust-version`).
@@ -106,6 +111,14 @@ impl AudioFeed {
}
}
/// Discard an old mix and snapshot the first unconsumed frame under the same queue lock
/// as [`Self::pull`], so a concurrent device callback cannot leave the cursor behind.
fn discard_pending(&self) -> Option<u64> {
let mut q = self.queue.lock().ok()?;
q.clear();
Some(self.consumed())
}
pub fn push(&self, samples: &[f32]) {
if let Ok(mut q) = self.queue.lock() {
q.extend(samples.iter().copied());
@@ -131,7 +144,7 @@ impl AudioFeed {
}
}
/// What a preview plays, in comp sample indices at the device rate.
/// What a preview plays, in output sample coordinates on its playback timeline.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PlaySpan {
pub start: i64,
@@ -149,15 +162,23 @@ impl PlaySpan {
}
}
/// The comp sample index audible now: `consumed` frames played by the device minus its
/// The output sample coordinate audible now: `consumed` frames played by the device minus its
/// `latency`, from the span start, wrapped into the work area when looping (clamped at its end
/// otherwise).
pub fn clock_sample(span: &PlaySpan, consumed: u64, latency: u64) -> i64 {
let s = span.start + consumed.saturating_sub(latency) as i64;
if s < span.wb {
return s;
// Slow previews can span more output samples than fit in a signed difference. Keep the
// elapsed count and loop width wide until the result is back inside the span.
let s = i128::from(span.start) + i128::from(consumed.saturating_sub(latency));
if s < i128::from(span.wb) {
return s as i64;
}
if span.looping {
let width = (i128::from(span.wb) - i128::from(span.wa)).max(1);
let wrapped = i128::from(span.wa) + (s - i128::from(span.wb)).rem_euclid(width);
wrapped.clamp(i64::MIN as i128, i64::MAX as i128) as i64
} else {
span.wb.saturating_sub(1)
}
if span.looping { span.wa + (s - span.wb).rem_euclid((span.wb - span.wa).max(1)) } else { span.wb - 1 }
}
/// Comp time of sample index `s` at `rate` Hz.
@@ -179,10 +200,23 @@ pub struct AudioPlayback {
device: Box<dyn AudioDevice>,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
/// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`].
feeder: Option<(RenderSource, ItemId, i64, u32)>,
/// What the mixer reads and advances: the feeder thread, or [`AudioPlayback::pump`] on wasm32
/// (no threads).
state: Arc<Mutex<Feeder>>,
comp: ItemId,
mix: u32,
pub rate: u32,
pub span: PlaySpan,
timeline: PreviewRate,
}
/// The mixer's position and its view of the project.
struct Feeder {
src: RenderSource,
/// The next output sample index to mix (mapped to composition time by the preview rate).
cursor: i64,
/// Bumped when the project changes: a block mixed from an older project is dropped.
generation: u64,
}
/// Audio kept queued ahead of the device: ~200 ms (a feeder thread tops it up continuously);
@@ -197,7 +231,7 @@ impl AudioPlayback {
/// at (resampled to the device's rate when they differ; 0 = the device's rate).
#[allow(clippy::too_many_arguments)]
pub fn start(
mut device: Box<dyn AudioDevice>,
device: Box<dyn AudioDevice>,
src: RenderSource,
comp: ItemId,
start: Tick,
@@ -205,15 +239,34 @@ impl AudioPlayback {
wb: Tick,
looping: bool,
mix_rate: u32,
) -> Result<AudioPlayback, String> {
Self::start_at_speed(device, src, comp, start, wa, wb, looping, mix_rate, 1.0)
}
/// Start at a preview speed relative to composition time. Both the sound (including pitch)
/// and its composition clock follow this speed; the physical device rate stays unchanged.
#[allow(clippy::too_many_arguments)]
pub fn start_at_speed(
mut device: Box<dyn AudioDevice>,
src: RenderSource,
comp: ItemId,
start: Tick,
wa: Tick,
wb: Tick,
looping: bool,
mix_rate: u32,
speed: f64,
) -> Result<AudioPlayback, String> {
let rate = device.sample_rate().max(8000);
let mix = if mix_rate == 0 { rate } else { mix_rate.clamp(8000, 192_000) };
let span = PlaySpan::new(start, wa, wb, looping, rate);
let timeline = PreviewRate::new(rate, speed, start)?;
let span = timeline.span(start, wa, wb, looping);
let feed = Arc::new(AudioFeed::default());
let stop = Arc::new(AtomicBool::new(false));
let state = Arc::new(Mutex::new(Feeder { src, cursor: span.start, generation: 0 }));
if cfg!(target_arch = "wasm32") {
// The device takes samples only in `pump`, which fills the queue first.
let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, span.start, mix)), rate, span };
let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, state, comp, mix, rate, span, timeline };
p.device.start(feed)?;
p.pump();
return Ok(p);
@@ -223,15 +276,14 @@ impl AudioPlayback {
// as that took (#408). The device plays silence until the first block arrives, and the
// clock doesn't count it.
let thread = {
let (feed, stop) = (feed.clone(), stop.clone());
let (feed, stop, state) = (feed.clone(), stop.clone(), state.clone());
std::thread::Builder::new()
.name("ec-audio-feed".into())
.spawn(move || {
let mut cursor = span.start;
let target = queue_target(rate);
while !stop.load(Ordering::Relaxed) {
if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) {
cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix);
feed_block(&state, comp, &feed, &span, timeline, mix);
} else {
std::thread::sleep(std::time::Duration::from_millis(4));
}
@@ -240,24 +292,63 @@ impl AudioPlayback {
.map_err(|e| e.to_string())?
};
device.start(feed.clone())?;
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), feeder: None, rate, span })
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), state, comp, mix, rate, span, timeline })
}
/// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread
/// does elsewhere), then let the device take what it needs.
pub fn pump(&mut self) {
if let Some((src, comp, cursor, mix)) = &mut self.feeder {
if cfg!(target_arch = "wasm32") {
let target = queue_target(self.rate);
while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) {
*cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate, *mix);
feed_block(&self.state, self.comp, &self.feed, &self.span, self.timeline, self.mix);
}
}
self.device.pump();
}
/// The comp sample index audible now.
/// The project the sound is mixed from.
pub fn project(&self) -> Option<Arc<Project>> {
self.state.lock().ok().map(|s| s.src.project.clone())
}
/// Mix from `project` from now on (the project was edited while playing, e.g. a layer's Audio
/// switch, #2104): what is queued came from the old project, so it is dropped and mixing
/// resumes at the first sample the device has not taken yet (also when the feeder had
/// already mixed up to the end of a non-looping span).
pub fn set_project(&mut self, project: Arc<Project>) {
let Ok(mut s) = self.state.lock() else { return };
let Some(consumed) = self.feed.discard_pending() else { return };
s.src.project = project;
s.generation = s.generation.wrapping_add(1);
self.feed.finished.store(false, Ordering::Relaxed);
// The display clock clamps to the last *audible* sample. A finished non-looping
// feeder must instead stay at the exclusive end, never replay that last sample.
let next = i128::from(self.span.start) + i128::from(consumed);
s.cursor = if !self.span.looping && next >= i128::from(self.span.wb) { self.span.wb } else { clock_sample(&self.span, consumed, 0) };
}
/// Follow an edited `project`: mixing continues from it, and the queued sound is dropped only
/// when the edit changed what is heard ([`audio_changed`]); a transform or colour edit leaves
/// the queue alone, so playback does not drop out.
pub fn follow_project(&mut self, project: Arc<Project>) {
let Some(old) = self.project() else { return };
if audio_changed(&old, &project, self.comp) {
self.set_project(project);
} else if let Ok(mut s) = self.state.lock() {
s.src.project = project;
}
}
/// The comp sample index audible now, expressed at the device's rate.
pub fn clock(&self) -> i64 {
clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames())
let sample = clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames());
if self.timeline.normal() { sample } else { self.timeline.time(sample).to_units_floor(i64::from(self.rate)) }
}
/// Composition time under the audible output sample, including preview speed and latency.
pub fn clock_time(&self) -> Tick {
self.timeline.time(clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()))
}
pub fn finished(&self) -> bool {
@@ -279,20 +370,101 @@ impl Drop for AudioPlayback {
}
}
/// Mix one block at `cursor` into the feed; returns the next cursor (wrapping or finishing at
/// the work-area end).
fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32, mix: u32) -> i64 {
let mut cursor = cursor;
if cursor >= span.wb {
if !span.looping {
feed.finished.store(true, Ordering::Relaxed);
return cursor;
/// Whether `new` sounds different from `old` for `comp`: a layer's timing, source, Audio / Solo /
/// Effects switch, Audio Levels or effects, or the same for a nested comp. Other edits (video
/// properties, names, labels) do not change the mix unless an audio expression can read them.
fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool {
audio_changed_inner(old, new, comp, &mut HashSet::new())
}
/// Each shared precomp is compared once per depth. A depth limit alone still visits an
/// exponentially large number of paths in a small graph of reused precomps, blocking the UI.
fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut HashSet<(ItemId, usize)>) -> bool {
/// Compare one comp and its reachable audio sources, without revisiting a shared path depth.
fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize, seen: &mut HashSet<(ItemId, usize)>) -> bool {
if depth >= 16 || !seen.insert((comp, depth)) {
return false;
}
let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() };
a.layers.len() != b.layers.len()
|| a.layers.iter().zip(&b.layers).any(|(x, y)| {
x.id != y.id
|| x.source != y.source
|| x.start_time != y.start_time
|| x.in_point != y.in_point
|| x.out_point != y.out_point
|| x.stretch != y.stretch
|| x.switches.audio != y.switches.audio
|| x.switches.solo != y.switches.solo
|| x.switches.effects != y.switches.effects
|| x.props.sub("audio") != y.props.sub("audio")
|| audio_effects(x) != audio_effects(y)
|| audio_has_expression(x)
|| audio_has_expression(y)
|| match &x.source {
LayerSource::Footage { item } => footage_differs(old, new, *item),
LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1, seen),
_ => false,
}
})
}
/// The layer's audio effects (Effect > Audio), the only effects the mixer applies; a video effect
/// edit leaves them as they were.
fn audio_effects(l: &Layer) -> Vec<&PropGroup> {
let is_audio = |g: &&PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_engine::effects::audio_fx::is_audio_effect(effect));
l.effects().into_iter().flat_map(|fx| fx.groups()).filter(is_audio).collect()
}
/// Audio expressions can read any property in any comp (including video-only effects and
/// names). Without a complete dependency graph, keeping the old mix would be stale.
fn audio_has_expression(l: &Layer) -> bool {
l.switches.audio
&& (l.props.sub("audio").and_then(|g| g.get("levels")).is_some_and(|p| p.has_expression())
|| (l.switches.effects && audio_effects(l).iter().any(|g| g.enabled && g.props().any(|p| p.has_expression()))))
}
/// The footage item's audio availability changed (relinked, missing, no audio).
fn footage_differs(old: &Project, new: &Project, item: ItemId) -> bool {
let f = |p: &Project| match p.item(item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => Some((f.has_audio, f.missing, f.path.clone())),
_ => None,
};
f(old) != f(new)
}
comps_differ(old, new, comp, 0, seen)
}
/// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or
/// finishing at the work-area end). The block is mixed without holding the lock, so a project
/// change never waits for a slow mix; a block mixed from the old project is dropped.
fn feed_block(state: &Mutex<Feeder>, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, timeline: PreviewRate, mix: u32) {
let Ok((src, mut cursor, generation)) = state.lock().map(|s| (s.src.clone(), s.cursor, s.generation)) else {
// Poisoned: nothing more can be mixed.
feed.finished.store(true, Ordering::Relaxed);
return;
};
let mut block = None;
if cursor >= span.wb && span.looping {
cursor = span.wa;
}
let n = ((span.wb - cursor) as usize).min(BLOCK);
feed.push(&mix_block(src, comp, sample_time(cursor, rate), n, rate, mix));
cursor + n as i64
if cursor < span.wb {
let n = span.wb.saturating_sub(cursor).clamp(0, BLOCK as i64) as usize;
block = Some(if timeline.normal() {
mix_block(&src, comp, timeline.time(cursor), n, timeline.rate(), mix)
} else {
timeline.mix(cursor, n, mix, |t, frames| {
effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, frames, mix)
})
});
cursor += n as i64;
}
let Ok(mut s) = state.lock() else { return };
if s.generation != generation {
return;
}
match block {
Some(b) => feed.push(&b),
None => feed.finished.store(true, Ordering::Relaxed),
}
s.cursor = cursor;
}
/// `n` stereo frames of `comp`'s mix from comp time `t` at the device `rate`, mixed at `mix` Hz
@@ -457,6 +629,13 @@ mod tests {
assert_eq!(clock_sample(&once, 100_000, 0), 47999);
}
#[test]
fn slow_preview_clocks_do_not_overflow_a_wide_output_span() {
let span = PlaySpan { start: 0, wa: i64::MIN, wb: i64::MAX, looping: true };
assert_eq!(clock_sample(&span, u64::MAX, 0), 0);
assert_eq!(clock_sample(&PlaySpan { looping: false, ..span }, u64::MAX, 0), i64::MAX - 1);
}
#[test]
fn frame_mapping_matches_comp_frames() {
let fps = FrameRate::new(30, 1);
@@ -559,6 +738,145 @@ mod tests {
playback.stop();
}
/// #2104: muting a layer while the preview plays silences it: the queued sound is dropped
/// and everything mixed afterwards comes from the edited project.
#[test]
fn muting_a_layer_while_playing_silences_the_preview() {
use effectcraft_engine::color::Label;
use effectcraft_engine::project::{Comp, Footage, FootageKind, ItemKind, LayerSource, build};
use effectcraft_engine::render::FootageSource;
use effectcraft_engine::render::cache::LayerCache;
use std::time::{Duration, Instant};
/// Footage audio that is a constant 0.5.
struct Steady;
impl FootageSource for Steady {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_engine::raster::Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, _: u32) -> Option<Vec<f32>> {
Some(vec![0.5; frames * 2])
}
}
struct Device;
impl AudioDevice for Device {
fn sample_rate(&self) -> u32 {
48000
}
fn start(&mut self, _: Arc<AudioFeed>) -> Result<(), String> {
Ok(())
}
fn stop(&mut self) {}
}
let mut p = Project::default();
let footage =
Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() };
let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage));
let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0));
let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None);
comp.layers = vec![layer].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
let mut project = Arc::new(p);
let src = RenderSource {
project: project.clone(),
footage: Arc::new(Steady),
expr: None,
layer_cache: Arc::new(LayerCache::default()),
gpu: None,
gpu_display: false,
disk: None,
};
let mut playback =
AudioPlayback::start(Box::new(Device), src, cid, Tick::ZERO, Tick::ZERO, Tick::from_seconds_f64(10.0), true, 0).expect("the preview starts");
let t = Instant::now();
while playback.feed.queued_frames() == 0 {
assert!(t.elapsed() < Duration::from_secs(30), "the feeder never delivered");
std::thread::sleep(Duration::from_millis(10));
}
// Mute the layer while the sound plays.
if let Some(c) = Arc::make_mut(&mut project).comp_mut(cid) {
c.layers[0].switches.audio = false;
}
playback.set_project(project.clone());
assert!(playback.project().is_some_and(|p| Arc::ptr_eq(&p, &project)));
// The feeder tops the queue up again, now from the muted project.
while playback.feed.queued_frames() == 0 {
assert!(t.elapsed() < Duration::from_secs(30), "the feeder never delivered");
std::thread::sleep(Duration::from_millis(10));
}
let mut out = vec![1.0f32; playback.feed.queued_frames() * 2];
playback.feed.pull(&mut out);
assert!(out.iter().all(|v| *v == 0.0), "the muted layer still plays");
playback.stop();
}
/// #2104: only edits that change the mix drop the queued sound.
#[test]
fn only_audible_edits_change_the_audio() {
use effectcraft_engine::color::Label;
use effectcraft_engine::project::{Comp, Footage, FootageKind, build};
let mut p = Project::default();
let footage =
Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() };
let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage));
let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0));
let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None);
comp.layers = vec![layer].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
let old = p;
// A video-only edit: the layer is hidden, its name and label change.
let mut video = old.clone();
if let Some(l) = video.comp_mut(cid).and_then(|c| c.layers.first_mut()) {
l.switches.video = false;
l.name = "renamed".into();
}
assert!(!audio_changed(&old, &video, cid), "a video edit must not drop the sound");
// Muting, solo and a moved in point do change it.
for edit in 0..3 {
let mut edited = old.clone();
if let Some(l) = edited.comp_mut(cid).and_then(|c| c.layers.first_mut()) {
match edit {
0 => l.switches.audio = false,
1 => l.switches.solo = true,
_ => l.in_point = Tick::from_seconds_f64(1.0),
}
}
assert!(audio_changed(&old, &edited, cid), "audible edit {edit} must be followed");
}
}
/// #2104: a video effect edit leaves the mix alone, an audio effect changes it.
#[test]
fn only_audio_effects_change_the_audio() {
use effectcraft_engine::color::Label;
use effectcraft_engine::project::build::{self, Ids};
use effectcraft_engine::project::{Comp, Footage, FootageKind};
let mut p = Project::default();
let footage =
Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() };
let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage));
let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0));
let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None);
comp.layers = vec![layer].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
let with_effect = |id: &str| {
let mut edited = p.clone();
let spec = effectcraft_engine::effects::find(id).expect("the effect exists");
let mut next = edited.next_id;
let g = effectcraft_engine::effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 36.0]);
edited.next_id = next;
if let Some(fx) = edited.comp_mut(cid).and_then(|c| c.layers.first_mut()).and_then(|l| l.props.sub_mut("effects")) {
fx.children.push(g.into());
}
edited
};
assert!(!audio_changed(&p, &with_effect("ec.blur.gaussian"), cid), "a video effect must not drop the sound");
assert!(audio_changed(&p, &with_effect("ec.audio.backwards"), cid), "an audio effect must be followed");
}
#[test]
fn meter_decays_and_holds_peaks() {
let mut m = Meter::default();
@@ -573,3 +891,7 @@ mod tests {
assert!(m.peak_db[0] < -10.0, "{}", m.peak_db[0]);
}
}
#[cfg(test)]
#[path = "audio/live_tests.rs"]
mod live_tests;
+217
View File
@@ -0,0 +1,217 @@
//! #2104: exercise the real mixer and queue without relying on thread timing or an audio device.
use super::{AudioDevice, AudioFeed, AudioPlayback, Feeder, PlaySpan, PreviewRate, feed_block};
use crate::frames::RenderSource;
use effectcraft_engine::color::Label;
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::{Comp, Expression, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project, Property, build};
use effectcraft_engine::render::{EvalCtx, ExprHost, FootageSource, LayerCache};
use effectcraft_engine::time::{FrameRate, Tick};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
struct Device;
impl AudioDevice for Device {
fn sample_rate(&self) -> u32 {
48000
}
fn start(&mut self, _: Arc<AudioFeed>) -> Result<(), String> {
Ok(())
}
fn stop(&mut self) {}
}
/// A ramp identifies the exact source sample, exposing skips and repeats after an edit.
struct Ramp;
impl FootageSource for Ramp {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_engine::raster::Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
let first = t.to_units_floor(i64::from(rate));
Some((0..frames).flat_map(|i| [(first + i as i64) as f32 / 128.0; 2]).collect())
}
}
/// Construct a paused feeder whose short nonzero span makes every source sample observable.
fn playback(footage: Arc<dyn FootageSource>) -> AudioPlayback {
let mut project = Project::default();
let f = Footage { path: "ramp.wav".into(), kind: FootageKind::Audio, has_audio: true, ..Default::default() };
let item = project.add_item("Ramp", Label::SeaFoam, None, ItemKind::Footage(f));
let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
let layer = build::layer(&mut project, &comp, "Ramp", LayerSource::Footage { item }, (0, 0), None);
comp.layers = vec![layer].into();
let cid = project.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
let src = RenderSource {
project: Arc::new(project),
footage,
expr: None,
layer_cache: Arc::new(LayerCache::default()),
gpu: None,
gpu_display: false,
disk: None,
};
AudioPlayback {
feed: Arc::new(AudioFeed::default()),
device: Box::new(Device),
stop: Arc::new(AtomicBool::new(false)),
thread: None,
state: Arc::new(Mutex::new(Feeder { src, cursor: 24, generation: 0 })),
comp: cid,
mix: 48000,
rate: 48000,
span: PlaySpan { start: 24, wa: 16, wb: 64, looping: false },
timeline: PreviewRate::new(48000, 1.0, Tick::ZERO).unwrap(),
}
}
/// Run exactly one production feeder block, without a background refill racing assertions.
fn mix(p: &AudioPlayback) {
feed_block(&p.state, p.comp, &p.feed, &p.span, p.timeline, p.mix);
}
/// Copy the active project and apply a layer edit through the same snapshot boundary as the UI.
fn edit(p: &AudioPlayback, update: impl FnOnce(&mut Layer)) -> Arc<Project> {
let mut project = p.project().unwrap();
update(&mut Arc::make_mut(&mut project).comp_mut(p.comp).unwrap().layers[0]);
project
}
/// Levels edits retain sample position across the loop boundary and the exclusive non-looping end.
#[test]
fn project_replacement_resumes_at_the_first_unconsumed_sample() {
for looping in [false, true] {
let mut p = playback(Arc::new(Ramp));
p.span.looping = looping;
mix(&p);
let mut out = [0.0; 64];
p.feed.pull(&mut out);
assert_eq!(out[0], 24.0 / 128.0);
assert_eq!(p.feed.consumed(), 32);
let updated = edit(&p, |l| l.props.sub_mut("audio").unwrap().get_mut("levels").unwrap().value = Value::Vec2([-6.020599913279624; 2]));
p.follow_project(updated);
assert_eq!(p.feed.queued_frames(), 0);
assert_eq!(p.state.lock().unwrap().cursor, 56);
mix(&p);
let mut tail = [0.0; 16];
p.feed.pull(&mut tail);
assert!((tail[0] - 56.0 / 256.0).abs() < 1e-6);
assert!((tail[14] - 63.0 / 256.0).abs() < 1e-6);
p.set_project(p.project().unwrap());
assert_eq!(p.state.lock().unwrap().cursor, if looping { 16 } else { 64 });
mix(&p);
if looping {
let mut first = [0.0; 2];
p.feed.pull(&mut first);
assert!((first[0] - 16.0 / 256.0).abs() < 1e-6, "resume at the work-area start");
} else {
assert!(p.finished(), "an edit after the last sample must not replay it");
assert_eq!(p.feed.queued_frames(), 0);
}
}
}
struct Gated {
entered: mpsc::Sender<()>,
resume: Mutex<mpsc::Receiver<()>>,
}
impl FootageSource for Gated {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_engine::raster::Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, _: u32) -> Option<Vec<f32>> {
self.entered.send(()).unwrap();
self.resume.lock().unwrap().recv_timeout(Duration::from_secs(10)).unwrap();
Some(vec![0.5; frames * 2])
}
}
/// A slow mix must not block edits or publish samples from the superseded project generation.
#[test]
fn a_block_in_flight_cannot_publish_after_a_mute() {
let (entered, waiting) = mpsc::channel();
let (resume, gate) = mpsc::channel();
let mut p = playback(Arc::new(Gated { entered, resume: Mutex::new(gate) }));
let (state, feed, span, comp, rate) = (p.state.clone(), p.feed.clone(), p.span, p.comp, p.rate);
let worker = std::thread::spawn(move || feed_block(&state, comp, &feed, &span, PreviewRate::new(rate, 1.0, Tick::ZERO).unwrap(), rate));
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
// The edit completes while the mixer is blocked, without waiting on its work.
p.follow_project(edit(&p, |l| l.switches.audio = false));
resume.send(()).unwrap();
worker.join().unwrap();
assert_eq!(p.feed.queued_frames(), 0, "the old generation cannot publish");
assert_eq!(p.state.lock().unwrap().cursor, 24, "nor advance the replacement cursor");
mix(&p);
let mut out = [1.0; 16];
p.feed.pull(&mut out);
assert_eq!(out, [0.0; 16]);
}
/// Models a levels expression that reads a video property through the renderer's expression API.
struct OpacityLevels;
impl ExprHost for OpacityLevels {
fn eval(&self, _: &EvalCtx, layer: &Layer, _: &Property, _: &Value) -> Result<Value, String> {
let opacity = layer.transform().unwrap().get("opacity").unwrap().value.as_f64();
Ok(Value::Vec2([if opacity == 0.0 { -96.0 } else { 0.0 }; 2]))
}
}
/// Video properties are irrelevant to a plain mix, but may be dependencies of audio expressions.
#[test]
fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() {
for expression in [false, true] {
let mut p = playback(Arc::new(Ramp));
if expression {
let updated = edit(&p, |l| {
l.props.sub_mut("audio").unwrap().get_mut("levels").unwrap().expr =
Some(Expression { text: "[transform.opacity, transform.opacity]".into(), enabled: true });
});
let mut state = p.state.lock().unwrap();
state.src.project = updated;
state.src.expr = Some(Arc::new(OpacityLevels));
}
mix(&p);
assert_eq!(p.feed.queued_frames(), 40);
let updated = edit(&p, |l| l.props.sub_mut("transform").unwrap().get_mut("opacity").unwrap().value = Value::Scalar(0.0));
p.follow_project(updated);
if expression {
assert_eq!(p.feed.queued_frames(), 0, "the expression can read the edited video property");
mix(&p);
let mut out = [1.0; 16];
p.feed.pull(&mut out);
assert_eq!(out, [0.0; 16]);
} else {
assert_eq!(p.feed.queued_frames(), 40, "an unrelated edit cannot interrupt audio");
}
}
}
/// Reused nested comps cost linear visits while edits to their descendants remain detectable.
#[test]
fn shared_nested_comps_are_compared_once_per_depth() {
let mut project = Project::default();
let mut child = None;
for _ in 0..10 {
let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
if let Some(item) = child {
for _ in 0..10 {
let mut layer = build::layer(&mut project, &comp, "Nested", LayerSource::Comp { item }, (64, 36), None);
layer.switches.audio = false;
comp.layers.push(layer);
}
}
child = Some(project.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(comp.into())));
}
let root = child.unwrap();
let mut edited = project.clone();
edited.comp_mut(root).unwrap().layers[0].name = "Renamed".into();
let mut seen = std::collections::HashSet::new();
assert!(!super::audio_changed_inner(&project, &edited, root, &mut seen));
assert_eq!(seen.len(), 10, "compare the ten comps, not a billion paths through them");
let leaf = *seen.iter().find(|(_, depth)| *depth == 9).map(|(item, _)| item).unwrap();
let mut layer = project.comp(root).unwrap().layers[0].clone();
layer.source = LayerSource::Null;
edited.comp_mut(leaf).unwrap().layers.push(layer);
assert!(super::audio_changed(&project, &edited, root), "memoization must still find a nested edit");
}
+199
View File
@@ -0,0 +1,199 @@
//! Preview speed changes composition time, not the output device's sample rate.
//! Keep the clock and the resampler on the same rational output-sample grid.
use effectcraft_engine::time::{FrameRate, TICKS_PER_SECOND, Tick};
use super::PlaySpan;
/// At most this many composition samples are mixed at once. At very high preview speeds,
/// skip samples that no output frame needs instead of allocating the entire source interval.
const SOURCE_PAGE: usize = 4096;
#[derive(Clone, Copy)]
pub(super) struct PreviewRate {
rate: u32,
speed: FrameRate,
origin: Tick,
}
impl PreviewRate {
pub(super) fn new(rate: u32, speed: f64, start: Tick) -> Result<Self, String> {
// A billionth keeps the ratio of decimal preview rates and rational comp rates without
// rounding it to an integer sample rate. Reject unrepresentable values, never mute them.
let num = (speed * 1_000_000_000.0).round();
if rate == 0 || !speed.is_finite() || speed <= 0.0 || num < 1.0 || num >= i64::MAX as f64 {
return Err("The preview playback rate must be finite, positive and representable".into());
}
let speed = FrameRate::new(num as i64, 1_000_000_000);
// Keep the existing 1x sample grid. At another speed, anchor the grid to the exact
// requested frame rather than rounding a nonzero start backwards by an output sample.
let origin = if speed.num == speed.den { Tick::ZERO } else { start };
Ok(Self { rate, speed, origin })
}
pub(super) fn rate(self) -> u32 {
self.rate
}
pub(super) fn normal(self) -> bool {
self.speed.num == self.speed.den
}
pub(super) fn time(self, sample: i64) -> Tick {
if self.normal() {
// Preserve the legacy grid, including negative samples at unusual device rates.
return super::sample_time(sample, self.rate);
}
self.origin + Tick::from_rational(sample, self.speed.num, i64::from(self.rate) * self.speed.den)
}
/// First output sample at or after a composition time. Ceiling keeps loop endpoints inside
/// the work area. A fractional loop boundary is quantized by less than one output sample.
fn sample_ceil(self, time: Tick) -> i64 {
let n = i128::from((time - self.origin).0) * i128::from(self.rate) * i128::from(self.speed.den);
let d = i128::from(TICKS_PER_SECOND) * i128::from(self.speed.num);
let sample = n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0);
sample.clamp(i64::MIN as i128, i64::MAX as i128) as i64
}
pub(super) fn span(self, start: Tick, wa: Tick, wb: Tick, looping: bool) -> PlaySpan {
if self.normal() {
return PlaySpan::new(start, wa, wb, looping, self.rate);
}
let wa = self.sample_ceil(wa);
PlaySpan { start: 0, wa, wb: self.sample_ceil(wb).max(wa.saturating_add(1)), looping }
}
/// Source sample and interpolation phase at an absolute output cursor. Computing this
/// from the timeline (not by accumulating block lengths) makes partitioning immaterial.
fn position(self, sample: i64, mix: u32) -> (i64, f32) {
let n = i128::from(self.time(sample).0) * i128::from(mix);
let d = i128::from(TICKS_PER_SECOND);
(n.div_euclid(d) as i64, n.rem_euclid(d) as f32 / d as f32)
}
pub(super) fn mix(self, cursor: i64, n: usize, mix: u32, mut render: impl FnMut(Tick, usize) -> Vec<f32>) -> Vec<f32> {
let mut out = Vec::with_capacity(n.saturating_mul(2));
let mut page = Vec::new();
let mut first = 0;
for i in 0..n {
let sample = cursor.saturating_add(i as i64);
let (at, fraction) = self.position(sample, mix);
let offset = usize::try_from(at.saturating_sub(first)).ok();
if page.is_empty() || offset.is_none_or(|i| i >= (page.len() / 2).saturating_sub(1)) {
first = at;
let last = self.position(cursor.saturating_add(n.saturating_sub(1) as i64), mix).0;
let next = self.position(sample.saturating_add(1), mix).0;
let frames = if next.saturating_sub(first) >= SOURCE_PAGE as i64 - 1 {
// No following output sample can reuse this page. Read only this sample
// and its interpolation neighbor, rather than mixing an unused gap.
2
} else {
last.saturating_sub(first).saturating_add(2).clamp(2, SOURCE_PAGE as i64) as usize
};
// mix_comp floors its starting sample. Add the fractional tick back so exotic
// source rates start on `first`, not the preceding composition sample.
let mut t = Tick::from_units(first, i64::from(mix));
if t.to_units_floor(i64::from(mix)) < first {
t.0 = t.0.saturating_add(1);
}
page = render(t, frames);
}
let index = usize::try_from(at.saturating_sub(first)).unwrap_or(usize::MAX).saturating_mul(2);
for channel in 0..2 {
let a = page.get(index.saturating_add(channel)).copied().unwrap_or(0.0);
let b = page.get(index.saturating_add(2 + channel)).copied().unwrap_or(a);
out.push(a + (b - a) * fraction);
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audio::clock_sample;
#[test]
fn normal_speed_preserves_the_legacy_sample_grid() {
for rate in [44_100, 48_000, 48_001] {
let timeline = PreviewRate::new(rate, 1.0, Tick::from_seconds_f64(-0.3)).unwrap();
for sample in [-48_001, -1, 0, 1, 48_001] {
assert_eq!(timeline.time(sample), crate::audio::sample_time(sample, rate));
}
}
}
#[test]
fn speed_clock_matches_slow_fast_and_fractional_previews() {
for (comp, preview) in [(FrameRate::FPS_60, 30.0), (FrameRate::FPS_30, 60.0), (FrameRate::FPS_59_94, 29.97)] {
let speed = preview / comp.as_f64();
let start = comp.tick_of(17);
let rate = PreviewRate::new(48_000, speed, start).unwrap();
assert_eq!(rate.time(0), start, "the first frame stays exact");
let time = rate.time(48_000);
assert!(((time - start).seconds() - speed).abs() < 1e-9);
assert_eq!(comp.frame_at(time), comp.frame_at(start + Tick::from_seconds_f64(speed)));
}
}
#[test]
fn speed_clock_subtracts_output_latency_and_wraps_inside_the_work_area() {
for speed in [0.5, 2.0, 0.73] {
let start = FrameRate::FPS_29_97.tick_of(19);
let wa = FrameRate::FPS_29_97.tick_of(13);
let wb = FrameRate::FPS_29_97.tick_of(43);
let rate = PreviewRate::new(48_000, speed, start).unwrap();
let span = rate.span(start, wa, wb, true);
assert_eq!(rate.time(clock_sample(&span, 512, 1024)), start);
assert!(rate.time(span.wb - 1) < wb);
let wrapped = rate.time(clock_sample(&span, span.wb as u64 + 1024, 1024));
assert!(wrapped >= wa && (wrapped - wa).seconds() < speed / 48_000.0);
let once = PlaySpan { looping: false, ..span };
assert!(rate.time(clock_sample(&once, u32::MAX as u64, 0)) < wb);
}
}
fn ramp(t: Tick, n: usize, mix: u32) -> Vec<f32> {
let first = t.to_units_floor(i64::from(mix));
(0..n).flat_map(|i| [(first + i as i64) as f32 / 100_000.0, -(first + i as i64) as f32 / 100_000.0]).collect()
}
#[test]
fn speed_mix_keeps_fractional_phase_across_uneven_blocks() {
for speed in [0.5, 2.0, 0.73, 29.97 / FrameRate::FPS_59_94.as_f64()] {
let start = FrameRate::FPS_29_97.tick_of(17);
let rate = PreviewRate::new(48_000, speed, start).unwrap();
for mix in [44_100, 48_000, 96_000] {
let whole = rate.mix(0, 2048, mix, |t, n| ramp(t, n, mix));
let split: Vec<_> = [(0, 321), (321, 107), (428, 1620)].into_iter().flat_map(|(at, n)| rate.mix(at, n, mix, |t, n| ramp(t, n, mix))).collect();
assert_eq!(whole, split, "speed {speed}, source rate {mix}");
for (i, frame) in whole.as_chunks::<2>().0.iter().enumerate() {
let expected = (start.seconds() + i as f64 * speed / 48_000.0) * mix as f64 / 100_000.0;
assert!((f64::from(frame[0]) - expected).abs() < 1e-6);
assert_eq!(frame[1], -frame[0]);
}
}
}
}
#[test]
fn extreme_speeds_keep_source_reads_bounded_without_changing_speed() {
for speed in [0.00001, 99_900.0] {
let rate = PreviewRate::new(8_000, speed, Tick::ZERO).unwrap();
let mut rendered = 0;
let out = rate.mix(0, 1024, 192_000, |_, n| {
assert!(n <= SOURCE_PAGE);
rendered += n;
vec![0.25; n * 2]
});
assert!(rendered <= SOURCE_PAGE, "unused source intervals were mixed: {rendered} samples");
assert_eq!(out, vec![0.25; 2048]);
assert!((rate.time(8000).seconds() - speed).abs() < 1e-9);
}
for speed in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MAX] {
assert!(PreviewRate::new(48_000, speed, Tick::ZERO).is_err());
}
}
}
+27 -2
View File
@@ -2,7 +2,9 @@
//! embedding host, never to a library borrowing its device. This bridge does no device work,
//! document mutation, file I/O or renderer locking inside a notification.
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, OnceLock};
pub use effectcraft_gpu::{ScopedError, ScopedErrorObserver};
type Notifier = Arc<dyn Fn(String) + Send + Sync>;
@@ -25,11 +27,22 @@ struct State {
pub struct GpuFailureBridge {
state: Arc<Mutex<State>>,
ctx: egui::Context,
scoped_observer: Arc<OnceLock<ScopedErrorObserver>>,
}
impl GpuFailureBridge {
pub fn new(ctx: &egui::Context) -> Self {
Self { state: Arc::default(), ctx: ctx.clone() }
Self { state: Arc::default(), ctx: ctx.clone(), scoped_observer: Arc::default() }
}
/// Register the host observer before handing this bridge to the app. Scoped errors do
/// not retire the device: they remain recoverable per-frame observations.
pub fn set_scoped_error_observer(&self, observer: impl Fn(ScopedError) + Send + Sync + 'static) -> bool {
self.scoped_observer.set(Arc::new(observer)).is_ok()
}
pub(crate) fn scoped_error_observer(&self) -> Option<ScopedErrorObserver> {
self.scoped_observer.get().cloned()
}
pub fn failure(&self) -> Option<GpuFailure> {
@@ -90,6 +103,18 @@ mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn scoped_observer_is_shared_without_retiring_the_device() {
let bridge = GpuFailureBridge::new(&egui::Context::default());
let seen = Arc::new(Mutex::new(Vec::new()));
let copy = seen.clone();
assert!(bridge.set_scoped_error_observer(move |error| copy.lock().unwrap().push(error)));
assert!(!bridge.set_scoped_error_observer(|_| panic!("must not replace observer")));
bridge.clone().scoped_error_observer().unwrap()(ScopedError::Internal);
assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal]);
assert!(bridge.failure().is_none());
}
#[test]
fn a_construction_failure_is_replayed_after_notifier_registration() {
let bridge = GpuFailureBridge::new(&egui::Context::default());
+2
View File
@@ -203,6 +203,8 @@ pub struct Playback {
/// sound plays it goes on through frames that aren't cached, and the picture drops frames to
/// follow it (#2473).
pub audio_held: bool,
/// The project last checked for a silent preview that became audible (#2104).
pub silent_project: Option<Arc<effectcraft_engine::project::Project>>,
/// Starting a preview with sound (#947): the playhead stays on the first frame while the
/// frames ahead render (the prefetch window, at most what fits in the RAM preview), then
/// sound and picture start together from it. Without this the picture played on silently
+80 -27
View File
@@ -588,15 +588,27 @@ pub fn snap_time(candidates: &[f64], t: f64, tolerance: f64) -> f64 {
/// Times the current-time indicator snaps to (comp seconds).
fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec<f64> {
snap_targets(comp, rows, &[], false)
}
/// [`snap_candidates`] without the `edited` layers' In / Out points; when `moving` them, without
/// their markers and keys either (those move along).
fn snap_targets(comp: &Comp, rows: &[Row], edited: &[u64], moving: bool) -> Vec<f64> {
let skip = |l: LayerId, contents: bool| edited.contains(&l.0) && (moving || !contents);
let mut v = vec![0.0, comp.work_area.0.seconds(), comp.work_area.1.seconds(), comp.duration.seconds()];
v.extend(comp.markers.iter().map(|m| m.time.seconds()));
for l in &comp.layers {
v.push(l.in_point.seconds());
v.push(l.out_point.seconds());
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
if !skip(l.id, false) {
v.push(l.in_point.seconds());
v.push(l.out_point.seconds());
}
if !skip(l.id, true) {
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
}
}
for r in rows {
if let RowKind::Prop { uid } = r.kind
&& !skip(r.layer, true)
&& let Some(l) = comp.layer(r.layer)
&& let Some(p) = l.props.find(uid)
{
@@ -3473,44 +3485,85 @@ fn layer_quick_offset_begin(ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, body: &e
ctx.data(|d| d.get_temp::<QuickBar>(quick_bar_id())).is_some_and(|q| q.layer == layer.id)
}
/// Moving and trimming a layer bar by whole frames.
/// One layer bar drag: the layers it edits, the pointer's travel (px), the grabbed layer's moving
/// ends and the snap targets when it started (comp seconds), and the whole frames sent so far.
#[derive(Clone)]
struct BarDrag {
edited: Vec<u64>,
px: f64,
ends: Vec<f64>,
targets: Arc<[f64]>,
sent: i64,
}
/// The whole frames a bar drag of `px` moves its ends by: truncated as the pointer goes, or with
/// `shift`, the move that puts one of `ends` on the nearest target within 8 px of the pointer
/// (#1828). Measured from the pointer, never the snapped result, so dragging on lets go. The
/// move stays whole frames, so an end off the frame grid lands within half a frame of its target.
fn bar_drag_frames(px: f64, pps: f64, fd: f64, ends: &[f64], targets: &[f64], shift: bool) -> i64 {
let secs = px / pps;
let snapped = shift
.then(|| {
ends.iter()
.flat_map(|e| targets.iter().map(move |t| t - e))
.filter(|d| ((d - secs) * pps).abs() <= 8.0)
.min_by(|a, b| (a - secs).abs().total_cmp(&(b - secs).abs()))
})
.flatten();
// `as` saturates (NaN → 0), so a zero frame duration or zoom can't panic.
snapped.map_or((secs / fd).trunc() as i64, |d| (d / fd).round() as i64)
}
/// Moving and trimming a layer bar by whole frames; Shift snaps its ends to the current time,
/// markers, keys, the work area and other layers' In / Out points, as in After Effects (#1828).
fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) {
let BarResps { body, lin, lout } = bars;
let pps = g.tl.pps;
let fd = g.tl.fd;
let ctx = g.tl.ctx.clone();
let actions = &mut g.tl.actions;
let selected = &g.selected;
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
let drag_key = format!("bar-{}", layer.id.0);
for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
if kind == "move" && quick {
continue;
}
let id = egui::Id::unique(("bar-drag", layer.id.0, kind));
if resp.dragged() {
let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
acc += resp.drag_delta().x;
let frames = ((acc as f64 / pps) / fd).trunc() as i64;
if frames != 0 {
acc -= (frames as f64 * fd * pps) as f32;
let d = frames as f64 * fd;
// Moving or trimming a selected layer acts on the whole
// selection; each layer trims from its own in/out point.
let targets = json!(edited_layers(layer.id, selected));
let params = match kind {
"move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
"in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
_ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
let shift = ctx.input(|i| i.modifiers.shift);
// Moving or trimming a selected layer acts on the whole selection as it is when the
// drag starts; each layer trims from its own in/out point. A Shift-drag on an
// unselected bar adds it to the selection (`layer_bar_hits`) and moves them all.
let start = || {
let mut edited = edited_layers(layer.id, &g.selected);
if kind == "move" && shift && !g.selected.contains(&layer.id) {
edited.extend(g.selected.iter().map(|l| l.0));
}
let (i, o) = (layer.in_point.seconds(), layer.out_point.seconds());
let ends = match kind {
"move" => vec![i, o],
"in" => vec![i],
_ => vec![o],
};
actions.push(("layer.timing".into(), params));
let mut targets = snap_targets(&g.tl.comp, &g.tl.rows, &edited, kind == "move");
targets.push(g.tl.time.seconds());
BarDrag { edited, px: 0.0, ends, targets: targets.into(), sent: 0 }
};
let mut drag = ctx.data(|d| d.get_temp::<BarDrag>(id)).filter(|_| !resp.drag_started()).unwrap_or_else(start);
drag.px += f64::from(resp.drag_delta().x);
let frames = bar_drag_frames(drag.px, g.tl.pps, g.tl.fd, &drag.ends, &drag.targets, shift);
if frames != drag.sent {
let d = frames.saturating_sub(drag.sent) as f64 * g.tl.fd;
drag.sent = frames;
let params = match kind {
"move" => json!({"layers": drag.edited, "delta": d, "merge": drag_key}),
"in" => json!({"layers": drag.edited, "inDelta": d, "merge": drag_key}),
_ => json!({"layers": drag.edited, "outDelta": d, "merge": drag_key}),
};
g.tl.actions.push(("layer.timing".into(), params));
}
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
ctx.data_mut(|d| d.insert_temp(id, drag));
}
if resp.drag_stopped() {
ctx.data_mut(|d| d.remove::<f32>(egui::Id::unique(("bar-acc", layer.id.0, kind))));
ui_actions.push(UiAct::EndMerge);
ctx.data_mut(|d| d.remove::<BarDrag>(id));
g.ui_actions.push(UiAct::EndMerge);
}
}
}
@@ -434,6 +434,34 @@ fn cti_snaps_to_the_nearest_candidate() {
assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0));
}
/// A dragged layer's own In / Out points are no snap targets; moved, neither are its markers
/// (#1828).
#[test]
fn bar_drags_dont_snap_to_their_own_layer() {
let mut app = app();
let bx = app.session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0;
app.session.execute("layer.addMarker", json!({"layers": [bx], "time": 1.0})).unwrap();
let comp = app.session.active_comp().unwrap().clone();
let rows = build_rows(&app, &comp);
let all = snap_targets(&comp, &rows, &[], false);
let trimmed = snap_targets(&comp, &rows, &[bx], false);
let moved = snap_targets(&comp, &rows, &[bx], true);
assert_eq!(trimmed.len(), all.len() - 2, "trimming drops only the layer's In and Out");
assert!(trimmed.contains(&1.0) && !moved.contains(&1.0), "moving drops the layer's marker: {moved:?}");
}
/// Shift snaps a bar drag's nearest end to a target within 8 px of the pointer; farther away, or
/// without Shift, the bar moves by the whole frames the pointer covered (#1828).
#[test]
fn bar_drags_snap_their_ends_with_shift() {
let (pps, fd, ends) = (100.0, 0.1, [2.0, 6.0]);
assert_eq!(bar_drag_frames(95.0, pps, fd, &ends, &[3.0], true), 10, "5 px short of 2 → 3 s snaps");
assert_eq!(bar_drag_frames(95.0, pps, fd, &ends, &[3.0], false), 9, "no snap without Shift");
assert_eq!(bar_drag_frames(88.0, pps, fd, &ends, &[3.0], true), 8, "12 px away lets go");
assert_eq!(bar_drag_frames(104.0, pps, fd, &ends, &[3.0, 7.06], true), 11, "the target nearest the pointer wins");
assert_eq!(bar_drag_frames(5.0, 0.0, 0.0, &ends, &[0.0], true), i64::MAX, "zero zoom saturates, no panic");
}
/// Every string drawn by one frame of `draw`.
fn drawn_text(mut draw: impl FnMut(&mut egui::Ui)) -> Vec<String> {
fn collect(shape: &egui::Shape, out: &mut Vec<String>) {
+16 -8
View File
@@ -838,9 +838,12 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m
// A right-click an overlay took (the Cryptomatte eyedropper's) opens the menu too.
let forwarded = ui.data_mut(|d| d.remove_temp::<egui::Pos2>(egui::Id::unique(FORWARDED_SECONDARY)));
if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.secondary_clicked()).or(forwarded) {
// Picking an already selected layer preserves the group; an unselected hit
// becomes the target, using the same visible/unlocked hit test as left-click.
pick(app, ectx, map.to_comp(pos), false);
// The layer a left click there would select: the topmost one, keeping the group when it
// is already selected (#1535), or the selected layer whose shown path is under the
// pointer. The menu's list reaches the layers under it.
if ov::segment_at(&v.paths, &map, pos, 6.0).is_none() {
click_pick(app, ectx, map.to_comp(pos), false);
}
let point = map.to_comp(pos);
let hits: Vec<(u64, String)> = layers_at(ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
ui.data_mut(|d| d.insert_temp(menu_hits_id, hits));
@@ -1845,15 +1848,19 @@ fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response
}
// The Roto Brush paints in the Layer panel only: select as usual and say so.
rt if rt.roto_kind(false).is_some() => {
if pick(app, ectx, cpt, mods.shift).is_none() {
if click_pick(app, ectx, cpt, mods.shift).is_none() {
let _ = app.session.execute("edit.deselectAll", json!({}));
}
app.ui.status = crate::i18n::tr(ROTO_HINT).to_string();
}
_ => {
if pick(app, ectx, cpt, mods.shift).is_none() {
let on_path = ov::segment_at(&paths, &map, pos, 6.0).is_some();
if on_path && !mods.shift {
// A click on a path of a selected layer stays on that layer, even where a
// layer in front covers it, so a double-click there free-transforms the path.
} else if click_pick(app, ectx, cpt, mods.shift).is_none() {
let _ = app.session.execute("edit.deselectAll", json!({}));
} else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() {
} else if !mods.shift && !app.session.state.selected_vertices.is_empty() && !on_path {
// A click away from the paths, even on their layer, deselects the mask
// points (the layer stays selected), as in After Effects (#513).
let _ = app.session.execute("mask.selectVertices", json!({"vertices": []}));
@@ -1904,7 +1911,8 @@ fn phase_dblclick(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Respons
{
let cpt = map.to_comp(pos);
let roto = app.ui.tool.roto_kind(false).is_some();
if let Some(l) = pick(app, ectx, cpt, false).and_then(|l| comp.layer(l)) {
// The topmost layer, as the double-click's first click selected it (#1535).
if let Some(l) = click_pick(app, ectx, cpt, false).and_then(|l| comp.layer(l)) {
match &l.source {
effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
// With a Roto Brush tool, double-clicking a layer opens it in the Layer panel,
@@ -2002,8 +2010,8 @@ use geometry::{
use geometry::{box_corners, project_clipped};
pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad};
use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius};
pub(crate) use shapes::{click_pick, layer_at, layer_at_including_locked, layers_at, pick, selectable_layers};
use shapes::{create_shape, draw_shape_ghost, shape_drag};
pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers};
#[cfg(test)]
use tools::constrain_45;
use tools::{add_motion_key, axis_at, axis_drag, drag_axes, draw_rigs, parent_world_inverse, pen_cursor, pen_cursor_icon, pen_press, select_motion_key};
+18 -3
View File
@@ -39,10 +39,24 @@ fn quad_area(q: &[[f64; 2]; 4]) -> f64 {
(0..4).map(|i| q[i][0] * q[(i + 1) % 4][1] - q[(i + 1) % 4][0] * q[i][1]).sum::<f64>().abs() / 2.0
}
/// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in
/// The layer a drag from a comp point acts on, selected (Shift toggles the topmost layer). As in
/// After Effects, a selected layer under the point comes before the layers in front of it, so it
/// can be dragged where they cover it; otherwise the topmost layer (#230).
/// can be dragged where they cover it; otherwise the topmost layer (#230). Clicks use
/// [`click_pick`], except a Puppet tool click while no layer has pins, which uses this so a pin
/// can go on a selected layer behind others.
pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option<LayerId> {
pick_layer(app, ectx, cpt, toggle, true)
}
/// The layer a click (press and release without a drag) at a comp point acts on: always the
/// topmost layer, selected unless it already is (Shift toggles it). Only a drag goes to a selected
/// layer behind others ([`pick`]), so a click can still select a layer in front of it (#1535).
/// The viewer doesn't call it for a click on a shown path, which stays on the path's layer.
pub(crate) fn click_pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option<LayerId> {
pick_layer(app, ectx, cpt, toggle, false)
}
fn pick_layer(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool, prefer_selected: bool) -> Option<LayerId> {
if toggle {
let hit = layer_at(ectx, cpt)?.id;
let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true}));
@@ -50,7 +64,8 @@ pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], togg
}
let selected = &app.session.state.selected_layers;
let hits: Vec<LayerId> = layers_at(ectx, cpt).map(|l| l.id).collect();
let hit = *hits.iter().find(|id| selected.contains(id)).or(hits.first())?;
let preferred = if prefer_selected { hits.iter().find(|id| selected.contains(id)) } else { None };
let hit = *preferred.or(hits.first())?;
if !selected.contains(&hit) {
let _ = app.session.execute("layer.select", json!({"layers": [hit.0]}));
}
+203
View File
@@ -739,3 +739,206 @@ fn adding_markers_does_not_stop_the_sound() {
}
assert_eq!(h.state().session.project.comp(cid).unwrap().markers.len(), 6);
}
/// #2104: unmuting a preview that started silent opens sound at the moving preview position,
/// both with video and with Video off (where the timeline deliberately stays still).
#[test]
fn unmuting_a_silent_preview_starts_audio_at_its_current_frame() {
for video in [false, true] {
let fake = Fake::default();
let (mut app, cid) = setup(true, &fake);
let layer = app.session.project.comp(cid).unwrap().layers[0].id.0;
app.session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "audio", "value": false})).unwrap();
let gate = GatedFrames::default();
app.frames.set_remote(Some(Arc::new(gate.clone())));
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
h.state_mut().use_manual_clock();
h.run_steps(2);
gate.render(&mut h, cid, &(0..60).collect::<Vec<_>>());
let now = h.state().now();
let shortcut = if video { h.state().session.prefs.preview.current } else { effectcraft_engine::preview::PreviewShortcut::AltNumpad0 };
h.state_mut().play_with(now, shortcut);
for _ in 0..6 {
step(&mut h);
}
assert!(h.state().audio.is_none(), "muted playback has no output");
let current = if video { frame(&h, cid) } else { h.state().playback.audio_frame.unwrap() };
assert!(current > 0, "the silent preview advanced");
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "audio", "value": true})).unwrap();
gate.render(&mut h, cid, &(0..60).collect::<Vec<_>>());
h.step();
assert_eq!(audio_frame(&h, cid), Some(current), "unmute starts at the moving preview frame, video={video}");
if !video {
assert_eq!(frame(&h, cid), 0, "audio-only preview leaves the timeline stationary");
}
let feed = fake.0.lock().unwrap().0.clone().expect("unmute opened the output");
assert!(pull(&feed, 256).iter().any(|v| v.abs() > 0.01), "the unmuted tone reaches the output");
// Mute and unmute again without stopping: edits reach the existing feed too.
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "audio", "value": false})).unwrap();
h.step();
assert!(pull(&feed, 256).iter().all(|v| *v == 0.0), "mute silences the current output");
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "audio", "value": true})).unwrap();
h.step();
assert!(pull(&feed, 256).iter().any(|v| v.abs() > 0.01), "a second unmute reaches the same output");
h.state_mut().stop();
}
}
/// Pull only queued frames: underruns are deliberately excluded so the test measures the
/// feeder's composition mapping, not how quickly a loaded test host schedules its thread.
fn take_preview_samples(app: &EffectcraftApp, frames: usize) -> Vec<f32> {
let audio = app.audio.as_ref().expect("preview audio opened");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let mut samples = Vec::with_capacity(frames * 2);
while samples.len() < frames * 2 {
let n = audio.feed.queued_frames().min(frames - samples.len() / 2);
if n == 0 {
assert!(!audio.finished(), "the preview ended before the requested samples");
assert!(std::time::Instant::now() < deadline, "audio feeder timed out");
std::thread::sleep(std::time::Duration::from_millis(1));
continue;
}
let mut block = vec![0.0; n * 2];
audio.feed.pull(&mut block);
samples.extend(block);
}
samples
}
#[test]
fn preview_rate_audio_sounds_at_the_selected_speed_and_finishes_at_the_end() {
use effectcraft_engine::preview::{PlayFrom, PreviewRange};
for (comp_fps, preview_fps, duration_frames) in [(60.0, 30.0, 96_000), (30.0, 60.0, 24_000)] {
let fake = Fake::default();
let (mut app, cid) = setup_long(true, &fake, 1.0);
app.session.execute("comp.settings", json!({"comp": cid.0, "frameRate": comp_fps})).unwrap();
let p = &mut app.session.prefs.preview.spacebar;
p.include_video = false;
p.frame_rate = Some(preview_fps);
p.loop_ = false;
p.range = PreviewRange::EntireDuration;
p.play_from = PlayFrom::RangeStart;
app.play(0.0);
assert!(app.audio.is_some(), "a frame-rate override must not mute the preview");
let samples = take_preview_samples(&app, 12_000);
assert!(samples.iter().any(|v| v.abs() > 0.01));
let audio = app.audio.as_ref().unwrap();
assert_eq!(audio.rate, 48_000, "the physical output rate is unchanged");
assert!((audio.clock_time().seconds() - 0.25 * preview_fps / comp_fps).abs() < 1e-9);
take_preview_samples(&app, duration_frames - 12_000);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while !app.audio.as_ref().unwrap().finished() {
assert!(std::time::Instant::now() < deadline, "one-shot audio did not finish");
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(app.audio.as_ref().unwrap().clock_time().seconds() < 1.0);
app.stop();
}
}
#[test]
fn preview_rate_audio_resamples_the_sound_instead_of_only_changing_the_clock() {
use effectcraft_engine::preview::{PlayFrom, PreviewRange};
let render = |preview_fps, frames| {
let fake = Fake::default();
let (mut app, _) = setup(true, &fake);
let p = &mut app.session.prefs.preview.spacebar;
p.include_video = false;
p.frame_rate = Some(preview_fps);
p.range = PreviewRange::EntireDuration;
p.play_from = PlayFrom::RangeStart;
app.session.prefs.audio.preview_sample_rate = 48_000;
app.play(0.0);
let samples = take_preview_samples(&app, frames);
app.stop();
samples
};
let normal = render(30.0, 2048);
let slow = render(15.0, 4096);
for (original, slowed) in normal.as_chunks::<2>().0.iter().zip(slow.as_chunks::<4>().0) {
assert!((original[0] - slowed[0]).abs() < 1e-5);
assert!((original[1] - slowed[1]).abs() < 1e-5);
}
}
#[test]
fn preview_rate_audio_drives_video_at_half_speed_and_loops() {
let fake = Fake::default();
let (mut app, cid) = setup_long(true, &fake, 1.0);
app.session.execute("comp.settings", json!({"comp": cid.0, "frameRate": 60})).unwrap();
app.session.prefs.preview.spacebar.frame_rate = Some(30.0);
app.session.prefs.preview.spacebar.loop_ = true;
let gate = GatedFrames::default();
app.frames.set_remote(Some(Arc::new(gate.clone())));
let ahead = (app.frames.parallelism() * 2).max(4) as i64;
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app);
h.state_mut().use_manual_clock();
h.run_steps(2);
gate.discard(&mut h);
h.state_mut().play(0.0);
assert!(h.state().playback.starting, "mismatched-rate audio waits with the first picture");
let frames: Vec<i64> = (0..ahead.min(60)).collect();
gate.render(&mut h, cid, &frames);
step(&mut h);
assert!(h.state().audio.is_some());
take_preview_samples(h.state(), 4800);
step(&mut h);
assert_eq!(frame(&h, cid), 3, "0.1 output seconds is 0.05 composition seconds");
take_preview_samples(h.state(), 96_000 - 4800);
step(&mut h);
assert_eq!(frame(&h, cid), 0, "the audio and picture wrap together");
assert!(!h.state().audio.as_ref().unwrap().finished());
h.state_mut().stop();
}
#[test]
fn preview_rate_audio_keeps_the_include_audio_switch_and_skip_independent() {
let fake = Fake::default();
let (mut app, _) = setup(true, &fake);
app.session.prefs.preview.spacebar.include_audio = false;
app.session.prefs.preview.spacebar.frame_rate = Some(60.0);
app.play(0.0);
assert!(app.audio.is_none() && !app.playback.audio_held && !app.playback.starting);
app.stop();
let p = &mut app.session.prefs.preview.spacebar;
p.include_audio = true;
p.include_video = false;
p.skip = 2;
app.play(0.0);
take_preview_samples(&app, 12_000);
assert_eq!(app.audio.as_ref().unwrap().clock_time(), Tick::from_seconds_f64(0.5), "Skip drops pictures, not audio or time");
app.stop();
}
/// Live edits keep the consumed output cursor at either speed, including a nonzero start.
#[test]
fn preview_rate_audio_keeps_its_position_through_live_mute_and_unmute() {
use effectcraft_engine::preview::{PlayFrom, PreviewRange};
for preview_fps in [15.0, 60.0] {
let fake = Fake::default();
let (mut app, cid) = setup(true, &fake);
let comp = app.session.project.comp(cid).unwrap();
let layer = comp.layers[0].id.0;
let start = comp.frame_rate.tick_of(17);
app.session.set_time(start);
let p = &mut app.session.prefs.preview.spacebar;
p.include_video = false;
p.frame_rate = Some(preview_fps);
p.range = PreviewRange::EntireDuration;
p.play_from = PlayFrom::CurrentTime;
app.play(0.0);
assert_eq!(app.audio.as_ref().unwrap().clock_time(), start);
for (edit, audible) in [(None, true), (Some(false), false), (Some(true), true)] {
if let Some(value) = edit {
app.session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "audio", "value": value})).unwrap();
app.audio.as_mut().unwrap().follow_project(app.session.project.clone());
}
let before = app.audio.as_ref().unwrap().clock_time();
let samples = take_preview_samples(&app, 2400);
assert_eq!(samples.iter().any(|v| v.abs() > 0.01), audible);
let elapsed = (app.audio.as_ref().unwrap().clock_time() - before).seconds();
assert!((elapsed - 0.05 * preview_fps / 30.0).abs() < 1e-9, "live edit changed the preview cursor");
}
app.stop();
}
}
+43 -2
View File
@@ -45,18 +45,46 @@ fn timing(h: &Harness<'_, EffectcraftApp>, layer: u64) -> (f64, f64, f64) {
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
drag_with(h, from, to, Modifiers::NONE);
}
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.event(Event::PointerMoved(from));
h.event(Event::ModifiersChanged(modifiers));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
for k in 1..=10 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
/// Shift-dragging a bar end or the bar snaps it to another layer's In / Out point a few pixels
/// away; without Shift it stops on the frame the pointer reached (#1828).
#[test]
fn shift_dragging_a_bar_snaps_to_other_layers() {
for shift in [false, true] {
let (mut h, solid, pre) = harness();
let mods = if shift { Modifiers::SHIFT } else { Modifiers::NONE };
let (b, p) = (bar(&h, solid), bar(&h, pre));
let y = b.center().y;
// The solid's Out end (6 s), grabbed 2 px inside, to 6 px short of the precomp's Out (7 s).
drag_with(&mut h, pos2(b.max.x - 2.0, y), pos2(p.max.x - 8.0, y), mods);
let (i, o, _) = timing(&h, solid);
assert!((i - 2.0).abs() < 1e-6, "shift {shift}: in stays at 2 s, got {i}");
assert_eq!((o - 7.0).abs() < 1e-6, shift, "shift {shift}: out {o} snaps to 7 s only with Shift");
// The whole bar, so its In (2 s) lands 5 px short of the precomp's In (3 s).
let b = bar(&h, solid);
drag_with(&mut h, pos2(b.center().x, y), pos2(b.center().x + p.min.x - b.min.x - 5.0, y), mods);
let (i, _, _) = timing(&h, solid);
assert_eq!((i - 3.0).abs() < 1e-6, shift, "shift {shift}: in {i} snaps to 3 s only with Shift");
}
}
/// Both ends of every bar show the trim cursor, just inside and just outside the end; the middle
/// doesn't. The same holds for a selected layer, a twirled-open layer and a short bar.
fn assert_trim_cursor_at_ends(h: &mut Harness<'_, EffectcraftApp>, layer: u64, case: &str) {
@@ -120,6 +148,19 @@ fn dragging_bar_ends_trims_in_and_out() {
assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}");
}
/// A Shift-drag on an unselected bar adds it to the selection and moves the whole selection
/// together, so the other selected layers keep their offset (#1828).
#[test]
fn shift_dragging_an_unselected_bar_moves_the_selection() {
let (mut h, solid, pre) = harness();
h.state_mut().session.execute("layer.select", json!({"layers": [pre]})).unwrap();
h.run_steps(2);
let b = bar(&h, solid);
drag_with(&mut h, b.center(), b.center() + vec2(b.width() / 4.0 * 2.5, 0.0), Modifiers::SHIFT);
let ((si, ..), (pi, ..)) = (timing(&h, solid), timing(&h, pre));
assert!(si > 4.0 && (pi - si - 1.0).abs() < 1e-6, "both moved by the same amount: solid in {si}, precomp in {pi}");
}
/// With Frames and Frame Count Start at 1, In moves to 00061 but the Duration column still counts
/// the layer's 120 frames (#1807).
#[test]
+82 -5
View File
@@ -198,11 +198,10 @@ fn viewer_click_picks_nothing_while_the_solo_layer_is_inactive() {
assert!(h.state().session.state.selected_layers.is_empty(), "{back:?} stays excluded by solo");
}
/// A selected layer under the pointer takes the press before the layers in front of it, as in
/// After Effects: it stays selected and a drag moves it. With none selected there, the topmost
/// layer does (#230).
/// A click, left or right, selects the topmost layer under the pointer, even over a selected
/// layer behind it (#1535); a drag still moves a selected layer behind others (#230).
#[test]
fn viewer_press_prefers_a_selected_layer_under_the_pointer() {
fn viewer_click_selects_the_topmost_layer_and_a_drag_moves_the_selected_one() {
let mut h = selection_harness();
let comp = h.state().session.active_comp().unwrap();
let (front, back) = (comp.layers[0].id, comp.layers[1].id);
@@ -210,7 +209,20 @@ fn viewer_press_prefers_a_selected_layer_under_the_pointer() {
h.run_steps(3);
let at = screen(&h, [320.0, 180.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![back], "a click keeps the selected layer behind");
assert_eq!(h.state().session.state.selected_layers, vec![front], "a click selects the layer in front of the selected one (#1535)");
// A right-click selects what a left click would, so the menu acts on the layer in front,
// here where both layers cover the pointer and the layer behind is selected.
h.state_mut().session.execute("layer.select", json!({"layers": [back.0]})).unwrap();
h.run_steps(3);
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(2);
assert_eq!(h.state().session.state.selected_layers, vec![front], "a right-click selects the layer in front");
h.key_press(egui::Key::Escape);
h.run_steps(3);
h.state_mut().session.execute("layer.select", json!({"layers": [back.0]})).unwrap();
h.run_steps(40);
let (front0, back0) = (position_and_anchor(&h, front).0, position_and_anchor(&h, back).0);
drag(&mut h, at, at + vec2(60.0, 0.0));
@@ -224,6 +236,71 @@ fn viewer_press_prefers_a_selected_layer_under_the_pointer() {
assert_eq!(h.state().session.state.selected_layers, vec![front], "nothing selected: the topmost layer");
}
/// A click, left or right, on a path of the selected layer, where a layer in front covers it, stays
/// on that path's layer rather than selecting the layer in front, so a double-click there
/// free-transforms the path (#1535 keeps this from `pick`).
#[test]
fn viewer_double_click_on_a_covered_path_of_the_selected_layer_free_transforms_it() {
let mut h = selection_harness();
let comp = h.state().session.active_comp().unwrap();
let back = comp.layers[1].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [back.0]})).unwrap();
// In Back's own space (80×80): its top edge runs through comp (320, 150), under Front.
s.execute("mask.new", json!({"layer": back.0, "vertices": [[10, 10], [70, 10], [70, 70], [10, 70]], "closed": true})).unwrap();
h.run_steps(40);
let at = screen(&h, [320.0, 150.0]);
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
// A right-click there acts on the path's layer too, as a left click does.
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(2);
assert_eq!(h.state().session.state.selected_layers, vec![back], "a right-click on the path keeps its layer");
h.key_press(egui::Key::Escape);
h.run_steps(40);
// Two clicks a frame apart: the first selects as a click does, the second double-clicks.
for _ in 0..2 {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.step();
}
h.run_steps(3);
assert_eq!(h.state().session.state.selected_layers, vec![back], "the path's layer stays selected");
assert_eq!(h.state().session.state.selected_vertices.len(), 4, "all the path's points are selected");
assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_some(), "the free-transform box is up");
}
/// A double-click where a precomp layer covers the selected layer opens the precomp: like a single
/// click, it acts on the topmost layer, which its first click selects (#1535).
#[test]
fn viewer_double_click_opens_the_topmost_layer_over_a_selected_one() {
let mut s = Session::default();
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 80, "height": 80, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Back", "color": "#406080", "width": 200, "height": 200})).unwrap();
s.execute("layer.addItem", json!({"item": "Inner"})).unwrap();
let back = s.active_comp().unwrap().layers[1].id;
s.execute("layer.select", json!({"layers": [back.0]})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.run_steps(40);
let at = screen(&h, [320.0, 180.0]);
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
// Two clicks a frame apart: the first selects as a click does, the second double-clicks.
for _ in 0..2 {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.step();
}
h.run_steps(3);
assert_eq!(h.state().session.active_comp_id().map(|c| c.0), Some(inner), "the precomp in front opens");
}
/// Each composition keeps its Resolution, saved with the project as in After Effects: chosen for
/// comp A in the viewer's popup, it is still A's after showing comp B, costs no undo step, keeps
/// A's cached frames and is read back from the saved file (#417).
+69
View File
@@ -865,6 +865,75 @@ not read or written.
`<comp>` is an id or name, or `-` for the active comp. `<value>` is JSON (`50`, `[960,540]`,
`"#ff0000"`) or a bare string.
## Repro scripts
Every journaled command the app runs (menus, panels, shortcuts, agents, scripts) goes into the session's
command journal. A **repro script** is that journal as JSON lines, so a bug or crash becomes
something an agent can replay and a regression test becomes a data file.
```text
// comments: lines starting with // or #; blank lines are skipped
{"start": "new" | "demo" | {"project": "rel/or/abs.ecproj"}, "allow_errors": false} optional, first line
{"command": "comp.new", "params": {"name": "Main", "frameRate": 30}}
{"command": "comp.settings", "params": {"frameRate": 0}, "expect_error": "frameRate"}
{"expect": {"query": "comp.info", "pointer": "/frameRate", "equals": 30}}
{"expect_no_error": true}
```
- `start`: the project to begin with (default `new`). A relative `project` path is relative to the
script. Other header keys (`version`, `app`, `os`, `crash`, `note`) are for people.
- `command`: run like the app runs it. It must succeed, unless it has `expect_error` (`true`, or a
text the error must contain) or the header says `allow_errors` (errors are reported but
tolerated). A panic always fails the replay.
- `expect`: run a command, normally a query (`prop.get`, `comp.info`, `layer.tree`, `project.summary`),
and compare the value at the JSON `pointer` of its result with `equals` (numbers within 1e-6).
Without `equals` it only checks that the query succeeds.
- `expect_no_error`: the previous command succeeded.
Where scripts come from:
- `journal.export {path?}` returns `{script, steps}` (or writes `path`): the commands since the
project was last replaced (`file.newProject`, `file.open`, `file.openDemoProject`). Queries and
saves are not in it; commands a script or `file.runScript` ran are covered by that one step.
A successful Revert starts a new replay boundary at the saved file it reopened.
- `help.exportRepro {path?}` saves the same for a bug report, by default to `Repros/` in the
settings folder (macOS `~/Library/Application Support/EffectCraft/Repros`, next to `Logs/`).
Confined automation must give an explicit path inside a write root; `journal.export {}`
remains available because it only returns the script in memory.
- **Crash repros**: when the last-resort guard catches a panic in a command, the app writes
`Repros/crash-<unix time>-<command>.jsonl` there automatically (the newest 20 are kept): the
journal, then the command that panicked, with `"allow_errors": true` and the error in the header.
The error the user or agent sees names the file. Headless sessions without a settings folder
(the CLI, tests) don't write one.
Privacy: a repro holds command ids and parameters only, never project, image or media file
contents. Parameters can include file paths, layer and item names and text typed into text
layers, so read a repro before sharing it.
Replay one headless (a fully wired session, like the CLI's other subcommands):
```sh
effectcraft-cli replay crash.jsonl # per-step ok/FAIL lines, then the first failure
effectcraft-cli replay crash.jsonl --json # {ok, panicked, steps, first_failure, skipped}
effectcraft-cli replay crash.jsonl --keep-going
```
Replaying a repro runs its commands with your privileges and file access, including commands
that write files or run scripts. Read repros from untrusted sources before running them, or use
a disposable environment.
It exits with status 1 when a step fails, an expectation is not met or a command panics, and
stops at the first failure unless `--keep-going`. Steps that work on the user's files (`file.open`
of their project, imports) need those files; replace them with a `start` project or generated
content when you turn a report into a test. A repro only sees commands: UI state changed
without one (a hover, a half-finished drag) is not in it.
**Regression tests are data files.** A crash fix's regression test is the repro, trimmed to the
steps that matter, with an `expect_error` or `expect` line where the fix changed the outcome,
dropped into [`tests/repros/`](../tests/repros). `crates/host/tests/it/repros.rs` replays every
`*.jsonl` there and fails on any panic or unmet expectation
(`cargo nextest run -p effectcraft-host --test it -E 'test(/^repros::/)'`).
## Seeing the UI
To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` (add `--demo` to
+76 -5
View File
@@ -3,6 +3,7 @@
```sh
cargo test --workspace # everything
cargo xtask ci # fmt, clippy -D warnings, tests, pdf-deps, lock-patches, layers, assets, wasm
cargo xtask ci --affected # the same gates, scoped to crates your change can affect
```
The After Effects test projects of `crates/aep` are not in the repository; without them the
@@ -32,6 +33,47 @@ Each crate with several integration-test files has one test binary, `tests/it/ma
modules are the files in `tests/it/`. Run one file with `cargo test -p <crate> --test it <module>::`
or `cargo nextest run -p <crate> -E 'binary(it) and test(/^<module>::/)'`.
## Affected-only CI
Use `cargo xtask ci --affected` to run the gates for the crates a change can affect. The full
`cargo xtask ci` remains the required check before a release and when affected mode falls back
because a workspace-wide input changed.
```sh
cargo xtask ci --affected # base: the merge-base of HEAD with main
cargo xtask ci --affected --base HEAD~3 # any git revision
cargo xtask ci --affected --dry-run # print the plan only
```
It collects files that differ from the base (committed, staged, unstaged and untracked), preserving
Unicode and whitespace in their names. Each file maps to the workspace package whose directory
holds it and every package whose sources point at it with a `../` literal, such as
`include_bytes!("../../../assets/fonts/Inter-Regular.ttf")` or
`concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/preferences.md")`. Deleted and renamed inputs
still select their readers, including when the input's entire directory no longer exists.
The affected set is those packages plus everything that depends on them (transitively through
normal and build dependencies) and every package that uses one of them as a dev-dependency.
Clippy and tests use the same dev profile as full CI. Both modes use the same test runner:
nextest plus doctests when installed, otherwise cargo test plus doctests. GPU tests run in one
libtest process, and the translation report reruns with visible output when ui-egui is affected.
The audio-backend probe runs before building affected crates.
Formatting, PDF dependency policy, lock-patch and layer checks always run. Assets are checked
when an asset, an `.attribution` sidecar or `ATTRIBUTION.md` changes. The wasm check selects
affected L0–L4 and web crates. Every step runs even when an earlier step fails; the final result
names failed steps and includes timings.
Changes to the root `Cargo.toml`, `Cargo.lock`, `.cargo/`, `xtask/`, `rust-toolchain(.toml)`,
`clippy.toml` or `.config/nextest.toml` can change every crate's build or test policy, so they run
the full CI instead and say why. Changes no crate reads (most of `docs/`, `README.md`, `.github/`,
`packaging/`) only get the cheap checks.
Limits: a crate that reads a repo file at run time through a path that is not a `../` literal in its
sources is not detected, and building a subset of packages can unify features differently from the
whole workspace. Non-UTF-8 Git paths stop the command rather than silently omitting checks.
Run the full `cargo xtask ci` before a release.
## What the tests cover
- **Engine commands** (`crates/engine/src/tests*.rs`): every command family, including undo and
@@ -123,16 +165,45 @@ A teardown panic leaves reporting armed. An abort or other unexpected process te
closes the pipe without that marker.
The helper saves a local report under the session log folder's `Crash Reports` directory and
shows a native error dialog. Control-server launches save the report without a modal dialog.
Reports contain version/platform and at most the last 64 KiB of the matching session's log.
Logging filters and the session file size cap can omit later events from that tail.
Reports contain version/platform, UTC detection and observer-start times, observed duration,
GUI uptime at the last target-present sample, and at most the last 64 KiB of the matching
session's log. A separate ring retains the last 32 timestamped, fixed-vocabulary backend and
GPU observations even if startup messages have fallen out of the log tail. `WGPU_BACKEND`
values are normalized to known backend names; arbitrary environment text is not copied.
Logging filters and the session file size cap can omit later events from the log tail.
They are never uploaded automatically. Review logs for private project paths before sharing.
On Windows the helper acquires a process handle before acknowledging readiness and retains
it until the GUI exits. It records the real OS exit status in unsigned hexadecimal, with a
small lookup of known status names. These bits are **not a verified exception record**:
software can explicitly set the same code, and an access violation does not identify the
faulting module or prove a driver crash. Other platforms report exit status as unavailable.
A lost pipe while the GUI is still present, or a failed/absent process refresh, is not reported
as confirmed termination.
The helper samples only the target process and system RAM at roughly one-second intervals,
retaining a count, last-attempt time, sampled resident-memory maximum, Windows private-commit
maximum (virtual memory on other platforms), and system available-RAM minimum. This is a
fixed-size summary, not a memory dump. OS query failure can leave stale or zero values, and
sampling misses short spikes. System commit limits/usage and GPU VRAM budgets are not captured.
No threshold is treated as proof of memory exhaustion. Typed wgpu OOM, internal/validation
errors and device-loss notifications are observations; device destruction is distinguished
from unknown loss, and neither establishes why the process later exited.
Run `cargo test -p effectcraft crash_report::tests` for bounded protocol, normal shutdown,
actual subprocess abort, unrelated child handles, concurrent sessions, missing log/helper,
log rotation and report-write failure coverage. The three ignored tests are subprocess entry
log rotation, bounded retained events, live-GUI pipe loss, and report-write failure coverage.
Windows also tests a real subprocess exit with explicit `0xC0000017` bits, without exhausting
memory or pretending that the exit was an actual OOM exception. The three ignored tests are subprocess entry
points launched by these tests, not manual tests.
This is diagnosis, not memory recovery: a pipe closing does not prove an out-of-memory failure
or provide the process's exit code. System-wide exhaustion, killing the whole process tree,
These are observations, not memory recovery or a causal diagnosis: a pipe closing does not
prove an out-of-memory failure. System-wide exhaustion, killing the whole process tree,
or an unavailable native dialog service can also prevent the helper from reporting. Existing
auto-save recovery remains responsible for restoring previously saved work.
Native exception records, faulting-module attribution and driver/TDR confirmation require
separately collected OS event evidence or a native dump. They are deliberately not gathered
here: dumps can contain document contents and credentials. Any future dump workflow must be
explicitly opt-in, bounded, local-first and separately reviewed before sharing. This reporter
must remain useful when those sources are unavailable.
+13
View File
@@ -0,0 +1,13 @@
// comp.new / comp.settings took a frameRate of 0 (or below 0.0005) as a 0/1 rate: the next
// frame-duration computation divided by zero and killed the process with the unsaved project
// (fixed in 780419c, "Compositions: reject zero and negative frame rates instead of panicking").
{"start": "new"}
{"command": "comp.new", "params": {"name": "Zero", "frameRate": 0}, "expect_error": "frameRate"}
{"command": "comp.new", "params": {"name": "Tiny", "frameRate": 0.0001}, "expect_error": "frameRate"}
{"command": "comp.new", "params": {"name": "Negative", "frameRate": -24}, "expect_error": "frameRate"}
{"command": "comp.new", "params": {"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 1}}
{"command": "comp.settings", "params": {"frameRate": 0}, "expect_error": "frameRate"}
{"expect": {"query": "comp.info", "pointer": "/frameRate", "equals": 30}}
{"command": "layer.newSolid", "params": {"color": "#ff0000"}}
{"command": "time.set", "params": {"time": 0.5}}
{"expect_no_error": true}
@@ -0,0 +1,8 @@
// Layer Settings' enablement read the first selected layer with `selected_layers[0]`: with layers
// in the comp but none selected it indexed an empty list and panicked (fixed in b21e04b, M4.12).
{"start": "demo"}
{"command": "edit.deselectAll"}
{"command": "layer.settings", "expect_error": "select a layer"}
{"command": "layer.newSolid", "params": {"color": "#3366ff", "name": "Blue"}}
{"command": "layer.settings", "params": {"name": "Renamed"}}
{"expect_no_error": true}
@@ -0,0 +1,17 @@
// File > New Comp from Selection with Sequence Layers: a huge `overlapDuration` times the layer
// count overflowed the duration arithmetic (a panic with overflow checks, a wrapped, wrong comp
// length without them), and `dimensionsFrom` past the selection indexed out of bounds
// (fixed in b21e04b, M4.12).
{"start": "new"}
{"command": "comp.new", "params": {"name": "Holder", "width": 64, "height": 64, "frameRate": 30, "duration": 2}}
{"command": "layer.newSolid", "params": {"color": "#ff0000", "name": "a"}}
{"command": "layer.newSolid", "params": {"color": "#00ff00", "name": "b"}}
{"command": "layer.newSolid", "params": {"color": "#0000ff", "name": "c"}}
{"command": "project.select", "params": {"items": ["a", "b", "c"]}}
{"command": "file.newCompFromSelection", "params": {"single": true, "sequence": true, "overlap": true, "overlapDuration": 1e30, "duration": 2}}
{"expect": {"query": "comp.info", "pointer": "/width", "equals": 64}}
{"command": "project.select", "params": {"items": ["a", "b", "c"]}}
{"command": "file.newCompFromSelection", "params": {"single": true, "sequence": true, "overlap": true, "overlapDuration": -5, "duration": 2}}
{"expect": {"query": "comp.info", "pointer": "/duration", "equals": 4}}
{"command": "project.select", "params": {"items": ["a", "b"]}}
{"command": "file.newCompFromSelection", "params": {"single": true, "dimensionsFrom": 99}, "expect_error": "dimensionsFrom"}
+766
View File
@@ -0,0 +1,766 @@
//! `cargo xtask ci --affected [--base REV] [--dry-run]`: the CI gates, run only for the crates a
//! change can affect (docs/testing.md).
//!
//! 1. Changed files: everything that differs from the base (default: the merge-base with `main`),
//! committed, staged, unstaged or untracked.
//! 2. Files map to the workspace package whose directory holds them, and to every package whose
//! sources point at them with a `../` path (`include_bytes!("../../../assets/fonts/…")`,
//! `env!("CARGO_MANIFEST_DIR")` + `"/../../docs/effects.md"`, …).
//! 3. Affected = those packages, every package that depends on them (transitively, through
//! normal and build dependencies) and every package that has one of them as a dev-dependency.
//! 4. Workspace-wide inputs (root `Cargo.toml`, `Cargo.lock`, `.cargo/`, `xtask/`, the toolchain
//! file, `clippy.toml`, `.config/nextest.toml`) fall back to the full `cargo xtask ci`.
use std::collections::BTreeSet;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use serde_json::Value;
/// A workspace package, as far as change mapping cares.
#[derive(Debug, Clone, Default)]
pub struct Pkg {
pub name: String,
/// Package directory relative to the workspace root, `/`-separated, no trailing slash.
pub dir: String,
/// Workspace (path) dependencies: (package name, is a dev-dependency).
pub deps: Vec<(String, bool)>,
/// Has a library target (`--lib` is valid even when doctests are disabled).
pub has_lib: bool,
/// Has a library target with doctests (`cargo test --doc` runs for it).
pub has_doctests: bool,
/// Repo paths outside `dir` that the sources read (files or directories).
pub inputs: Vec<String>,
}
/// Changes to these force the full CI: they can change every crate's build or test policy.
pub fn full_ci_reason(path: &str) -> Option<&'static str> {
match path {
"Cargo.toml" => Some("workspace Cargo.toml changed"),
"Cargo.lock" => Some("Cargo.lock changed"),
"rust-toolchain" | "rust-toolchain.toml" => Some("toolchain changed"),
"clippy.toml" => Some("clippy.toml changed"),
// Test selection and required runner versions apply to the whole workspace.
".config/nextest.toml" => Some("nextest configuration changed"),
_ if path.starts_with(".cargo/") => Some(".cargo/ changed"),
_ if path.starts_with("xtask/") => Some("xtask changed"),
_ => None,
}
}
/// Files the `assets` check looks at (see `assets()` in main.rs).
pub fn is_asset_related(path: &str) -> bool {
path == "ATTRIBUTION.md" || path.ends_with(".attribution") || crate::is_asset(path)
}
fn under(path: &str, dir: &str) -> bool {
dir.is_empty() || path == dir || path.strip_prefix(dir).is_some_and(|r| r.starts_with('/'))
}
/// The package whose directory holds `path` (the innermost one).
pub fn owner<'a>(path: &str, pkgs: &'a [Pkg]) -> Option<&'a Pkg> {
pkgs.iter().filter(|p| !p.dir.is_empty() && under(path, &p.dir)).max_by_key(|p| p.dir.len())
}
/// Packages whose sources read `path` from outside their own directory.
pub fn readers<'a>(path: &'a str, pkgs: &'a [Pkg]) -> impl Iterator<Item = &'a Pkg> + 'a {
pkgs.iter().filter(move |p| p.inputs.iter().any(|i| under(path, i)))
}
/// `direct` plus everything that depends on it: transitively through normal/build edges, then
/// one hop through dev-dependency edges (a dev-dependent's tests rerun, its dependents don't).
pub fn expand(direct: &BTreeSet<String>, pkgs: &[Pkg]) -> BTreeSet<String> {
let mut out = direct.clone();
let mut stack: Vec<String> = direct.iter().cloned().collect();
while let Some(n) = stack.pop() {
for p in pkgs {
if !out.contains(&p.name) && p.deps.iter().any(|(d, dev)| !dev && *d == n) {
out.insert(p.name.clone());
stack.push(p.name.clone());
}
}
}
let dev: Vec<String> = pkgs.iter().filter(|p| p.deps.iter().any(|(d, dev)| *dev && out.contains(d))).map(|p| p.name.clone()).collect();
out.extend(dev);
out
}
/// What `ci --affected` will do for a set of changed files.
#[derive(Debug, Default, PartialEq)]
pub struct Plan {
/// Set when the full CI must run instead.
pub full: Option<String>,
/// Packages that hold or read a changed file.
pub direct: BTreeSet<String>,
/// `direct` and its dependents: clippy and tests run for these.
pub affected: BTreeSet<String>,
/// Whether the assets check runs.
pub assets: bool,
/// Changed files no package reads (docs, CI configs, packaging…).
pub unmapped: Vec<String>,
}
pub fn plan(files: &[String], pkgs: &[Pkg]) -> Plan {
let mut plan = Plan::default();
for f in files {
if let Some(why) = full_ci_reason(f) {
plan.full = Some(format!("{why} ({f})"));
return plan;
}
plan.assets |= is_asset_related(f);
let mut hit = false;
for p in owner(f, pkgs).into_iter().chain(readers(f, pkgs)) {
plan.direct.insert(p.name.clone());
hit = true;
}
if !hit {
plan.unmapped.push(f.clone());
}
}
plan.affected = expand(&plan.direct, pkgs);
plan
}
/// Normalise `base/rel` (both `/`-separated, relative to the repo root). None if it leaves the repo.
pub fn join_rel(base: &str, rel: &str) -> Option<String> {
let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty() && *s != ".").collect();
for c in rel.split('/') {
match c {
"" | "." => {}
".." => {
parts.pop()?;
}
c => parts.push(c),
}
}
Some(parts.join("/"))
}
/// The `../…` paths in a line of Rust: the part of each string literal from its first `../`,
/// cut back to a directory when a format placeholder follows (`"{}/../model/fixtures/{n}"`).
pub fn rel_paths(line: &str) -> Vec<String> {
let mut out = vec![];
for lit in line.split('"').skip(1).step_by(2) {
let Some(i) = lit.find("../") else { continue };
let Some(rest) = lit.get(i..) else { continue };
let p = match rest.find('{') {
Some(j) => rest.get(..j).and_then(|s| s.rfind('/').and_then(|k| s.get(..k))).unwrap_or(""),
None => rest,
};
let p = p.trim_end_matches('/');
if !p.is_empty() {
out.push(p.to_string());
}
}
out
}
/// Repo paths outside `pkg_dir` that a source file at `file` (both repo-relative) points at.
/// A literal may be relative to the file (`include_bytes!`) or to the manifest dir
/// (`CARGO_MANIFEST_DIR`), so both are tried. Keep existing paths and paths containing a change,
/// including deleted files and directories that no longer exist in the working tree.
pub fn inputs_of(file: &str, pkg_dir: &str, text: &str, known_input: &dyn Fn(&str) -> bool) -> BTreeSet<String> {
let file_dir = file.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
let mut out = BTreeSet::new();
for line in text.lines().filter(|l| l.contains("../")) {
let t = line.trim_start();
if t.starts_with("//") && !t.starts_with("///") && !t.starts_with("//!") {
continue;
}
for rel in rel_paths(line) {
for base in [file_dir, pkg_dir] {
let Some(p) = join_rel(base, &rel) else { continue };
if p.is_empty() || p == "target" || p.starts_with("target/") || under(&p, pkg_dir) || !known_input(&p) {
continue;
}
out.insert(p);
}
}
}
out
}
fn rs_files(dir: &Path, out: &mut Vec<std::path::PathBuf>, depth: u32) {
if depth > 32 {
return;
}
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
let name = e.file_name();
if p.is_dir() {
if name != "target" && name != ".git" {
rs_files(&p, out, depth + 1);
}
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
fn rel_to(root: &Path, p: &Path) -> String {
p.strip_prefix(root).unwrap_or(p).components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/")
}
/// Workspace packages (xtask excluded) with their path dependencies and outside inputs.
pub fn packages(root: &Path, md: &Value, changed: &[String]) -> Vec<Pkg> {
let ws = md["workspace_root"].as_str().map(Path::new).unwrap_or(root);
let names: BTreeSet<&str> = md["packages"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect();
let mut pkgs = vec![];
for p in md["packages"].as_array().into_iter().flatten() {
let Some(name) = p["name"].as_str().filter(|n| *n != "xtask") else { continue };
let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent()) else { continue };
let deps = p["dependencies"]
.as_array()
.into_iter()
.flatten()
.filter(|d| !d["path"].is_null())
.filter_map(|d| d["name"].as_str().filter(|n| names.contains(n)).map(|n| (n.to_string(), d["kind"] == "dev")))
.collect();
let lib_kind = |k: &Value| ["lib", "rlib", "dylib", "cdylib", "staticlib", "proc-macro"].iter().any(|l| k == *l);
let libs: Vec<&Value> = p["targets"].as_array().into_iter().flatten().filter(|t| t["kind"].as_array().into_iter().flatten().any(lib_kind)).collect();
let has_lib = !libs.is_empty();
let has_doctests = libs.iter().any(|t| t["doctest"] != false);
let rel = rel_to(ws, dir);
let mut files = vec![];
rs_files(dir, &mut files, 0);
let known_input = |p: &str| ws.join(p).exists() || changed.iter().any(|f| under(f, p));
let mut inputs = BTreeSet::new();
for f in files {
if let Ok(text) = std::fs::read_to_string(&f) {
inputs.extend(inputs_of(&rel_to(ws, &f), &rel, &text, &known_input));
}
}
pkgs.push(Pkg { name: name.to_string(), dir: rel, deps, has_lib, has_doctests, inputs: inputs.into_iter().collect() });
}
pkgs
}
fn git(root: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git").arg("-C").arg(root).args(args).output().map_err(|e| format!("git: {e}"))?;
if !out.status.success() {
return Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
}
String::from_utf8(out.stdout).map_err(|e| format!("git {}: output is not UTF-8; cannot safely map changed paths: {e}", args.join(" ")))
}
/// The base commit: `--base REV` or the merge-base of HEAD with `main` (or `origin/main`).
fn base(root: &Path, rev: Option<&str>) -> Result<(String, String), String> {
if let Some(r) = rev {
let sha = git(root, &["rev-parse", "--verify", &format!("{r}^{{commit}}")])?;
return Ok((sha.trim().to_string(), r.to_string()));
}
for m in ["main", "origin/main"] {
if let Ok(sha) = git(root, &["merge-base", "HEAD", m]) {
return Ok((sha.trim().to_string(), format!("merge-base with {m}")));
}
}
Err("no merge-base with main or origin/main; pass --base REV".into())
}
/// Committed since `base`, staged, unstaged and untracked files (repo-relative, sorted).
fn changed_files(root: &Path, base: &str) -> Result<Vec<String>, String> {
let diff = git(root, &["diff", "--name-only", "--no-renames", "-z", base, "--"])?;
let untracked = git(root, &["ls-files", "--others", "--exclude-standard", "-z"])?;
// Git otherwise C-quotes Unicode, tabs and newlines. Whitespace is part of the filename.
let set: BTreeSet<String> = diff.split('\0').chain(untracked.split('\0')).filter(|p| !p.is_empty()).map(str::to_string).collect();
Ok(set.into_iter().collect())
}
fn short_list(names: &BTreeSet<String>) -> String {
if names.is_empty() { "none".into() } else { names.iter().map(|n| crate::short(n)).collect::<Vec<_>>().join(", ") }
}
fn first_few(files: &[String]) -> String {
let mut s = files.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
if files.len() > 3 {
s.push_str(&format!(", … (+{})", files.len() - 3));
}
s
}
/// One CI step: label and the cargo invocation (None = an in-process check).
struct Step {
label: String,
cmd: Option<Command>,
check: Option<Box<dyn Fn() -> Result<(), String>>>,
}
fn cargo(args: &[&str], pkgs: &[&String], tail: &[&str]) -> Command {
let mut c = Command::new(env!("CARGO"));
c.args(args);
for p in pkgs {
c.args(["-p", p.as_str()]);
}
c.args(tail);
c
}
/// Library tests and doctests are independent: a library can opt out of doctests.
fn test_targets(plan: &Plan, pkgs: &[Pkg]) -> (bool, bool) {
pkgs.iter().filter(|p| plan.affected.contains(&p.name)).fold((false, false), |(lib, docs), p| (lib || p.has_lib, docs || p.has_doctests))
}
fn steps(plan: &Plan, pkgs: &[Pkg], nextest: bool) -> Vec<Step> {
let mut v = vec![Step { label: "fmt".into(), cmd: Some(cargo(&["fmt", "--check"], &[], &[])), check: None }];
let aff: Vec<&String> = plan.affected.iter().collect();
if !aff.is_empty() {
v.push(Step { label: "audio backend".into(), cmd: None, check: Some(Box::new(crate::audio_backend)) });
let n = aff.len();
v.push(Step { label: format!("clippy ({n})"), cmd: Some(cargo(&["clippy", "--all-targets"], &aff, &["--", "-D", "warnings"])), check: None });
let (has_lib, doctests) = test_targets(plan, pkgs);
let selected: Vec<String> = aff.iter().map(|p| p.to_string()).collect();
v.push(Step { label: format!("tests ({n})"), cmd: None, check: Some(Box::new(move || crate::tests_for(&selected, nextest, has_lib, doctests))) });
}
v.push(Step { label: "pdf-deps".into(), cmd: None, check: Some(Box::new(|| crate::pdf_deps::run(&crate::root()))) });
v.push(Step { label: "lock-patches".into(), cmd: None, check: Some(Box::new(|| crate::lock_patches::run(&crate::root()))) });
v.push(Step { label: "layers".into(), cmd: None, check: Some(Box::new(crate::layers)) });
if plan.assets {
v.push(Step { label: "assets".into(), cmd: None, check: Some(Box::new(crate::assets)) });
}
let wasm: Vec<&String> = aff.iter().copied().filter(|a| crate::layer_of(a).is_some_and(|l| l <= 4) || crate::WEB_CRATES.contains(&a.as_str())).collect();
if !wasm.is_empty() {
v.push(Step { label: format!("wasm ({})", wasm.len()), cmd: Some(cargo(&["check", "--target", "wasm32-unknown-unknown"], &wasm, &[])), check: None });
}
v
}
fn fmt_dur(d: Duration) -> String {
let s = d.as_secs();
if s >= 60 { format!("{}m{:02}s", s / 60, s % 60) } else { format!("{:.1}s", d.as_secs_f64()) }
}
fn summary(times: &[(String, Duration, bool)]) {
let total: Duration = times.iter().map(|t| t.1).sum();
eprintln!("\nci --affected timing:");
for (label, d, ok) in times {
eprintln!(" {:<16} {:>8} {}", label, fmt_dur(*d), if *ok { "ok" } else { "FAILED" });
}
eprintln!(" {:<16} {:>8}", "total", fmt_dur(total));
}
pub fn run(root: &Path, args: &[String]) -> Result<(), String> {
let dry = args.iter().any(|a| a == "--dry-run");
let rev = match args.iter().position(|a| a == "--base") {
Some(i) => Some(args.get(i + 1).map(String::as_str).ok_or("--base needs a revision")?),
None => None,
};
let (sha, why) = base(root, rev)?;
let files = changed_files(root, &sha)?;
let md = crate::metadata()?;
let pkgs = packages(root, &md, &files);
let plan = plan(&files, &pkgs);
let short_sha = sha.get(..9).unwrap_or(&sha);
println!("ci --affected: base {short_sha} ({why}), {} changed file(s)", files.len());
if let Some(reason) = &plan.full {
println!(" full CI: {reason}");
if dry {
return Ok(());
}
let t = Instant::now();
let r = crate::ci();
summary(&[("full ci".into(), t.elapsed(), r.is_ok())]);
return r;
}
println!(" changed: {}", short_list(&plan.direct));
println!(" affected: {} ({} of {} crates)", short_list(&plan.affected), plan.affected.len(), pkgs.len());
if !plan.unmapped.is_empty() {
println!(" read by no crate: {}", first_few(&plan.unmapped));
}
let nextest = Command::new(env!("CARGO")).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success());
let steps = steps(&plan, &pkgs, nextest);
let labels: Vec<&str> = steps.iter().map(|s| s.label.as_str()).collect();
println!(" steps: {}{}", labels.join(", "), if plan.assets { "" } else { " (assets skipped: no asset changes)" });
if dry {
return Ok(());
}
let mut times = vec![];
let mut failed = vec![];
for mut s in steps {
let t = Instant::now();
let r = match (s.cmd.as_mut(), s.check) {
(Some(c), _) => crate::run(c),
(None, Some(f)) => f(),
(None, None) => Ok(()),
};
if let Err(e) = &r {
eprintln!("ci --affected: {} failed: {e}", s.label);
failed.push(s.label.clone());
}
times.push((s.label, t.elapsed(), r.is_ok()));
}
summary(&times);
if failed.is_empty() { Ok(()) } else { Err(format!("ci --affected failed: {}", failed.join(", "))) }
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, dir: &str, deps: &[(&str, bool)], inputs: &[&str]) -> Pkg {
Pkg {
name: name.into(),
dir: dir.into(),
deps: deps.iter().map(|(d, dev)| (d.to_string(), *dev)).collect(),
has_lib: true,
has_doctests: true,
inputs: inputs.iter().map(|s| s.to_string()).collect(),
}
}
/// time ← render ← engine ← ui ← app; host has engine as a dev-dependency; text reads fonts.
fn ws() -> Vec<Pkg> {
vec![
pkg("effectcraft-time", "crates/time", &[], &[]),
pkg("effectcraft-text", "crates/text", &[], &["assets/fonts"]),
pkg("effectcraft-render", "crates/render", &[("effectcraft-time", false), ("effectcraft-text", false)], &[]),
pkg("effectcraft-engine", "crates/engine", &[("effectcraft-render", false)], &["docs/preferences.md"]),
pkg("effectcraft-host", "crates/host", &[("effectcraft-time", false), ("effectcraft-engine", true)], &["crates/svg/tests/fixtures"]),
pkg("effectcraft-ui-egui", "crates/ui-egui", &[("effectcraft-engine", false)], &[]),
pkg("effectcraft", "apps/effectcraft", &[("effectcraft-ui-egui", false)], &[]),
pkg("effectcraft-cli", "apps/effectcraft-cli", &[("effectcraft", false)], &[]),
pkg("effectcraft-svg", "crates/svg", &[], &[]),
]
}
fn set(v: &[&str]) -> BTreeSet<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn files(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn file_maps_to_innermost_package() {
let mut p = ws();
p.push(pkg("nested", "crates/time/nested", &[], &[]));
assert_eq!(owner("crates/time/src/lib.rs", &p).map(|p| p.name.as_str()), Some("effectcraft-time"));
assert_eq!(owner("crates/time/nested/src/lib.rs", &p).map(|p| p.name.as_str()), Some("nested"));
// a prefix of the dir name is not a match
assert_eq!(owner("crates/timeline/src/lib.rs", &p).map(|p| p.name.as_str()), None);
assert_eq!(owner("apps/effectcraft-cli/src/main.rs", &p).map(|p| p.name.as_str()), Some("effectcraft-cli"));
assert_eq!(owner("apps/effectcraft/src/main.rs", &p).map(|p| p.name.as_str()), Some("effectcraft"));
assert!(owner("docs/testing.md", &p).is_none());
}
#[test]
fn reverse_deps_follow_normal_edges_and_one_dev_hop() {
let p = ws();
// time: render, engine, ui, app, cli via normal edges; host directly; host is not
// a dependency of anything.
let a = expand(&set(&["effectcraft-time"]), &p);
assert_eq!(
a,
set(&["effectcraft-time", "effectcraft-render", "effectcraft-engine", "effectcraft-host", "effectcraft-ui-egui", "effectcraft", "effectcraft-cli"])
);
// ui-egui: only its dependents
assert_eq!(expand(&set(&["effectcraft-ui-egui"]), &p), set(&["effectcraft-ui-egui", "effectcraft", "effectcraft-cli"]));
// engine: host only through its dev-dependency, and nothing beyond host
let e = expand(&set(&["effectcraft-engine"]), &p);
assert!(e.contains("effectcraft-host"));
assert!(!e.contains("effectcraft-time") && !e.contains("effectcraft-render"));
assert_eq!(expand(&set(&["effectcraft-cli"]), &p), set(&["effectcraft-cli"]));
assert!(expand(&BTreeSet::new(), &p).is_empty());
}
#[test]
fn workspace_inputs_fall_back_to_full_ci() {
let p = ws();
for f in ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "xtask/src/main.rs", "rust-toolchain.toml", "clippy.toml"] {
let pl = plan(&files(&["crates/time/src/lib.rs", f]), &p);
assert!(pl.full.is_some(), "{f}");
}
// a crate manifest is not workspace-wide
assert_eq!(plan(&files(&["crates/time/Cargo.toml"]), &p).full, None);
assert_eq!(full_ci_reason("docs/xtask.md"), None);
}
#[test]
fn nextest_policy_alone_requires_full_ci() {
let pl = plan(&files(&[".config/nextest.toml"]), &ws());
assert_eq!(pl.full.as_deref(), Some("nextest configuration changed (.config/nextest.toml)"));
// Only the actual shared test input triggers this fallback, not every config file.
assert_eq!(full_ci_reason(".config/editor.toml"), None);
assert_eq!(full_ci_reason("docs/nextest.toml"), None);
}
#[test]
fn docs_only_changes_affect_nothing() {
let pl = plan(&files(&["docs/gaps.md", "README.md", ".github/workflows/release.yml"]), &ws());
assert_eq!(pl.full, None);
assert!(pl.affected.is_empty());
assert!(!pl.assets);
assert_eq!(pl.unmapped.len(), 3);
}
#[test]
fn files_read_from_outside_map_to_their_readers() {
let p = ws();
// docs/preferences.md is read by an engine test
let pl = plan(&files(&["docs/preferences.md"]), &p);
assert_eq!(pl.direct, set(&["effectcraft-engine"]));
assert!(pl.unmapped.is_empty());
// a font: read by text (directory input), and an asset
let pl = plan(&files(&["assets/fonts/Inter-Regular.ttf"]), &p);
assert_eq!(pl.direct, set(&["effectcraft-text"]));
assert!(pl.assets);
assert!(pl.affected.contains("effectcraft-cli"));
// svg fixtures belong to svg and are read by host
let pl = plan(&files(&["crates/svg/tests/fixtures/basic-shapes.svg"]), &p);
assert_eq!(pl.direct, set(&["effectcraft-svg", "effectcraft-host"]));
}
#[test]
fn asset_rules() {
assert!(is_asset_related("ATTRIBUTION.md"));
assert!(is_asset_related("crates/engine/scripts/Rename Layers.jsx.attribution"));
assert!(is_asset_related("docs/brand/artcraft-mark.png"));
assert!(!is_asset_related("docs/testing.md"));
assert!(!is_asset_related("crates/time/src/lib.rs"));
}
#[test]
fn rel_path_extraction() {
assert_eq!(rel_paths(r#"static F: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");"#), ["../../../assets/fonts/Inter-Regular.ttf"]);
assert_eq!(rel_paths(r#"let p = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/effects.md");"#), ["../../docs/effects.md"]);
assert_eq!(rel_paths(r#"format!("{}/../model/tests/fixtures/{n}", env!("CARGO_MANIFEST_DIR"))"#), ["../model/tests/fixtures"]);
assert_eq!(rel_paths(r#"let a = "x"; let b = "../y/z{n}.png";"#), ["../y"]);
assert!(rel_paths(r#"let s = "no parent here";"#).is_empty());
}
#[test]
fn rel_path_joining() {
assert_eq!(join_rel("crates/text/src", "../../../assets/fonts/A.ttf").as_deref(), Some("assets/fonts/A.ttf"));
assert_eq!(join_rel("crates/host", "../model/./tests").as_deref(), Some("crates/model/tests"));
assert_eq!(join_rel("crates", "../../outside"), None);
}
#[test]
fn inputs_try_file_and_manifest_dirs() {
let existing = set(&["assets/fonts/Inter-Regular.ttf", "docs/effects.md", "crates/model/tests/fixtures", "crates/svg/tests/fixtures/a.svg"]);
let exists = |p: &str| existing.contains(p);
let src = r#"
static F: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let p = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/effects.md");
let fx = format!("{}/../model/tests/fixtures/{n}", env!("CARGO_MANIFEST_DIR"));
let own = include_bytes!("../tests/fixtures/own.svg");
let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out");
// let commented = include_bytes!("../../svg/tests/fixtures/a.svg");
"#;
let got = inputs_of("crates/host/src/lib.rs", "crates/host", src, &exists);
assert_eq!(got, set(&["assets/fonts/Inter-Regular.ttf", "docs/effects.md", "crates/model/tests/fixtures"]));
// integration tests in tests/ resolve include_bytes! against tests/
let got = inputs_of("crates/host/tests/v.rs", "crates/host", r#"include_bytes!("../../svg/tests/fixtures/a.svg")"#, &exists);
assert_eq!(got, set(&["crates/svg/tests/fixtures/a.svg"]));
}
fn committed_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
git(root, &["init", "-q"]).unwrap();
git(root, &["config", "core.quotepath", "true"]).unwrap();
std::fs::write(root.join("README.md"), "fixture\n").unwrap();
commit_fixture(root);
dir
}
fn commit_fixture(root: &Path) {
git(root, &["add", "--all"]).unwrap();
git(root, &["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "--no-verify", "-qm", "fixture"])
.unwrap();
}
#[test]
fn changed_files_preserve_unicode_in_tracked_and_untracked_paths() {
let dir = committed_repo();
let root = dir.path();
let tracked = "crates/time/src/caf\u{e9}.rs";
let untracked = "crates/time/src/\u{65b0}\u{898f}.rs";
std::fs::create_dir_all(root.join("crates/time/src")).unwrap();
std::fs::write(root.join(tracked), "before").unwrap();
commit_fixture(root);
std::fs::write(root.join(tracked), "after").unwrap();
std::fs::write(root.join(untracked), "new").unwrap();
let changed = changed_files(root, "HEAD").unwrap();
assert_eq!(changed, files(&[tracked, untracked]));
assert_eq!(plan(&changed, &ws()).direct, set(&["effectcraft-time"]));
}
#[cfg(unix)]
#[test]
fn changed_files_preserve_whitespace_quotes_and_backslashes() {
let dir = committed_repo();
let root = dir.path();
let tracked = " tracked\t\"\\name\n ";
let untracked = " untracked\t\"\\name\n ";
std::fs::write(root.join(tracked), "before").unwrap();
commit_fixture(root);
std::fs::write(root.join(tracked), "after").unwrap();
std::fs::write(root.join(untracked), "new").unwrap();
assert_eq!(changed_files(root, "HEAD").unwrap(), files(&[tracked, untracked]));
}
// macOS (APFS) refuses file names that aren't UTF-8 ("Illegal byte sequence"), so there the
// fixture can't be made and such a change can't happen.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn changed_files_reject_non_utf8_instead_of_silently_omitting_it() {
use std::os::unix::ffi::OsStringExt;
let dir = committed_repo();
std::fs::write(dir.path().join(std::ffi::OsString::from_vec(b"invalid-\xff.rs".to_vec())), "new").unwrap();
assert!(changed_files(dir.path(), "HEAD").unwrap_err().contains("not UTF-8"));
}
#[test]
fn deleted_and_renamed_shared_inputs_still_select_their_readers() {
for rename in [false, true] {
let dir = committed_repo();
let root = dir.path();
std::fs::create_dir_all(root.join("crates/engine/src")).unwrap();
std::fs::create_dir_all(root.join("docs/fixtures")).unwrap();
std::fs::write(root.join("docs/preferences.md"), "preferences").unwrap();
std::fs::write(root.join("docs/fixtures/example.json"), "{}").unwrap();
std::fs::write(
root.join("crates/engine/src/lib.rs"),
r#"
const PREFS: &str = include_str!("../../../docs/preferences.md");
fn fixture(n: &str) -> String { format!("{}/../../docs/fixtures/{n}", env!("CARGO_MANIFEST_DIR")) }
"#,
)
.unwrap();
commit_fixture(root);
if rename {
std::fs::rename(root.join("docs/preferences.md"), root.join("docs/renamed.md")).unwrap();
std::fs::rename(root.join("docs/fixtures"), root.join("docs/moved")).unwrap();
} else {
std::fs::remove_file(root.join("docs/preferences.md")).unwrap();
std::fs::remove_dir_all(root.join("docs/fixtures")).unwrap();
}
let changed = changed_files(root, "HEAD").unwrap();
let md = serde_json::json!({
"workspace_root": root,
"packages": [{
"name": "effectcraft-engine",
"manifest_path": root.join("crates/engine/Cargo.toml"),
"targets": [{"kind": ["lib"], "doctest": true}],
"dependencies": []
}]
});
let pkgs = packages(root, &md, &changed);
let engine = pkgs.first().unwrap();
assert!(engine.inputs.contains(&"docs/preferences.md".into()));
assert!(engine.inputs.contains(&"docs/fixtures".into()));
for deleted in ["docs/preferences.md", "docs/fixtures/example.json"] {
assert!(changed.contains(&deleted.to_string()));
assert_eq!(plan(&files(&[deleted]), &pkgs).direct, set(&["effectcraft-engine"]));
}
}
}
#[test]
fn test_runner_keeps_dev_profile_target_selection_and_gpu_isolation() {
for packages in [vec![], files(&["effectcraft-gpu", "effectcraft-ui-egui"])] {
for nextest in [false, true] {
let command = crate::test_command(&packages, nextest, true);
let args: Vec<_> = command.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert!(!args.iter().any(|a| a == "--release" || a == "--profile"));
for target in ["--lib", "--bins", "--tests", "--no-fail-fast"] {
assert!(args.iter().any(|a| a == target));
}
assert_eq!(args.iter().any(|a| a == "--workspace"), packages.is_empty());
for package in &packages {
assert!(args.windows(2).any(|w| w[0] == "-p" && w[1] == *package));
}
assert_eq!(args.windows(2).any(|w| w == ["-E", "not package(effectcraft-gpu)"]), nextest);
assert_eq!(args.iter().any(|a| a == "--no-tests=pass"), nextest && !packages.is_empty());
}
}
}
#[test]
fn full_workspace_test_commands_keep_library_tests() {
for nextest in [false, true] {
let command = crate::test_command(&[], nextest, false);
assert!(command.get_args().any(|a| a == "--workspace"));
assert!(command.get_args().any(|a| a == "--lib"));
}
}
#[test]
fn bin_only_and_doctest_disabled_packages_select_the_right_test_targets() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let md = serde_json::json!({
"workspace_root": root,
"packages": [
{
"name": "effectcraft-cli",
"manifest_path": root.join("apps/effectcraft-cli/Cargo.toml"),
"targets": [{"kind": ["bin"], "doctest": false}],
"dependencies": []
},
{
"name": "effectcraft-time",
"manifest_path": root.join("crates/time/Cargo.toml"),
"targets": [{"kind": ["lib"], "doctest": false}],
"dependencies": []
},
{
"name": "effectcraft-color",
"manifest_path": root.join("crates/color/Cargo.toml"),
"targets": [{"kind": ["lib"], "doctest": true}],
"dependencies": []
}
]
});
let pkgs = packages(root, &md, &[]);
for (name, changed, want_lib, want_doctests) in [
("effectcraft-cli", "apps/effectcraft-cli/src/main.rs", false, false),
("effectcraft-time", "crates/time/src/lib.rs", true, false),
("effectcraft-color", "crates/color/src/lib.rs", true, true),
] {
let plan = plan(&files(&[changed]), &pkgs);
assert_eq!(plan.affected, set(&[name]));
let (has_lib, doctests) = test_targets(&plan, &pkgs);
assert_eq!((has_lib, doctests), (want_lib, want_doctests));
let selected = plan.affected.into_iter().collect::<Vec<_>>();
for nextest in [false, true] {
let command = crate::test_command(&selected, nextest, has_lib);
let args: Vec<_> = command.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args.iter().any(|a| a == "--lib"), want_lib);
assert!(args.windows(2).any(|w| w == ["-p", name]));
for target in ["--bins", "--tests", "--no-fail-fast"] {
assert!(args.iter().any(|a| a == target));
}
}
}
}
fn labels(s: &[Step]) -> Vec<String> {
s.iter().map(|s| s.label.clone()).collect()
}
#[test]
fn steps_follow_the_plan() {
let mut p = ws();
if let Some(cli) = p.iter_mut().find(|p| p.name == "effectcraft-cli") {
cli.has_lib = false;
cli.has_doctests = false;
}
// docs only: cheap checks
assert_eq!(labels(&steps(&plan(&files(&["docs/gaps.md"]), &p), &p, true)), ["fmt", "pdf-deps", "lock-patches", "layers"]);
// ui-egui (L5, a web crate) and its two app dependents; cli has no doctests
let s = steps(&plan(&files(&["crates/ui-egui/src/dock.rs"]), &p), &p, true);
assert_eq!(labels(&s), ["fmt", "audio backend", "clippy (3)", "tests (3)", "pdf-deps", "lock-patches", "layers", "wasm (1)"]);
let args = |i: usize| s.get(i).and_then(|s| s.cmd.as_ref()).map(|c| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>());
let clippy = args(2).unwrap_or_default();
assert!(!clippy.iter().any(|a| a == "--release" || a == "--profile"));
assert!(clippy.windows(2).any(|w| w == ["-p", "effectcraft-ui-egui"]));
assert!(clippy.ends_with(&["--".into(), "-D".into(), "warnings".into()]));
assert!(args(7).unwrap_or_default().windows(2).any(|w| w == ["-p", "effectcraft-ui-egui"]));
// without nextest: one cargo test step; an asset change adds the assets check
let s = steps(&plan(&files(&["assets/fonts/A.ttf"]), &p), &p, false);
assert!(labels(&s).contains(&"tests (7)".to_string()) && labels(&s).contains(&"assets".to_string()));
}
}
+64 -19
View File
@@ -13,11 +13,14 @@
//! `crates/aep/tests/fixtures/corpus.lock`, verify every file's SHA-256, install into `corpus/aep`.
//! - `ico <out.ico> <in.png>…`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`).
//! - `ci`: fmt check, clippy -D warnings, tests (`cargo nextest run` when installed) and doctests,
//! PDF dependency policy, layers, assets, wasm. Everything builds in the dev profile.
//! PDF dependency policy, lock patches, layers, assets, wasm. Everything builds in the dev profile.
//! - `ci --affected [--base REV] [--dry-run]`: the same gates for only the crates the change since
//! REV (default: the merge-base with `main`) can affect; see `affected.rs` and docs/testing.md.
//! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios
//! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md).
mod aep_corpus;
mod affected;
mod bench;
mod ico;
mod lock_patches;
@@ -480,34 +483,73 @@ fn audio_backend() -> Result<(), String> {
///
/// Each run goes ahead when an earlier one fails; the error names every run that failed.
fn tests() -> Result<(), String> {
let nextest = Command::new(env!("CARGO")).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success());
tests_for(&[], nextest, true, true)
}
/// Keep the full and affected gates on the same test runner, including GPU isolation and the
/// translation report. An empty package list selects the whole workspace.
fn tests_for(packages: &[String], nextest: bool, has_lib: bool, doctests: bool) -> Result<(), String> {
let cargo = env!("CARGO");
let targets = ["--workspace", "--lib", "--bins", "--tests", "--no-fail-fast"];
let nextest = Command::new(cargo).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success());
let mut runs = Vec::new();
if nextest {
runs.push(run(Command::new(cargo).args(["nextest", "run"]).args(targets).args(["-E", "not package(effectcraft-gpu)"])));
// effectcraft-gpu's tests share one device, so they run in one libtest process: the
// binary nextest just built (`cargo test -p effectcraft-gpu` would rebuild ~2 minutes).
runs.push(lib_test_binary("effectcraft-gpu").and_then(|mut c| run(&mut c)));
} else {
let selection = test_selection(packages);
let includes = |name: &str| packages.is_empty() || packages.iter().any(|p| p == name);
if !nextest {
println!("cargo-nextest not found, running cargo test (install: cargo install cargo-nextest --locked)");
runs.push(run(Command::new(cargo).arg("test").args(targets)));
}
let mut runs = vec![run(&mut test_command(packages, nextest, has_lib))];
if nextest && includes("effectcraft-gpu") {
// GPU tests share one device, so reuse the selected build in one libtest process.
runs.push(lib_test_binary("effectcraft-gpu", &selection).and_then(|mut c| run(&mut c)));
}
// The test runs above hide a passing test's output, so the always-passing report runs again
// with --nocapture, from the binary already built.
runs.push(lib_test_binary("effectcraft-ui-egui").and_then(|mut c| run(c.args(["missing_translations_report", "--nocapture"]))));
runs.push(run(Command::new(cargo).args(["test", "--workspace", "--doc", "--no-fail-fast"])));
if includes("effectcraft-ui-egui") {
runs.push(lib_test_binary("effectcraft-ui-egui", &selection).and_then(|mut c| run(c.args(["missing_translations_report", "--nocapture"]))));
}
if doctests {
runs.push(run(Command::new(cargo).arg("test").args(&selection).args(["--doc", "--no-fail-fast"])));
}
let failed: Vec<String> = runs.into_iter().filter_map(Result::err).collect();
if failed.is_empty() { Ok(()) } else { Err(failed.join("; ")) }
}
/// Select library tests only when the selected packages contain a library; bin-only package
/// sets otherwise fail before running any tests. The full workspace always has library targets.
fn test_command(packages: &[String], nextest: bool, has_lib: bool) -> Command {
let mut c = Command::new(env!("CARGO"));
if nextest {
c.args(["nextest", "run"]);
// A selected package can have no tests, or only GPU tests that run separately.
if !packages.is_empty() {
c.arg("--no-tests=pass");
}
} else {
c.arg("test");
}
c.args(test_selection(packages));
if packages.is_empty() || has_lib {
c.arg("--lib");
}
c.args(["--bins", "--tests", "--no-fail-fast"]);
if nextest {
c.args(["-E", "not package(effectcraft-gpu)"]);
}
c
}
fn test_selection(packages: &[String]) -> Vec<String> {
if packages.is_empty() { vec!["--workspace".into()] } else { packages.iter().flat_map(|p| ["-p".into(), p.clone()]).collect() }
}
/// A command running `package`'s lib test binary from the test build above, the way `cargo test`
/// runs it (from the package directory), found with `--no-run` and the same workspace selection
/// runs it (from the package directory), found with `--no-run` and the same package selection
/// so nothing rebuilds: `cargo test -p <package>` resolves features for that package alone and
/// recompiles much of the workspace.
fn lib_test_binary(package: &str) -> Result<Command, String> {
fn lib_test_binary(package: &str, selection: &[String]) -> Result<Command, String> {
let out = Command::new(env!("CARGO"))
.args(["test", "--workspace", "--lib", "--bins", "--tests", "--no-run", "--message-format=json-render-diagnostics"])
.arg("test")
.args(selection)
.args(["--lib", "--bins", "--tests", "--no-run", "--message-format=json-render-diagnostics"])
.stderr(std::process::Stdio::inherit())
.output()
.map_err(|e| format!("cargo test --no-run: {e}"))?;
@@ -560,7 +602,10 @@ fn main() -> ExitCode {
"pdf-deps" => pdf_deps::run(&root()),
"lock-patches" => lock_patches::run(&root()),
"aep-corpus" => aep_corpus::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
"ci" => ci(),
"ci" => {
let rest: Vec<String> = std::env::args().skip(2).collect();
if rest.iter().any(|a| a == "--affected") { affected::run(&root(), &rest) } else { ci() }
}
"audio-backend" => audio_backend(),
"web" => web(&std::env::args().skip(2).collect::<Vec<_>>()),
"bench" => bench::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
@@ -572,7 +617,7 @@ fn main() -> ExitCode {
let rest: Vec<String> = std::env::args().skip(2).collect();
version::run(&root(), &rest.iter().map(String::as_str).collect::<Vec<_>>())
}
_ => Err("usage: cargo xtask <layers|assets|pdf-deps|lock-patches|aep-corpus [--dir DIR]|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|audio-backend|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
_ => Err("usage: cargo xtask <layers|assets|pdf-deps|lock-patches|aep-corpus [--dir DIR]|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci [--affected [--base REV] [--dry-run]]|audio-backend|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
@@ -585,7 +630,7 @@ fn main() -> ExitCode {
/// The workspace root (the parent of `xtask/`).
pub fn root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(std::path::Path::to_path_buf).unwrap_or_else(|| std::path::PathBuf::from("."))
}
#[cfg(all(test, target_os = "linux"))]