From ec42e06b8077aca5a422a4a986d2f897e50bd48d Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 01:45:19 -0700 Subject: [PATCH 1/9] bench(interpreter): add fork_for_serving criterion bench Track per-request interpreter fork cost with Criterion fixtures for both empty and closure-heavy templates, and surface the benchmark in CI. Co-authored-by: Cursor --- .github/workflows/ci.yml | 9 + .planning/fork-for-serving-benchmark.plan.md | 35 ++++ Cargo.lock | 182 +++++++++++++++++++ Cargo.toml | 10 + benches/fork_for_serving.rs | 63 +++++++ src/interpreter/tests.rs | 28 --- 6 files changed, 299 insertions(+), 28 deletions(-) create mode 100644 .planning/fork-for-serving-benchmark.plan.md create mode 100644 benches/fork_for_serving.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e9bd4..6e79deb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,15 @@ jobs: # Broad suppressions (-A dead-code, -A unused-variables, etc.) removed. - run: cargo clippy --all-targets -- -A clippy::approx_constant -A clippy::result_large_err -A clippy::only_used_in_recursion -A clippy::len_zero + fork-benchmark: + name: fork_for_serving benchmark + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo bench --bench fork_for_serving -- --warm-up-time 3 --measurement-time 5 --sample-size 50 + fmt: name: Format runs-on: ubuntu-latest diff --git a/.planning/fork-for-serving-benchmark.plan.md b/.planning/fork-for-serving-benchmark.plan.md new file mode 100644 index 0000000..b96d7a1 --- /dev/null +++ b/.planning/fork-for-serving-benchmark.plan.md @@ -0,0 +1,35 @@ +# Fork For Serving Benchmark Plan + +## Goal + +Close issue #112 by adding a real Criterion benchmark for `Interpreter::fork_for_serving()` and wiring it into CI for visibility. + +## Current State + +- There is no `benches/` directory or Criterion setup. +- `src/interpreter/tests.rs` has a diagnostic `fork_for_serving_is_under_50ms` test, but its threshold is intentionally loose and not a useful performance signal. +- `fork_for_serving()` is public and benchmarkable from an external bench target through the `forge_lang` library crate. + +## Implementation + +1. Add `criterion` as a dev-dependency using Cargo. +2. Add `[[bench]] name = "fork_for_serving" harness = false` to `Cargo.toml`. +3. Add an explicit `[profile.bench] opt-level = 3` so benchmark builds are release-like even if future profile edits change defaults. +4. Add `benches/fork_for_serving.rs`: + - build an `empty` fixture from `Interpreter::new()` to show the lower bound, + - build a `with_closures` fixture by lexing/parsing/running a small representative program that creates top-level objects, arrays, functions, and captured lambdas, + - benchmark `criterion::black_box(interp.fork_for_serving())` for each fixture. +5. Add a lightweight CI job on Ubuntu that runs: + - `cargo bench --bench fork_for_serving -- --warm-up-time 3 --measurement-time 5 --sample-size 50` +6. Keep the CI job visibility-only: do not parse timing or fail on noisy performance thresholds. Compilation and successful benchmark execution are the gate. +7. Remove the existing diagnostic wall-clock test in `src/interpreter/tests.rs`; Criterion replaces it with a statistically meaningful benchmark. + +## Tests + +- `cargo fmt` +- `cargo bench --bench fork_for_serving -- --warm-up-time 3 --measurement-time 5 --sample-size 50` +- `cargo test` + +## Rollback + +Remove the bench target, Criterion dev-dependency, CI job, and this plan file. diff --git a/Cargo.lock b/Cargo.lock index ace61cb..e761c07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -291,6 +297,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.56" @@ -348,6 +360,33 @@ dependencies = [ "phf 0.12.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -619,6 +658,39 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +dependencies = [ + "cast", + "itertools", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -675,6 +747,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -880,6 +958,7 @@ dependencies = [ "cranelift-jit", "cranelift-module", "cranelift-native", + "criterion", "dotenvy", "flate2", "futures-util", @@ -1104,6 +1183,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1848,6 +1938,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opentelemetry" version = "0.31.0" @@ -2036,6 +2132,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "postgres-protocol" version = "0.6.10" @@ -2270,6 +2394,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2525,6 +2669,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "saturating" version = "0.1.0" @@ -2914,6 +3067,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -3473,6 +3636,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3728,6 +3901,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index f61992c..aadaea8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,3 +136,13 @@ opt-level = 1 opt-level = 3 lto = true strip = true + +[profile.bench] +opt-level = 3 + +[dev-dependencies] +criterion = { version = "0.7.0", features = ["html_reports"] } + +[[bench]] +name = "fork_for_serving" +harness = false diff --git a/benches/fork_for_serving.rs b/benches/fork_for_serving.rs new file mode 100644 index 0000000..92cefa0 --- /dev/null +++ b/benches/fork_for_serving.rs @@ -0,0 +1,63 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use forge_lang::interpreter::Interpreter; +use forge_lang::lexer::Lexer; +use forge_lang::parser::Parser; +use std::hint::black_box; + +fn parse_and_run(source: &str) -> Interpreter { + let mut lexer = Lexer::new(source); + let tokens = lexer.tokenize().expect("bench source should lex"); + let mut parser = Parser::new(tokens); + let program = parser.parse_program().expect("bench source should parse"); + + let mut interp = Interpreter::new(); + interp.run(&program).expect("bench source should run"); + interp +} + +fn fixture_with_closures() -> Interpreter { + parse_and_run( + r#" + fn make_counter(seed) { + let mut count = seed + return fn() { + count = count + 1 + return count + } + } + + let counter_a = make_counter(0) + let counter_b = make_counter(100) + let config = { + name: "bench", + nested: { + items: [1, 2, 3, 4, 5], + flags: { fast: true, isolated: true } + } + } + + fn handler() { + return { + a: counter_a(), + b: counter_b(), + name: config.name + } + } + "#, + ) +} + +fn bench_fork_for_serving(c: &mut Criterion) { + let empty = Interpreter::new(); + c.bench_function("fork_for_serving/empty", |b| { + b.iter(|| black_box(empty.fork_for_serving())) + }); + + let with_closures = fixture_with_closures(); + c.bench_function("fork_for_serving/with_closures", |b| { + b.iter(|| black_box(with_closures.fork_for_serving())) + }); +} + +criterion_group!(benches, bench_fork_for_serving); +criterion_main!(benches); diff --git a/src/interpreter/tests.rs b/src/interpreter/tests.rs index 077b4c0..b0f72a5 100644 --- a/src/interpreter/tests.rs +++ b/src/interpreter/tests.rs @@ -6947,34 +6947,6 @@ fn fork_for_serving_supports_concurrent_use() { assert_eq!(template.env.get("rid"), None); } -/// Diagnostic benchmark — not a hard gate. Prints fork cost so we can -/// see if it dominates request latency. Threshold-style assertion only: -/// fork must be <50ms; the throughput-improvement story falls apart -/// well before that. -#[test] -fn fork_for_serving_is_under_50ms() { - let interp = Interpreter::new(); - let n = 100; - let t0 = std::time::Instant::now(); - for _ in 0..n { - let _ = interp.fork_for_serving(); - } - let elapsed = t0.elapsed(); - let mean_ms = elapsed.as_secs_f64() * 1000.0 / n as f64; - eprintln!( - "fork_for_serving: {} forks in {:?}, mean {:.3}ms", - n, elapsed, mean_ms - ); - // Tightened from 50ms in the previous PR. Today's baseline after - // the closure-walk fix is ~0.1ms; 1ms gives 10x headroom while still - // catching real regressions. - assert!( - mean_ms < 1.0, - "fork cost regressed: {:.3}ms per fork (gate: 1ms)", - mean_ms - ); -} - // ── Closure isolation across forks (PR #110) ───────────────────────────── /// Helper: define a top-level mutable variable plus a Lambda that captures From 83e0d36ad069e11ab32e9cced0a66125eeb87da8 Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 08:38:31 -0700 Subject: [PATCH 2/9] chore(ci): allow FFI pointer lint at runtime boundaries Co-authored-by: Cursor --- src/lib.rs | 1 + src/vm/jit/runtime.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 3666272..bd34629 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ use std::panic; /// `bytecode_ptr` must point to `bytecode_len` valid bytes of serialized /// Forge bytecode (produced by `vm::serialize::serialize_chunk`). #[no_mangle] +#[allow(clippy::not_unsafe_ptr_arg_deref)] pub extern "C" fn forge_execute_bytecode(bytecode_ptr: *const u8, bytecode_len: usize) -> i32 { if bytecode_ptr.is_null() || bytecode_len == 0 { eprintln!("forge: null or empty bytecode"); diff --git a/src/vm/jit/runtime.rs b/src/vm/jit/runtime.rs index 52b7b0f..529de1a 100644 --- a/src/vm/jit/runtime.rs +++ b/src/vm/jit/runtime.rs @@ -1,5 +1,6 @@ // JIT runtime bridge functions. Many are unused until M2 NaN-boxing JIT is wired up. #![allow(dead_code)] +#![allow(clippy::not_unsafe_ptr_arg_deref)] /// Runtime bridge functions for JIT-compiled code. /// From 24fa637b931c7988199db6860e2524653112e2db Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 08:43:54 -0700 Subject: [PATCH 3/9] chore(deps): refresh audited transitive dependencies Co-authored-by: Cursor --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e761c07..97dc781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,9 +135,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.0" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -145,9 +145,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -2277,9 +2277,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -2613,9 +2613,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", From 6d08852eb546133ba3e5c458a983724fc6be4297 Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 08:56:28 -0700 Subject: [PATCH 4/9] fix(ci): align tests with shell permission and Windows Co-authored-by: Cursor --- .github/workflows/ci.yml | 2 +- src/publish.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e79deb..2b13cb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: - name: Run Forge test suite run: | echo "Running all .fg tests in tests/ directory..." - ./target/release/forge test tests/ 2>&1 | tee /tmp/forge-test-out.txt + ./target/release/forge --allow-run test tests/ 2>&1 | tee /tmp/forge-test-out.txt # Check for any failures in the summary line if grep -qE "[1-9][0-9]* failed" /tmp/forge-test-out.txt; then echo "FAILURE: Some Forge tests failed" diff --git a/src/publish.rs b/src/publish.rs index 02a56b7..90d1dc5 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -427,6 +427,7 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(unix)] #[test] fn collect_files_skips_symlinks() { let dir = temp_path("symlink"); From 2b26f45f8fba7d4c4d915148aed261b6897daab5 Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 09:12:32 -0700 Subject: [PATCH 5/9] test: make path tests portable across platforms Co-authored-by: Cursor --- src/interpreter/tests.rs | 31 +++++++++++++++++++++++-------- src/package.rs | 26 +++++++++++++++----------- src/stdlib/fs.rs | 19 +++++++++++++++++-- src/stdlib/path_module.rs | 16 +++++++++++++--- src/vm/schedule_watch_tests.rs | 14 ++++++++++++-- 5 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/interpreter/tests.rs b/src/interpreter/tests.rs index b0f72a5..1580780 100644 --- a/src/interpreter/tests.rs +++ b/src/interpreter/tests.rs @@ -22,6 +22,10 @@ fn try_run_forge(source: &str) -> Result { interpreter.run(&program) } +fn forge_string_literal_path(path: &std::path::Path) -> String { + path.to_string_lossy().replace('\\', "\\\\") +} + #[test] fn evaluates_interpolated_expression() { let value = run_forge( @@ -797,15 +801,18 @@ fn fs_write_read_remove() { #[test] fn fs_exists() { - let result = try_run_forge( + let path = std::env::temp_dir().join("forge_test_exists.txt"); + let source = format!( r#" - let p = "/tmp/forge_test_exists.txt" + let p = "{}" fs.write(p, "x") assert(fs.exists(p)) fs.remove(p) assert(fs.exists(p) == false) "#, + forge_string_literal_path(&path) ); + let result = try_run_forge(&source); assert!(result.is_ok()); } @@ -1458,17 +1465,22 @@ fn method_chaining_map_filter() { #[test] fn fs_copy_and_rename() { - let result = try_run_forge( + let path1 = std::env::temp_dir().join("forge_copy_test.txt"); + let path2 = std::env::temp_dir().join("forge_copy_test2.txt"); + let source = format!( r#" - let p1 = "/tmp/forge_copy_test.txt" - let p2 = "/tmp/forge_copy_test2.txt" + let p1 = "{}" + let p2 = "{}" fs.write(p1, "hello") fs.copy(p1, p2) assert(fs.exists(p2)) fs.remove(p1) fs.remove(p2) "#, + forge_string_literal_path(&path1), + forge_string_literal_path(&path2) ); + let result = try_run_forge(&source); assert!(result.is_ok()); } @@ -1504,16 +1516,19 @@ fn fs_mkdir_list() { #[test] fn csv_read_write() { - let result = try_run_forge( + let path = std::env::temp_dir().join("forge_csv_test.csv"); + let source = format!( r#" - let p = "/tmp/forge_csv_test.csv" - let data = [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }] + let p = "{}" + let data = [{{ name: "Alice", age: 30 }}, {{ name: "Bob", age: 25 }}] csv.write(p, data) let loaded = csv.read(p) assert(len(loaded) == 2) fs.remove(p) "#, + forge_string_literal_path(&path) ); + let result = try_run_forge(&source); assert!(result.is_ok()); } diff --git a/src/package.rs b/src/package.rs index 8de1f03..b9711a1 100644 --- a/src/package.rs +++ b/src/package.rs @@ -1072,6 +1072,10 @@ toolkit = "1.2.3" std::fs::write(dir.join("forge.toml"), content).unwrap(); } + fn toml_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "\\\\") + } + #[test] fn transitive_deps_chain() { // A -> B -> C (all path deps) @@ -1088,7 +1092,7 @@ toolkit = "1.2.3" &pkg_b_src, &format!( "[project]\nname = \"pkg-b\"\n[dependencies]\npkg-c = {{ path = \"{}\" }}", - pkg_c_src.display() + toml_path(&pkg_c_src) ), ); @@ -1097,7 +1101,7 @@ toolkit = "1.2.3" let manifest: Manifest = toml::from_str(&format!( "[project]\nname = \"app\"\n[dependencies]\npkg-b = {{ path = \"{}\" }}", - pkg_b_src.display() + toml_path(&pkg_b_src) )) .unwrap(); @@ -1138,7 +1142,7 @@ toolkit = "1.2.3" &pkg_a_src, &format!( "[project]\nname = \"pkg-a\"\n[dependencies]\npkg-b = {{ path = \"{}\" }}", - pkg_b_src.display() + toml_path(&pkg_b_src) ), ); @@ -1149,13 +1153,13 @@ toolkit = "1.2.3" &pkg_b_src, &format!( "[project]\nname = \"pkg-b\"\n[dependencies]\npkg-a = {{ path = \"{}\" }}", - pkg_a_src.display() + toml_path(&pkg_a_src) ), ); let manifest: Manifest = toml::from_str(&format!( "[project]\nname = \"app\"\n[dependencies]\npkg-a = {{ path = \"{}\" }}", - pkg_a_src.display() + toml_path(&pkg_a_src) )) .unwrap(); @@ -1196,14 +1200,14 @@ toolkit = "1.2.3" &pkg_b_src, &format!( "[project]\nname = \"pkg-b\"\n[dependencies]\npkg-c = {{ path = \"{}\" }}", - pkg_c_src.display() + toml_path(&pkg_c_src) ), ); let manifest: Manifest = toml::from_str(&format!( "[project]\nname = \"app\"\n[dependencies]\npkg-b = {{ path = \"{}\" }}\npkg-c = {{ path = \"{}\" }}", - pkg_b_src.display(), - pkg_c_src.display() + toml_path(&pkg_b_src), + toml_path(&pkg_c_src) )) .unwrap(); @@ -1241,7 +1245,7 @@ toolkit = "1.2.3" let manifest: Manifest = toml::from_str(&format!( "[project]\nname = \"app\"\n[dependencies]\npkg-b = {{ path = \"{}\" }}", - pkg_b_src.display() + toml_path(&pkg_b_src) )) .unwrap(); @@ -1276,7 +1280,7 @@ toolkit = "1.2.3" &pkg_a_src, &format!( "[project]\nname = \"pkg-a\"\n[dependencies]\nmy-app = {{ path = \"{}\" }}", - app_src.display() + toml_path(&app_src) ), ); @@ -1286,7 +1290,7 @@ toolkit = "1.2.3" let manifest: Manifest = toml::from_str(&format!( "[project]\nname = \"my-app\"\n[dependencies]\npkg-a = {{ path = \"{}\" }}", - pkg_a_src.display() + toml_path(&pkg_a_src) )) .unwrap(); diff --git a/src/stdlib/fs.rs b/src/stdlib/fs.rs index aace067..dae2a26 100644 --- a/src/stdlib/fs.rs +++ b/src/stdlib/fs.rs @@ -535,6 +535,10 @@ mod tests { #[test] fn test_fs_join_path() { + let expected = std::path::Path::new("/home") + .join("user") + .to_string_lossy() + .to_string(); assert_eq!( call( "fs.join_path", @@ -544,7 +548,7 @@ mod tests { ] ) .unwrap(), - Value::String("/home/user".to_string()) + Value::String(expected) ); } @@ -643,10 +647,21 @@ mod tests { #[test] fn confine_path_absolute_outside_is_rejected() { let base = unique_temp_dir("absolute"); - let err = confine_path_with("/etc/passwd", Some(base.to_str().unwrap())) + let outside = std::env::temp_dir().join(format!( + "forge_confine_outside_{}_{}.txt", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::write(&outside, "outside").unwrap(); + + let err = confine_path_with(outside.to_str().unwrap(), Some(base.to_str().unwrap())) .expect_err("absolute outside path should be rejected"); assert!(err.contains("escapes FORGE_FS_BASE"), "got: {}", err); + std::fs::remove_file(&outside).ok(); std::fs::remove_dir_all(&base).ok(); } diff --git a/src/stdlib/path_module.rs b/src/stdlib/path_module.rs index 8d2ea73..276aa6a 100644 --- a/src/stdlib/path_module.rs +++ b/src/stdlib/path_module.rs @@ -157,7 +157,11 @@ mod tests { vec![Value::String("a".into()), Value::String("b".into())], ) .unwrap(); - assert_eq!(result, Value::String("a/b".into())); + let expected = std::path::Path::new("a") + .join("b") + .to_string_lossy() + .to_string(); + assert_eq!(result, Value::String(expected)); } #[test] @@ -171,7 +175,12 @@ mod tests { ], ) .unwrap(); - assert_eq!(result, Value::String("a/b/c.txt".into())); + let expected = std::path::Path::new("a") + .join("b") + .join("c.txt") + .to_string_lossy() + .to_string(); + assert_eq!(result, Value::String(expected)); } #[test] @@ -188,7 +197,8 @@ mod tests { #[test] fn is_absolute_detects_absolute() { - let result = call("path.is_absolute", vec![Value::String("/usr/bin".into())]).unwrap(); + let absolute = std::env::temp_dir().to_string_lossy().to_string(); + let result = call("path.is_absolute", vec![Value::String(absolute)]).unwrap(); assert_eq!(result, Value::Bool(true)); } diff --git a/src/vm/schedule_watch_tests.rs b/src/vm/schedule_watch_tests.rs index 642c3e0..daf0dbc 100644 --- a/src/vm/schedule_watch_tests.rs +++ b/src/vm/schedule_watch_tests.rs @@ -22,6 +22,10 @@ fn run_vm(source: &str) { vm.execute(&chunk).expect("vm error"); } +fn forge_string_literal_path(path: &std::path::Path) -> String { + path.to_string_lossy().replace('\\', "\\\\") +} + #[test] fn vm_schedule_compiles() { compile_ok("schedule every 1 seconds { let x = 1 }"); @@ -68,7 +72,10 @@ fn vm_watch_fires_on_change() { let watched = std::env::temp_dir().join("forge_watch_vm_test.txt"); std::fs::write(&watched, "initial").expect("write watched file"); - let source = format!(r#"watch "{}" {{ let x = 1 }}"#, watched.display()); + let source = format!( + r#"watch "{}" {{ let x = 1 }}"#, + forge_string_literal_path(&watched) + ); run_vm(&source); let _ = std::fs::remove_file(&watched); } @@ -79,7 +86,10 @@ fn vm_watch_no_fire_without_change() { let watched = std::env::temp_dir().join("forge_watch_vm_nochange.txt"); std::fs::write(&watched, "stable").expect("write watched file"); - let source = format!(r#"watch "{}" {{ let x = 1 }}"#, watched.display()); + let source = format!( + r#"watch "{}" {{ let x = 1 }}"#, + forge_string_literal_path(&watched) + ); run_vm(&source); let _ = std::fs::remove_file(&watched); } From 22d7dc6ad8b6accc0f6f6927cd0f8e3a1085892c Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 09:18:18 -0700 Subject: [PATCH 6/9] test: avoid fixed temp paths in interpreter fs tests Co-authored-by: Cursor --- src/interpreter/tests.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/interpreter/tests.rs b/src/interpreter/tests.rs index 1580780..4a4a9b2 100644 --- a/src/interpreter/tests.rs +++ b/src/interpreter/tests.rs @@ -26,6 +26,14 @@ fn forge_string_literal_path(path: &std::path::Path) -> String { path.to_string_lossy().replace('\\', "\\\\") } +fn unique_temp_path(name: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("forge_{}_{}_{}", name, std::process::id(), nanos)) +} + #[test] fn evaluates_interpolated_expression() { let value = run_forge( @@ -801,7 +809,7 @@ fn fs_write_read_remove() { #[test] fn fs_exists() { - let path = std::env::temp_dir().join("forge_test_exists.txt"); + let path = unique_temp_path("test_exists.txt"); let source = format!( r#" let p = "{}" @@ -1465,8 +1473,8 @@ fn method_chaining_map_filter() { #[test] fn fs_copy_and_rename() { - let path1 = std::env::temp_dir().join("forge_copy_test.txt"); - let path2 = std::env::temp_dir().join("forge_copy_test2.txt"); + let path1 = unique_temp_path("copy_test.txt"); + let path2 = unique_temp_path("copy_test2.txt"); let source = format!( r#" let p1 = "{}" @@ -1516,7 +1524,7 @@ fn fs_mkdir_list() { #[test] fn csv_read_write() { - let path = std::env::temp_dir().join("forge_csv_test.csv"); + let path = unique_temp_path("csv_test.csv"); let source = format!( r#" let p = "{}" From 007e81dc1a22befa6897a619e4b154016d308642 Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 09:34:45 -0700 Subject: [PATCH 7/9] test: support platform-specific parity expectations Co-authored-by: Cursor --- src/native.rs | 6 +++++- src/testing/parity.rs | 9 +++++++-- tests/parity/supported/os_path_modules.fg | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/native.rs b/src/native.rs index f2355f0..79e5e5d 100644 --- a/src/native.rs +++ b/src/native.rs @@ -388,7 +388,11 @@ mod tests { #[test] fn native_output_path_drops_source_extension() { let path = Path::new("/tmp/hello.fg"); - assert_eq!(native_output_path(path), PathBuf::from("/tmp/hello")); + let mut expected = PathBuf::from("/tmp/hello"); + if cfg!(windows) { + expected.set_extension("exe"); + } + assert_eq!(native_output_path(path), expected); } #[test] diff --git a/src/testing/parity.rs b/src/testing/parity.rs index 049c4b8..14cde17 100644 --- a/src/testing/parity.rs +++ b/src/testing/parity.rs @@ -41,8 +41,13 @@ pub fn load_supported_cases() -> Vec { load_cases("supported") .into_iter() .map(|(path, source)| SupportedParityCase { - expected: metadata_value(&source, "expect") - .unwrap_or_else(|| panic!("missing '// expect:' header in {}", path.display())), + expected: if cfg!(windows) { + metadata_value(&source, "expect-windows") + .or_else(|| metadata_value(&source, "expect")) + } else { + metadata_value(&source, "expect") + } + .unwrap_or_else(|| panic!("missing '// expect:' header in {}", path.display())), path, source, }) diff --git a/tests/parity/supported/os_path_modules.fg b/tests/parity/supported/os_path_modules.fg index a78a1f0..a570c58 100644 --- a/tests/parity/supported/os_path_modules.fg +++ b/tests/parity/supported/os_path_modules.fg @@ -1,4 +1,5 @@ // expect: [src/main.rs, /, main.rs, .rs, src, false] +// expect-windows: [src\main.rs, \, main.rs, .rs, src, false] let p = path.join("src", "main.rs") [p, path.separator, path.basename(p), path.extname(p), path.dirname(p), path.is_absolute(p)] From 740d06729e590035cfbb29e5b5cad0b381249ce8 Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 09:36:57 -0700 Subject: [PATCH 8/9] test: avoid hardcoded temp dir in fs tests Co-authored-by: Cursor --- src/stdlib/fs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/stdlib/fs.rs b/src/stdlib/fs.rs index dae2a26..9097dd4 100644 --- a/src/stdlib/fs.rs +++ b/src/stdlib/fs.rs @@ -554,8 +554,9 @@ mod tests { #[test] fn test_fs_is_dir() { + let temp_dir = std::env::temp_dir().to_string_lossy().to_string(); assert_eq!( - call("fs.is_dir", vec![Value::String("/tmp".to_string())]).unwrap(), + call("fs.is_dir", vec![Value::String(temp_dir)]).unwrap(), Value::Bool(true) ); assert_eq!( From b184071a58b7bd354db9c50a906fc74661890e28 Mon Sep 17 00:00:00 2001 From: Archith Date: Sun, 3 May 2026 09:52:05 -0700 Subject: [PATCH 9/9] test: stabilize server concurrency checks in CI Co-authored-by: Cursor --- tests/server_concurrency.rs | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/server_concurrency.rs b/tests/server_concurrency.rs index 6a4547b..f9c7492 100644 --- a/tests/server_concurrency.rs +++ b/tests/server_concurrency.rs @@ -27,6 +27,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +const MAX_SCALING_RATIO: f64 = 3.8; + /// Pick an unused TCP port by binding 0 and letting the kernel choose. fn pick_port() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); @@ -108,6 +110,10 @@ fn wait_for_path(path: &Path, timeout: Duration) -> bool { false } +fn forge_string_literal_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "\\\\") +} + /// Time N concurrent GET requests using blocking reqwest on N OS threads. /// Returns the total wall time from the first request issued to the last /// response received. @@ -180,17 +186,18 @@ fn http_handlers_run_in_parallel_not_serialized() { ); // On a fully serialized server (the pre-fix Arc> - // model), C=4 would take ~4x longer than C=1. We allow 3.5x to + // model), C=4 would take ~4x longer than C=1. We allow 3.8x to // accommodate slow CI runners (ubuntu-latest is effectively // 2-core with hyperthreading and frequently under load), tokio // scheduling overhead, and per-request tower_http middleware // cost. The gate still detects a regression to full serialization // (which would be ~4x). assert!( - parallel < single.mul_f64(3.5), - "handlers serialized: C=4 wall {:?} should be < 3.5x C=1 wall {:?} \ + parallel < single.mul_f64(MAX_SCALING_RATIO), + "handlers serialized: C=4 wall {:?} should be < {:.1}x C=1 wall {:?} \ (ratio {:.2}x). The per-request fork model has regressed.", parallel, + MAX_SCALING_RATIO, single, parallel.as_secs_f64() / single.as_secs_f64() ); @@ -257,11 +264,12 @@ fn closure_capturing_handlers_run_in_parallel_not_serialized() { ); assert!( - parallel < single.mul_f64(3.5), - "closure-capturing handlers serialized: C=4 wall {:?} should be < 3.5x C=1 wall {:?} \ + parallel < single.mul_f64(MAX_SCALING_RATIO), + "closure-capturing handlers serialized: C=4 wall {:?} should be < {:.1}x C=1 wall {:?} \ (ratio {:.2}x). The per-request closure isolation has regressed -- \ check Environment::deep_clone_isolated and fork_for_serving.", parallel, + MAX_SCALING_RATIO, single, parallel.as_secs_f64() / single.as_secs_f64() ); @@ -271,10 +279,7 @@ fn closure_capturing_handlers_run_in_parallel_not_serialized() { fn schedule_mutations_do_not_leak_into_handler_forks() { let sentinel = unique_temp_file("schedule_handler_isolation"); let _ = std::fs::remove_file(&sentinel); - let sentinel_str = sentinel - .to_str() - .expect("temp path should be valid UTF-8") - .to_string(); + let sentinel_str = forge_string_literal_path(&sentinel); // `spawn_test_server` leaves `defer_host_runtime` at the Interpreter default // (`false`), so schedules start during `interp.run()`. That differs from the @@ -343,9 +348,9 @@ fn websocket_handler_cancelled_on_client_disconnect() { let _ = std::fs::remove_file(path); } - let started_str = started.to_str().expect("temp path should be UTF-8"); - let progress_str = progress.to_str().expect("temp path should be UTF-8"); - let finished_str = finished.to_str().expect("temp path should be UTF-8"); + let started_str = forge_string_literal_path(&started); + let progress_str = forge_string_literal_path(&progress); + let finished_str = forge_string_literal_path(&finished); let source = r#" @server(port: __PORT__) @@ -367,9 +372,9 @@ fn websocket_handler_cancelled_on_client_disconnect() { return "done" } "# - .replace("__STARTED__", started_str) - .replace("__PROGRESS__", progress_str) - .replace("__FINISHED__", finished_str); + .replace("__STARTED__", &started_str) + .replace("__PROGRESS__", &progress_str) + .replace("__FINISHED__", &finished_str); let port = spawn_test_server(&source); let url = format!("ws://127.0.0.1:{}/ws", port);