From 0c7181917e79858b277540ecbfa8b8a65a3226fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 06:21:49 +0200 Subject: [PATCH 1/9] fix(runtime): preserve evaluated Error heritage (cherry picked from commit 4fcf6191817f0d08a4b49dad40b7dadfd337a799) --- crates/perry-runtime/src/object/instanceof.rs | 14 ++ ...sue_9940_compile_package_error_identity.rs | 135 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 crates/perry/tests/issue_9940_compile_package_error_identity.rs diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 8c537cacbc..a688ed8f8e 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -1755,6 +1755,20 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // For user-defined classes that extend Error: `myErr instanceof Error` should be true. if class_id == crate::error::CLASS_ID_ERROR { + // #9940: a function-local class declaration gets a fresh class + // object on every evaluation, but all evaluations share its + // compile-time class id. A constructor factory can therefore + // evaluate `class Definition extends Error {}`, then later + // evaluate the same declaration with an Object parent. The class + // registry is keyed by the shared id and is necessarily + // last-wins; the instance's recorded evaluation prototype is the + // authoritative chain. Zod's `$constructor` has exactly this + // shape, and its later schema classes made an earlier ZodError + // fail `instanceof Error` even though getPrototypeOf still showed + // `ZodError -> Error -> Object`. + if let Some(matches) = recorded_prototype_instanceof_builtin(value, "Error") { + return if matches { true_val } else { false_val }; + } let obj_class_id = (*obj_ptr).class_id; if extends_builtin_error(obj_class_id) { return true_val; diff --git a/crates/perry/tests/issue_9940_compile_package_error_identity.rs b/crates/perry/tests/issue_9940_compile_package_error_identity.rs new file mode 100644 index 0000000000..00fdfdcf4c --- /dev/null +++ b/crates/perry/tests/issue_9940_compile_package_error_identity.rs @@ -0,0 +1,135 @@ +//! Regression test for #9940: an Error subclass declared in a +//! `compilePackages` dependency must share the application's global Error +//! identity. Frameworks such as Hono use `value instanceof Error` to decide +//! whether a thrown value reaches their error handler. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn compiled_package_error_subclass_is_instanceof_global_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("package.json"), + r#"{ + "name": "compile-package-error-identity", + "private": true, + "type": "module", + "perry": { + "compilePackages": ["error-package"], + "allow": { "compilePackages": ["error-package"] } + } +}"#, + ) + .expect("write consumer package.json"); + + let package = root.join("node_modules").join("error-package"); + std::fs::create_dir_all(package.join("src")).expect("mkdir error-package/src"); + std::fs::write( + package.join("package.json"), + r#"{ + "name": "error-package", + "version": "1.0.0", + "type": "module", + "exports": { ".": "./index.js" } +}"#, + ) + .expect("write error-package package.json"); + std::fs::write( + package.join("index.js"), + "export { PackageError, makeError } from \"./src/errors.js\";\n", + ) + .expect("write error-package published entry"); + std::fs::write( + package.join("src/core.ts"), + r#"export function constructorFactory(name: string, params?: { Parent?: any }): any { + const Parent = params?.Parent ?? Object; + class Definition extends Parent {} + function DynamicPackageError(message: string) { + const inst: any = params?.Parent ? new Definition() : this; + inst.message = message; + inst.kind = name; + return inst; + } + Object.defineProperty(DynamicPackageError, Symbol.hasInstance, { + value: (inst: any) => inst?.kind === name, + }); + Object.defineProperty(DynamicPackageError, "name", { value: name }); + return DynamicPackageError; +} +"#, + ) + .expect("write error-package core.ts"); + std::fs::write( + package.join("src/errors.ts"), + r#"import { constructorFactory } from "./core.js"; + +// Zod creates many Object-backed classes from this factory before it creates +// its Error-backed class from the same nested class declaration. +export const PlainThing = constructorFactory("PlainThing"); +export const PackageError = constructorFactory("PackageError", { Parent: Error }); +export const AnotherPlainThing = constructorFactory("AnotherPlainThing"); +export function makeError(message: string): any { + return new PackageError(message); +} +"#, + ) + .expect("write error-package errors.ts"); + std::fs::write( + package.join("src/index.ts"), + "export { PackageError, makeError } from \"./errors.js\";\n", + ) + .expect("write error-package src/index.ts"); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#"import { makeError } from "error-package"; + +const packageError: any = makeError("bad input"); +class AppError extends Error {} +console.log( + packageError instanceof Error, + new AppError("app") instanceof Error, + new Error("plain") instanceof Error +); +"#, + ) + .expect("write entry"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + stdout, + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + stdout, "true true true\n", + "the package subclass must inherit the application's global Error identity" + ); +} From 1d146de3c9b5dc4cd90b66a4157f5161ca913319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 06:22:39 +0200 Subject: [PATCH 2/9] docs(changelog): record Error heritage fix (cherry picked from commit bb077b0a43a0936ff7dc893bf252bf5bc32447b9) --- changelog.d/9946-compile-package-error-identity.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/9946-compile-package-error-identity.md diff --git a/changelog.d/9946-compile-package-error-identity.md b/changelog.d/9946-compile-package-error-identity.md new file mode 100644 index 0000000000..19e5bd7a67 --- /dev/null +++ b/changelog.d/9946-compile-package-error-identity.md @@ -0,0 +1 @@ +Fixed Error subclasses created by repeated `compilePackages` class factories to remain instances of the global `Error` constructor. From 4ef146d0ff7743a3884ae1a02454cc83961ce6af Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Mon, 7 Sep 2026 00:41:09 -0400 Subject: [PATCH 3/9] feat(diagnostics): capture Linux server hangs outside the event loop (cherry picked from commit 3de6178498f456f6e3d6d81644d5482ea279d221) --- .github/workflows/test.yml | 4 + changelog.d/9942-linux-incident-capture.md | 4 + docs/src/SUMMARY.md | 1 + docs/src/testing/linux-incident-capture.md | 61 +++++++ scripts/capture_linux_incident.py | 201 +++++++++++++++++++++ scripts/test_capture_linux_incident.py | 83 +++++++++ 6 files changed, 354 insertions(+) create mode 100644 changelog.d/9942-linux-incident-capture.md create mode 100644 docs/src/testing/linux-incident-capture.md create mode 100644 scripts/capture_linux_incident.py create mode 100644 scripts/test_capture_linux_incident.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e884953480..2455be32a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -272,6 +272,10 @@ jobs: python3 scripts/workspace_architecture.py --self-test python3 scripts/workspace_architecture.py --check --print-summary + - name: Linux incident collector fixtures + if: ${{ !cancelled() }} + run: python3 scripts/test_capture_linux_incident.py + - name: Public benchmark evidence freshness if: ${{ !cancelled() }} run: | diff --git a/changelog.d/9942-linux-incident-capture.md b/changelog.d/9942-linux-incident-capture.md new file mode 100644 index 0000000000..31bafe3ffe --- /dev/null +++ b/changelog.d/9942-linux-incident-capture.md @@ -0,0 +1,4 @@ +Added an external Linux process incident collector for server hangs and memory +growth, preserving per-thread CPU, memory snapshots, and optional perf evidence +without depending on the affected event loop. This instruments issue #9942; +it does not claim to fix the reported leak or hang. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 335968a3cc..8400e3d587 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -157,6 +157,7 @@ - [CI Tiers (PR gate / sweep / full)](testing/ci-tiers.md) - [Claude Code Bundle Parity](testing/cc-parity.md) - [CI Gate Scheduling](testing/ci-gate-scheduling.md) +- [Linux Incident Capture](testing/linux-incident-capture.md) # CLI Reference diff --git a/docs/src/testing/linux-incident-capture.md b/docs/src/testing/linux-incident-capture.md new file mode 100644 index 0000000000..7f18faa58f --- /dev/null +++ b/docs/src/testing/linux-incident-capture.md @@ -0,0 +1,61 @@ +# Capturing a Linux server hang or memory slope + +Issue #9942 lost the spinning process before a user-space stack was captured. +`scripts/capture_linux_incident.py` runs outside the affected process, so it +works even when the JS event loop cannot run timers or signal callbacks. +It reads `/proc`; it does not restart or signal the server. + +Before restarting a wedged service, capture its current PID: + +```sh +python3 scripts/capture_linux_incident.py 3296296 \ + --output /tmp/perry-incident-20260907 --perf-seconds 10 +``` + +The output directory must not exist. It is created with owner-only access. +Use the service account (or an account permitted to inspect it). Missing +`smaps_rollup`, kernel stacks, or perf permissions are recorded explicitly; +available evidence is retained. `perf` is optional and is never installed by +the script. An unsuccessful requested perf recording makes the command fail +while preserving the `/proc` samples. + +For the steady growth reported in #9942, collect a healthy baseline and a later +sample under the same idle scheduler workload: + +```sh +python3 scripts/capture_linux_incident.py 3296296 \ + --output /tmp/perry-growth-20260907 --samples 61 --interval 1 +``` + +`summary.json` contains per-interval RSS growth and per-thread CPU percentages +(100% means one busy core), derived using the host clock tick and page sizes. +Thread IDs are compared with their start time so a recycled ID cannot produce +a false CPU delta. A replaced process ends the capture instead of mixing two +server lifetimes. Interrupting collection preserves the completed samples. + +Each sample retains `status`, `smaps_rollup`, `maps`, `io`, `limits`, and per-thread +`stat`, `wchan`, `stack`, and `schedstat`. `/proc/.../stack` is a **kernel** stack; +it cannot identify a spinning Rust/JS function. For that, inspect the optional +user-space profile alongside the exact deployed executable and matching symbols: + +```sh +perf report --stdio -i /tmp/perry-incident-20260907/perf.data +``` + +The default perf call chain uses frame pointers. Record whether the deployed +runtime and executable contain frame pointers/debug symbols if its stack is +incomplete. Keep the build commit, workload duration, and the last application +error beside the capture. Maps and profiles contain executable paths and +addresses; review the artifact before attaching it publicly. The collector does +not read the process environment, command line, request bodies, or application +heap. + +This is evidence collection, not a fix for #9942. RSS growth alone does not +distinguish retained live objects, allocator retention, or an off-heap leak. +The profile and memory breakdown are intended to choose the next reproducer. + +Collector tests (also runnable off Linux using synthetic `/proc` fixtures): + +```sh +python3 -m unittest discover -s scripts -p test_capture_linux_incident.py +``` diff --git a/scripts/capture_linux_incident.py b/scripts/capture_linux_incident.py new file mode 100644 index 0000000000..b83f99cc18 --- /dev/null +++ b/scripts/capture_linux_incident.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Capture a live Linux process without relying on its event loop (#9942).""" + +import argparse +import datetime +import json +import os +from pathlib import Path +import platform +import subprocess +import sys +import time + + +MAX_FILE_BYTES = 4 * 1024 * 1024 + + +def parse_stat(text): + # comm may contain spaces and closing parentheses; fields start after the + # final ')'. Index zero here is Linux stat field 3 (state). + if not isinstance(text, str): + raise ValueError("stat is unavailable") + end = text.rfind(")") + start = text.find("(") + if start < 0 or end <= start: + raise ValueError("malformed /proc stat comm") + fields = text[end + 1:].split() + return { + "comm": text[start + 1:end], + "state": fields[0], + "cpu_ticks": int(fields[11]) + int(fields[12]), + "start_ticks": int(fields[19]), + "virtual_bytes": int(fields[20]), + "rss_pages": int(fields[21]), + } + + +def read_file(path, errors): + try: + with path.open("rb") as source: + data = source.read(MAX_FILE_BYTES + 1) + if len(data) > MAX_FILE_BYTES: + errors.append(f"{path}: truncated at {MAX_FILE_BYTES} bytes") + return data[:MAX_FILE_BYTES].decode("utf-8", errors="replace") + except OSError as error: + errors.append(f"{path}: {error}") + return None + + +def identity(process): + return parse_stat((process / "stat").read_text())["start_ticks"] + + +def snapshot(process, expected_start, max_threads=256): + if identity(process) != expected_start: + raise RuntimeError("PID was reused; refusing to mix two processes") + result = {"monotonic_seconds": time.monotonic(), "files": {}, "threads": {}, "errors": []} + for name in ("stat", "status", "smaps_rollup", "io", "limits", "maps"): + result["files"][name] = read_file(process / name, result["errors"]) + tasks = sorted((process / "task").iterdir(), key=lambda path: int(path.name)) + result["threads_seen"] = len(tasks) + if len(tasks) > max_threads: + result["errors"].append(f"thread capture limited to {max_threads} of {len(tasks)} threads") + for task in tasks[:max_threads]: + result["threads"][task.name] = { + name: read_file(task / name, result["errors"]) + for name in ("stat", "wchan", "stack", "schedstat") + } + if identity(process) != expected_start: + raise RuntimeError("PID changed during capture; discarding this sample") + return result + + +def summarize(samples, ticks_per_second, page_size): + if len(samples) < 2: + return {"intervals": [], "note": "Need two samples to measure growth and CPU."} + intervals = [] + for before, after in zip(samples, samples[1:]): + elapsed = after["monotonic_seconds"] - before["monotonic_seconds"] + if elapsed <= 0: + continue + row = {"elapsed_seconds": elapsed, "threads": [], "errors": []} + try: + first = parse_stat(before["files"]["stat"]) + last = parse_stat(after["files"]["stat"]) + growth = (last["rss_pages"] - first["rss_pages"]) * page_size + row.update(rss_bytes=last["rss_pages"] * page_size, + rss_delta_bytes=growth, rss_bytes_per_second=growth / elapsed) + except (TypeError, ValueError, IndexError) as error: + row["errors"].append(f"process stat unavailable: {error}") + for tid, thread in after["threads"].items(): + previous = before["threads"].get(tid) + if previous is None: + continue + try: + first, last = parse_stat(previous["stat"]), parse_stat(thread["stat"]) + if first["start_ticks"] != last["start_ticks"]: + continue # A recycled thread ID is not a CPU delta. + cpu_seconds = (last["cpu_ticks"] - first["cpu_ticks"]) / ticks_per_second + if cpu_seconds < 0: + continue + row["threads"].append({"tid": int(tid), "comm": last["comm"], + "state": last["state"], "cpu_seconds": cpu_seconds, + "cpu_percent_one_core": 100 * cpu_seconds / elapsed, + "wchan": thread["wchan"]}) + except (TypeError, ValueError, IndexError) as error: + row["errors"].append(f"thread {tid} stat unavailable: {error}") + row["threads"].sort(key=lambda item: item["cpu_seconds"], reverse=True) + intervals.append(row) + return {"intervals": intervals} + + +def write_json(path, value): + path.write_text(json.dumps(value, indent=2) + "\n") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("pid", type=int) + parser.add_argument("--output", required=True, type=Path, help="new directory; never overwrites a capture") + parser.add_argument("--samples", type=int, default=6) + parser.add_argument("--interval", type=float, default=2) + parser.add_argument("--max-threads", type=int, default=256) + parser.add_argument("--perf-seconds", type=int, default=0, + help="optionally record user-space call chains with perf for 1–60 seconds") + args = parser.parse_args(argv) + if platform.system() != "Linux": + parser.error("this collector requires Linux /proc") + if args.pid <= 0 or not 2 <= args.samples <= 3600 or not 0.1 <= args.interval <= 60: + parser.error("require positive PID, 2–3600 samples and interval 0.1–60 seconds") + if not 1 <= args.max_threads <= 4096 or not 0 <= args.perf_seconds <= 60: + parser.error("require max-threads 1–4096 and perf-seconds 0–60") + process = Path("/proc") / str(args.pid) + try: + expected_start = identity(process) + args.output.mkdir(mode=0o700, parents=False, exist_ok=False) + except (OSError, ValueError, IndexError) as error: + parser.error(str(error)) + metadata = {"pid": args.pid, "start_ticks": expected_start, + "captured_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "kernel": platform.release(), "machine": platform.machine(), + "ticks_per_second": os.sysconf("SC_CLK_TCK"), + "page_size": os.sysconf("SC_PAGE_SIZE"), "errors": []} + try: + metadata["executable"] = os.readlink(process / "exe") + except OSError as error: + metadata["errors"].append(str(error)) + samples = [] + perf = None + perf_log = None + try: + if args.perf_seconds: + command = ["perf", "record", "-F", "99", "-g", "-p", str(args.pid), + "-o", str(args.output / "perf.data"), "--", "sleep", str(args.perf_seconds)] + metadata["perf_command"] = command + perf_log = (args.output / "perf.log").open("w") + try: + perf = subprocess.Popen(command, stdout=perf_log, stderr=perf_log) + except OSError as error: + metadata["errors"].append(f"perf unavailable: {error}") + for index in range(args.samples): + try: + sample = snapshot(process, expected_start, args.max_threads) + except (OSError, RuntimeError, ValueError, IndexError) as error: + metadata["errors"].append(str(error)) + break + samples.append(sample) + write_json(args.output / f"sample-{index:04d}.json", sample) + if index + 1 < args.samples: + time.sleep(args.interval) + if perf is not None: + try: + metadata["perf_exit_code"] = perf.wait(timeout=args.perf_seconds + 5) + except subprocess.TimeoutExpired: + metadata["errors"].append("perf exceeded its deadline") + except KeyboardInterrupt: + metadata["errors"].append("capture interrupted; partial samples preserved") + finally: + if perf is not None and perf.poll() is None: + # Stop only the collector we launched, never the observed process. + perf.terminate() + try: + perf.wait(timeout=5) + except subprocess.TimeoutExpired: + perf.kill() + perf.wait() + if perf_log is not None: + perf_log.close() + metadata["samples_captured"] = len(samples) + metadata["samples_requested"] = args.samples + write_json(args.output / "metadata.json", metadata) + write_json(args.output / "summary.json", summarize( + samples, metadata["ticks_per_second"], metadata["page_size"])) + print(f"Captured {len(samples)}/{args.samples} samples in {args.output}") + if args.perf_seconds: + print("User-space stacks: inspect perf.log, then perf report --stdio -i /perf.data") + return 0 if len(samples) == args.samples and not metadata["errors"] and metadata.get("perf_exit_code", 0) == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_capture_linux_incident.py b/scripts/test_capture_linux_incident.py new file mode 100644 index 0000000000..632fa5374f --- /dev/null +++ b/scripts/test_capture_linux_incident.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Deterministic /proc fixtures for the incident collector.""" +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import capture_linux_incident as capture + + +def stat(cpu=10, start=123, rss=100, comm="server (worker) name"): + fields = ["0"] * 22 + fields[0], fields[11], fields[12] = "R", str(cpu), "2" + fields[19], fields[20], fields[21] = str(start), "8192000", str(rss) + return "42 (" + comm + ") " + " ".join(fields) + + +def sample(at, cpu, rss, start=123): + return {"monotonic_seconds": at, "files": {"stat": stat(cpu, rss=rss)}, + "threads": {"42": {"stat": stat(cpu, start), "wchan": "0"}}} + + +class CaptureTests(unittest.TestCase): + def test_stat_with_parentheses_and_spaces(self): + parsed = capture.parse_stat(stat()) + self.assertEqual(parsed["comm"], "server (worker) name") + self.assertEqual(parsed["cpu_ticks"], 12) + self.assertEqual(parsed["start_ticks"], 123) + self.assertEqual(parsed["rss_pages"], 100) + + def test_cpu_and_growth_use_actual_time_ticks_and_page_size(self): + result = capture.summarize([sample(10, 0, 100), sample(12, 200, 110)], 100, 4096) + row = result["intervals"][0] + self.assertEqual(row["rss_delta_bytes"], 40960) + self.assertEqual(row["rss_bytes_per_second"], 20480) + self.assertEqual(row["threads"][0]["cpu_percent_one_core"], 100) + + def test_reused_tid_is_not_reported_as_cpu(self): + result = capture.summarize([sample(10, 0, 100), sample(12, 200, 110, start=456)], 100, 4096) + self.assertEqual(result["intervals"][0]["threads"], []) + + def test_missing_thread_stat_preserves_memory_summary(self): + before, after = sample(10, 0, 100), sample(12, 200, 110) + after["threads"]["42"]["stat"] = None + result = capture.summarize([before, after], 100, 4096)["intervals"][0] + self.assertEqual(result["rss_delta_bytes"], 40960) + self.assertTrue(result["errors"]) + + def test_partial_snapshot_records_missing_files(self): + with tempfile.TemporaryDirectory() as directory: + process = Path(directory) + (process / "stat").write_text(stat()) + task = process / "task" / "42" + task.mkdir(parents=True) + (task / "stat").write_text(stat()) + result = capture.snapshot(process, 123) + self.assertEqual(result["threads_seen"], 1) + self.assertIsNone(result["files"]["smaps_rollup"]) + self.assertTrue(result["errors"]) + + def test_pid_reuse_before_or_during_capture_is_rejected(self): + with patch.object(capture, "identity", return_value=456): + with self.assertRaisesRegex(RuntimeError, "reused"): + capture.snapshot(Path("/unused"), 123) + with tempfile.TemporaryDirectory() as directory: + process = Path(directory) + (process / "task").mkdir() + with patch.object(capture, "identity", side_effect=[123, 456]): + with self.assertRaisesRegex(RuntimeError, "changed"): + capture.snapshot(process, 123) + + def test_read_is_bounded(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "maps" + source.write_text("x" * 50) + errors = [] + with patch.object(capture, "MAX_FILE_BYTES", 10): + self.assertEqual(capture.read_file(source, errors), "x" * 10) + self.assertIn("truncated", errors[0]) + + +if __name__ == "__main__": + unittest.main() From 499bb5d8d1019b5a3753dd02a58eb90b64afcd73 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Mon, 7 Sep 2026 00:42:15 -0400 Subject: [PATCH 4/9] docs: key incident capture changeset to PR 9947 (cherry picked from commit 08720845e3f69db0662dab0c3401f5a6028a2fa9) --- ...2-linux-incident-capture.md => 9947-linux-incident-capture.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9942-linux-incident-capture.md => 9947-linux-incident-capture.md} (100%) diff --git a/changelog.d/9942-linux-incident-capture.md b/changelog.d/9947-linux-incident-capture.md similarity index 100% rename from changelog.d/9942-linux-incident-capture.md rename to changelog.d/9947-linux-incident-capture.md From 481e20bfe5f4b6119494f3b7352a675a18ecfb1c Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Mon, 7 Sep 2026 00:44:24 -0400 Subject: [PATCH 5/9] test(drizzle): stress SQL and parameter prefixes across collection (cherry picked from commit 5ecf8827426be101650595cdb577f3c686f18e20) --- changelog.d/9935-drizzle-sql-prefix-probe.md | 3 + .../packages/drizzle-sql-prefix/README.md | 58 ++++++++ .../packages/drizzle-sql-prefix/entry.ts | 108 ++++++++++++++ .../packages/drizzle-sql-prefix/expected.txt | 1 + .../packages/drizzle-sql-prefix/fixture.sh | 10 ++ .../drizzle-sql-prefix/package-lock.json | 140 ++++++++++++++++++ .../packages/drizzle-sql-prefix/package.json | 12 ++ 7 files changed, 332 insertions(+) create mode 100644 changelog.d/9935-drizzle-sql-prefix-probe.md create mode 100644 tests/release/packages/drizzle-sql-prefix/README.md create mode 100644 tests/release/packages/drizzle-sql-prefix/entry.ts create mode 100644 tests/release/packages/drizzle-sql-prefix/expected.txt create mode 100755 tests/release/packages/drizzle-sql-prefix/fixture.sh create mode 100644 tests/release/packages/drizzle-sql-prefix/package-lock.json create mode 100644 tests/release/packages/drizzle-sql-prefix/package.json diff --git a/changelog.d/9935-drizzle-sql-prefix-probe.md b/changelog.d/9935-drizzle-sql-prefix-probe.md new file mode 100644 index 0000000000..e820b310a7 --- /dev/null +++ b/changelog.d/9935-drizzle-sql-prefix-probe.md @@ -0,0 +1,3 @@ +Added a database-free Drizzle SQL-prefix stress fixture with exact SQL and +parameter assertions, wide chunk arrays, and explicit GC windows. It provides +investigation coverage for #9935 without claiming the production issue is fixed. diff --git a/tests/release/packages/drizzle-sql-prefix/README.md b/tests/release/packages/drizzle-sql-prefix/README.md new file mode 100644 index 0000000000..f256aeb2fe --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/README.md @@ -0,0 +1,58 @@ +# SQL prefix stress probe (#9935) + +This fixture uses the reported `drizzle-orm@0.44.7` query-construction code, +without a database, driver, credentials, or network requests. It checks the +exact SQL text and every parameter for the reported four-predicate SELECT and +a 40-extra-predicate variant that repeatedly grows the chunk/parameter arrays. +It retains previous query results and collects between constructing the head +and appending the tail, and after materializing SQL. + +This is an investigation probe. Passing does **not** establish that the rare +Linux production failure is fixed, or exclude a driver/transaction/async path. +Failing gives a smaller boundary to investigate before involving MySQL. + +Install and check against the Node version in `.node-version`: + +```sh +cd tests/release/packages/drizzle-sql-prefix +npm ci --ignore-scripts +node --expose-gc --experimental-strip-types entry.ts > node-out.txt +diff -u expected.txt node-out.txt +``` + +Run the usual release fixture using a compiler and static runtime built from +the same source tree: + +```sh +PERRY_BIN=/absolute/path/to/perry bash fixture.sh +``` + +For a standalone stress run, compile once and execute the same binary under +both normal collection and forced moving collection: + +```sh +"$PERRY_BIN" compile entry.ts -o out +PERRY_SQL_PREFIX_ITERATIONS=10000 ./out +PERRY_SQL_PREFIX_ITERATIONS=10000 PERRY_GC_DIAG=1 \ + PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ + ./out > forced.out 2> forced.log +``` + +For additional collection windows *inside* Drizzle's loops, use a scheduled +run (loop polls must be present in the compiled binary): + +```sh +PERRY_SQL_PREFIX_ITERATIONS=1000 PERRY_GC_DIAG=1 \ + PERRY_GC_SCHEDULE_SEED=9935 PERRY_GC_SCHEDULE_RATE=0.05 \ + PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 \ + ./out > scheduled.out 2> scheduled.log +``` + +Record exit status, the exact build commit/platform, seed, stdout, and stderr. +Use an external timeout for unattended runs. An explicit `gc()` count only +proves that the fixture called `gc`; a moving-GC result also needs runtime +evidence that copying/evacuation actually ran. Inspect `[gc-copy-minor] ran` +and `[gc-fromspace-protect] retired_set` records before claiming that coverage. +If a verifier fails before a SQL assertion, preserve that diagnostic separately; +it is not proof that SQL lost its prefix. Issue #9942's hang/leak may be related, +but this fixture does not assume that connection. diff --git a/tests/release/packages/drizzle-sql-prefix/entry.ts b/tests/release/packages/drizzle-sql-prefix/entry.ts new file mode 100644 index 0000000000..349b8a0112 --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/entry.ts @@ -0,0 +1,108 @@ +// #9935: check Drizzle's SQL/parameter prefixes before they reach a driver. +// This is an investigation probe, not a demonstrated reproduction of the +// production failure. Keep both the original four predicates and a wider +// variant that forces the SQL chunk/parameter arrays to grow repeatedly. +import { and, asc, eq, isNotNull, lte } from "drizzle-orm"; +import { datetime, int, mysqlTable, QueryBuilder, varchar } from "drizzle-orm/mysql-core"; + +declare function gc(): void; + +const iterations = Number(process.env.PERRY_SQL_PREFIX_ITERATIONS ?? "1000"); +if (!Number.isInteger(iterations) || iterations < 1 || iterations > 1000000) { + throw new Error("PERRY_SQL_PREFIX_ITERATIONS must be an integer from 1 to 1000000"); +} +if (typeof gc !== "function") { + throw new Error("explicit GC is required; use node --expose-gc for the oracle"); +} + +const auctions = mysqlTable("auctions", { + id: int("id").primaryKey(), + status: varchar("status", { length: 32 }), + format: varchar("format", { length: 32 }), + endsAt: datetime("endsAt"), +}); +const builder = new QueryBuilder(); +const at = new Date("2026-09-07T12:00:00.000Z"); +const expectedDate = "2026-09-07 12:00:00.000"; +const prefix = "select `id` from `auctions` where ("; +const baseConditions = "`auctions`.`status` = ? and `auctions`.`format` = ? and `auctions`.`endsAt` is not null and `auctions`.`endsAt` <= ?"; +const suffix = ") order by `auctions`.`endsAt` asc limit ?"; +type Query = { sql: string; params: unknown[] }; + +function check(query: Query, expectedSql: string, expectedParams: unknown[], context: string) { + if (query.sql !== expectedSql) { + throw new Error(context + ": SQL mismatch\nexpected: " + expectedSql + "\nactual: " + query.sql); + } + if (!Array.isArray(query.params) || query.params.length !== expectedParams.length) { + throw new Error(context + ": parameter length mismatch, expected " + expectedParams.length); + } + for (let index = 0; index < expectedParams.length; index++) { + if (query.params[index] !== expectedParams[index]) { + throw new Error(context + ": parameter " + index + " mismatch, expected " + expectedParams[index] + ", actual " + query.params[index]); + } + } +} + +// Fixed expected structure; changing iteration values prevent a cached answer +// or a result from an earlier query from satisfying the assertions. +const width = 40; +let wideConditions = baseConditions; +for (let index = 0; index < width; index++) { + wideConditions += " and `auctions`.`id` = ?"; +} +let checked = 0; +let collections = 0; +let previous: Query | undefined; +let previousParams: unknown[] = []; +for (let iteration = 0; iteration < iterations; iteration++) { + const status = "live-" + iteration; + const format = "auction-" + iteration; + const limit = 50 + iteration % 7; + const originalParams: unknown[] = [status, format, expectedDate, limit]; + const original = builder.select({ id: auctions.id }).from(auctions).where(and( + eq(auctions.status, status), + eq(auctions.format, format), + isNotNull(auctions.endsAt), + lte(auctions.endsAt, at), + )); + if (iteration % 16 === 0) { + // Keep the already-built head alive while the collector runs, before + // the orderBy/limit tail is appended (the reported missing-head shape). + gc(); + collections++; + } + const query = original.orderBy(asc(auctions.endsAt)).limit(limit).toSQL(); + check(query, prefix + baseConditions + suffix, originalParams, "original iteration " + iteration); + checked++; + + const conditions = [ + eq(auctions.status, status), eq(auctions.format, format), + isNotNull(auctions.endsAt), lte(auctions.endsAt, at), + ]; + const wideParams: unknown[] = [status, format, expectedDate]; + for (let index = 0; index < width; index++) { + const value = iteration * width + index; + conditions.push(eq(auctions.id, value)); + wideParams.push(value); + } + wideParams.push(limit); + const wide = builder.select({ id: auctions.id }).from(auctions) + .where(and(...conditions)).orderBy(asc(auctions.endsAt)).limit(limit).toSQL(); + if (iteration % 16 === 0) { + gc(); + collections++; + } + check(wide, prefix + wideConditions + suffix, wideParams, "wide iteration " + iteration); + check(query, prefix + baseConditions + suffix, originalParams, "retained current " + iteration); + checked += 2; + if (previous !== undefined) { + check(previous, prefix + wideConditions + suffix, previousParams, "retained previous " + iteration); + checked++; + } + previous = wide; + previousParams = wideParams; +} +if (checked !== 4 * iterations - 1 || collections === 0) { + throw new Error("the stress probe did not exercise every assertion and collection window"); +} +console.log("sql-prefix-stress: iterations=" + iterations + " checked=" + checked + " explicit_gc=" + collections); diff --git a/tests/release/packages/drizzle-sql-prefix/expected.txt b/tests/release/packages/drizzle-sql-prefix/expected.txt new file mode 100644 index 0000000000..15d68af1c0 --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/expected.txt @@ -0,0 +1 @@ +sql-prefix-stress: iterations=1000 checked=3999 explicit_gc=126 diff --git a/tests/release/packages/drizzle-sql-prefix/fixture.sh b/tests/release/packages/drizzle-sql-prefix/fixture.sh new file mode 100755 index 0000000000..278d31455a --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/fixture.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +source ../_fixture_lib.sh + +# No database or mysql2 connection is needed. Keep the package version pinned +# to the production report; compiler/runtime linking remains the harness's job. +fixture_setup "drizzle-sql-prefix" +export PERRY_SQL_PREFIX_ITERATIONS=1000 +fixture_compile_run_diff "drizzle-sql-prefix" diff --git a/tests/release/packages/drizzle-sql-prefix/package-lock.json b/tests/release/packages/drizzle-sql-prefix/package-lock.json new file mode 100644 index 0000000000..44209f245e --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/package-lock.json @@ -0,0 +1,140 @@ +{ + "name": "perry-release-fixture-drizzle-sql-prefix", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-release-fixture-drizzle-sql-prefix", + "version": "0.0.0", + "dependencies": { + "drizzle-orm": "0.44.7" + } + }, + "node_modules/drizzle-orm": { + "version": "0.44.7", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.7.tgz", + "integrity": "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + } + } +} diff --git a/tests/release/packages/drizzle-sql-prefix/package.json b/tests/release/packages/drizzle-sql-prefix/package.json new file mode 100644 index 0000000000..c059b0f2d3 --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/package.json @@ -0,0 +1,12 @@ +{ + "name": "perry-release-fixture-drizzle-sql-prefix", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Database-free SQL and parameter prefix stress probe for issue #9935.", + "dependencies": { "drizzle-orm": "0.44.7" }, + "perry": { + "compilePackages": ["drizzle-orm"], + "allow": { "compilePackages": ["drizzle-orm"] } + } +} From 9054d9cfe80e69f77caee38e01cbc51792b01eb5 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Mon, 7 Sep 2026 00:45:28 -0400 Subject: [PATCH 6/9] docs: key SQL prefix changeset to PR 9948 (cherry picked from commit e3d11d9350254710dcc942c814bd415ec191c315) --- ...izzle-sql-prefix-probe.md => 9948-drizzle-sql-prefix-probe.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9935-drizzle-sql-prefix-probe.md => 9948-drizzle-sql-prefix-probe.md} (100%) diff --git a/changelog.d/9935-drizzle-sql-prefix-probe.md b/changelog.d/9948-drizzle-sql-prefix-probe.md similarity index 100% rename from changelog.d/9935-drizzle-sql-prefix-probe.md rename to changelog.d/9948-drizzle-sql-prefix-probe.md From 6e2a352327f45a670c3c59bef65b52630da69955 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Mon, 7 Sep 2026 00:47:53 -0400 Subject: [PATCH 7/9] docs: use a bounded scheduled-GC probe example (cherry picked from commit f8a33e212145acfd970624a031cebe51ea2bbc80) --- tests/release/packages/drizzle-sql-prefix/README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/release/packages/drizzle-sql-prefix/README.md b/tests/release/packages/drizzle-sql-prefix/README.md index f256aeb2fe..32e3cbf1a8 100644 --- a/tests/release/packages/drizzle-sql-prefix/README.md +++ b/tests/release/packages/drizzle-sql-prefix/README.md @@ -42,17 +42,22 @@ For additional collection windows *inside* Drizzle's loops, use a scheduled run (loop polls must be present in the compiled binary): ```sh -PERRY_SQL_PREFIX_ITERATIONS=1000 PERRY_GC_DIAG=1 \ +PERRY_SQL_PREFIX_ITERATIONS=10 PERRY_GC_DIAG=1 \ PERRY_GC_SCHEDULE_SEED=9935 PERRY_GC_SCHEDULE_RATE=0.05 \ PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 \ ./out > scheduled.out 2> scheduled.log ``` +Start with the small scheduled run: removing allocation pacing can produce +thousands of collections in only ten iterations. Scale it separately from the +ordinary 1,000-iteration acceptance run. + Record exit status, the exact build commit/platform, seed, stdout, and stderr. Use an external timeout for unattended runs. An explicit `gc()` count only proves that the fixture called `gc`; a moving-GC result also needs runtime evidence that copying/evacuation actually ran. Inspect `[gc-copy-minor] ran` -and `[gc-fromspace-protect] retired_set` records before claiming that coverage. +records and `[gc-fromspace-protect]` lines containing `retired_set=` before +claiming that coverage. If a verifier fails before a SQL assertion, preserve that diagnostic separately; it is not proof that SQL lost its prefix. Issue #9942's hang/leak may be related, but this fixture does not assume that connection. From b71b94a4d9b180a4c7dc6a992eb493cb543f7582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 04:56:54 +0200 Subject: [PATCH 8/9] fix(module): synchronize named builtin exports (cherry picked from commit bf874497ebc3b73221a19f3e2dd8196cd7db1bc9) --- .../9944-module-sync-builtin-exports.md | 1 + .../src/runtime_decls/objects.rs | 5 +++ crates/perry-hir/src/lower/expr_call/mod.rs | 14 ++++++++ .../src/lower/expr_call/native_module.rs | 29 +++++++++++++++- .../perry-hir/src/lower/lower_expr/helpers.rs | 26 ++++++++++++-- crates/perry-hir/src/lower/module_decl.rs | 2 ++ .../module_decl/static_import_bindings.rs | 30 ++++++++++++++++ crates/perry-hir/src/lower/tests.rs | 27 +++++++++------ .../src/lower/tests/native_module_sync.rs | 27 +++++++++++++++ .../perry-runtime/src/object/native_module.rs | 34 +++++++++++++------ .../module/methods/sync-builtin-exports.ts | 5 +-- 11 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 changelog.d/9944-module-sync-builtin-exports.md create mode 100644 crates/perry-hir/src/lower/tests/native_module_sync.rs diff --git a/changelog.d/9944-module-sync-builtin-exports.md b/changelog.d/9944-module-sync-builtin-exports.md new file mode 100644 index 0000000000..8afb9ea05b --- /dev/null +++ b/changelog.d/9944-module-sync-builtin-exports.md @@ -0,0 +1 @@ +Fixed named imports from Node builtins to retain their ESM export value until `syncBuiltinESMExports()` copies CommonJS namespace changes. diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 1a9b60f941..e1c704af85 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -298,6 +298,11 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { DOUBLE, &[DOUBLE, DOUBLE], ); + module.declare_function( + "js_native_module_named_esm_export_value", + DOUBLE, + &[DOUBLE, DOUBLE], + ); // Issue #894: materialize a NATIVE_MODULE_CLASS_ID-tagged namespace // object for `Expr::NativeModuleRef` when it reaches the value-form // fallback path (the require-call-result-then-member-access shape diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 752b4a4d98..ed277d5c23 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -516,6 +516,20 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result bool { /// `satisfies`, angle-bracket assertions, parens) off an expression so a /// cast receiver like `(Readable as any).toWeb(...)` still matches the /// bare-identifier module/class shape the dispatch arms below expect. -fn unwrap_ts_wrappers(e: &ast::Expr) -> &ast::Expr { +pub(super) fn unwrap_ts_wrappers(e: &ast::Expr) -> &ast::Expr { let mut cur = e; loop { match cur { @@ -402,6 +402,33 @@ pub(super) fn is_node_builtin_module_call(ctx: &LoweringContext, callee: &ast::E } } +/// A module that can call `syncBuiltinESMExports()` must invoke named Node +/// imports through their ESM export cells. The ordinary native fast path calls +/// the built-in implementation directly and would therefore ignore a CommonJS +/// replacement copied into the cell by the sync operation. +pub(super) fn named_import_call_needs_esm_binding( + ctx: &LoweringContext, + callee: &ast::Expr, +) -> bool { + let ast::Expr::Ident(ident) = unwrap_ts_wrappers(callee) else { + return false; + }; + let Some((module, Some(export))) = ctx.lookup_native_module(ident.sym.as_ref()) else { + return false; + }; + if !is_node_core(module) + || export == "default" + || (module.strip_prefix("node:").unwrap_or(module) == "module" + && export == "syncBuiltinESMExports") + { + return false; + } + ctx.native_modules.iter().any(|(_, module, method)| { + module.strip_prefix("node:").unwrap_or(module) == "module" + && method.as_deref() == Some("syncBuiltinESMExports") + }) +} + /// node-forge sub-namespace flattening. Unlike the single-level `ns.method()` /// shape the other arms match, forge's API is deeply nested: /// `forge.pki.rsa.generateKeyPair(...)`, `forge.pki.createCertificate()`, diff --git a/crates/perry-hir/src/lower/lower_expr/helpers.rs b/crates/perry-hir/src/lower/lower_expr/helpers.rs index 45679dda18..6c4babbd35 100644 --- a/crates/perry-hir/src/lower/lower_expr/helpers.rs +++ b/crates/perry-hir/src/lower/lower_expr/helpers.rs @@ -381,6 +381,13 @@ pub(crate) fn native_module_binding_value(ctx: &LoweringContext, name: &str) -> } } if let Some(method) = method_name { + if method == "default" { + return Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::NativeModuleRef(module_name.to_string())), + property: method.to_string(), + }; + } // #3946: a `node:process` *property* imported by name // (`import { pid, arch } from "node:process"`) must read // the live process value, not a generic native-module @@ -391,10 +398,23 @@ pub(crate) fn native_module_binding_value(ctx: &LoweringContext, name: &str) -> return e; } } - return Expr::PropertyGet { + // A named ESM import is a live binding to Node's builtin ESM export + // cell, whose value changes only when syncBuiltinESMExports() updates + // that cell. Keep this value read distinct from a property read on a + // default/namespace import, which must observe CommonJS monkey patches + // immediately. + return Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_native_module_named_esm_export_value".to_string(), + param_types: vec![Type::String, Type::String], + return_type: Type::Any, + }), + args: vec![ + Expr::String(module_name.to_string()), + Expr::String(method.to_string()), + ], + type_args: vec![], byte_offset: 0, - object: Box::new(Expr::NativeModuleRef(module_name.to_string())), - property: method.to_string(), }; } if ctx.lookup_builtin_module_alias(name).is_none() diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 66a1843fa1..f39c7dbbb5 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -24,6 +24,7 @@ use native_default_import::{ node_submodule_default_export_key, }; use object_literal::is_direct_object_literal; +use static_import_bindings::init_named_cell; pub(super) use static_import_bindings::{ import_is_runtime_erased, pre_register_static_import_bindings, }; @@ -247,6 +248,7 @@ pub(crate) fn lower_module_decl( } else { (source.clone(), Some(imported.clone())) }; + init_named_cell(module, &source, &imported, native_method.as_ref()); ctx.register_native_module(local.clone(), native_module, native_method); // #1991: `perry/ui` exposes these as numeric // `const enum`s in `types/perry/ui/index.d.ts`. diff --git a/crates/perry-hir/src/lower/module_decl/static_import_bindings.rs b/crates/perry-hir/src/lower/module_decl/static_import_bindings.rs index a0ab100da3..d354c6846f 100644 --- a/crates/perry-hir/src/lower/module_decl/static_import_bindings.rs +++ b/crates/perry-hir/src/lower/module_decl/static_import_bindings.rs @@ -4,6 +4,36 @@ use super::*; use swc_ecma_ast as ast; +/// Initialize a Node named import's snapshot-backed ESM export cell before +/// top-level user code can mutate the CommonJS namespace. This also preserves +/// the original value when the binding's first read happens after a mutation. +pub(super) fn init_named_cell( + module: &mut Module, + source: &str, + imported: &str, + method: Option<&String>, +) { + if method.is_none() || !perry_api_manifest::is_node_core_module(source) { + return; + } + module.init.insert( + 0, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_native_module_named_esm_export_value".to_string(), + param_types: vec![Type::String, Type::String], + return_type: Type::Any, + }), + args: vec![ + Expr::String(source.to_string()), + Expr::String(imported.to_string()), + ], + type_args: vec![], + byte_offset: 0, + }), + ); +} + /// Register ordinary source-module import bindings before statement lowering. /// /// ESM imports are module-scoped and hoisted regardless of where their diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 946f1264e9..353f4883df 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -486,7 +486,8 @@ fn test_lower_native_module_registration() { fn test_native_module_binding_value_named_import() { // #5242: a named builtin import (`import { relative } from 'path'`) used // as a value (e.g. an object-literal shorthand `{ relative }`) must resolve - // to the callable builtin — `path.relative` — not be dropped to undefined. + // to the snapshot-backed builtin export — `path.relative` — not be dropped + // to undefined or conflated with the mutable default namespace property. let mut ctx = make_ctx(); ctx.register_native_module( "relative".to_string(), @@ -494,15 +495,20 @@ fn test_native_module_binding_value_named_import() { Some("relative".to_string()), ); let value = super::lower_expr::native_module_binding_value(&ctx, "relative"); - match value { - crate::ir::Expr::PropertyGet { - object, property, .. - } => { - assert_eq!(property, "relative"); - assert!(matches!(*object, crate::ir::Expr::NativeModuleRef(ref m) if m == "path")); - } - other => panic!("expected PropertyGet(path.relative), got {other:?}"), - } + assert!(matches!( + value, + crate::ir::Expr::Call { callee, args, .. } + if matches!( + callee.as_ref(), + crate::ir::Expr::ExternFuncRef { name, .. } + if name == "js_native_module_named_esm_export_value" + ) + && matches!( + args.as_slice(), + [crate::ir::Expr::String(module), crate::ir::Expr::String(property)] + if module == "path" && property == "relative" + ) + )); } #[test] @@ -1978,6 +1984,7 @@ mod unresolved_new_global; mod capture_stash; mod mixin_parent_chain; +mod native_module_sync; mod nullish_over_optional_chain; mod ui_widget_add_child; diff --git a/crates/perry-hir/src/lower/tests/native_module_sync.rs b/crates/perry-hir/src/lower/tests/native_module_sync.rs new file mode 100644 index 0000000000..ee45c71208 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/native_module_sync.rs @@ -0,0 +1,27 @@ +#[test] +fn direct_named_calls_use_the_synchronized_esm_cell() { + let source = r#" +import { readFile } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +syncBuiltinESMExports(); +readFile(); +"#; + let module = perry_parser::parse_typescript(source, "sync-builtins.ts").expect("source parses"); + let hir = super::super::lower_module(&module, "sync-builtins", "sync-builtins.ts") + .expect("source lowers"); + let dump = format!("{:#?}", hir.init); + assert!( + dump.contains("js_native_module_named_esm_export_value"), + "named call must read the synchronized ESM cell: {dump}" + ); + assert!( + dump.matches("js_native_module_named_esm_export_value") + .count() + >= 2, + "named import must initialize its ESM cell before the dynamic call: {dump}" + ); + assert!( + !dump.contains("module: \"fs\",\n class_name: None,\n object: None,\n method: \"readFile\""), + "named call must not bypass the ESM cell through native dispatch: {dump}" + ); +} diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index d427223761..10c4746ab3 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1069,10 +1069,7 @@ fn native_module_string_arg(value: f64) -> Option { Some(String::from_utf8_lossy(bytes).into_owned()) } -/// Snapshot-backed value used for named ESM imports from builtins. CommonJS -/// namespace writes stay isolated until `syncBuiltinESMExports()` copies them. -#[no_mangle] -pub extern "C" fn js_native_module_esm_export_value(module: f64, property: f64) -> f64 { +fn native_module_export_value(module: f64, property: f64, observe_namespace_writes: bool) -> f64 { let Some(module) = native_module_string_arg(module) else { return f64::from_bits(crate::value::TAG_UNDEFINED); }; @@ -1080,13 +1077,14 @@ pub extern "C" fn js_native_module_esm_export_value(module: f64, property: f64) return f64::from_bits(crate::value::TAG_UNDEFINED); }; let module = normalize_native_module_alias(&module).to_string(); - // A user write to the member wins over the built-in snapshot below — - // this entry also serves property reads off the DEFAULT export object - // (`import fs from "node:fs"; fs.rename` after graceful-fs patched it), - // which is Node's live mutable CJS exports object. See - // `native_namespace_user_value`. - if let Some(value) = native_namespace_user_value(&module, &property) { - return value; + if observe_namespace_writes { + // A user write to the member wins over the built-in snapshot below. + // Default and namespace imports expose Node's live mutable CommonJS + // exports object. Named imports pass false and retain their ESM cell + // until syncBuiltinESMExports() refreshes the shared cache. + if let Some(value) = native_namespace_user_value(&module, &property) { + return value; + } } let key = format!("{module}\0{property}"); if let Some(bits) = NATIVE_ESM_EXPORT_VALUES.with(|values| values.borrow().get(&key).copied()) { @@ -1111,6 +1109,20 @@ pub extern "C" fn js_native_module_esm_export_value(module: f64, property: f64) value } +/// Mutable property read used by native-module default and namespace objects. +/// User writes to the CommonJS namespace are observable immediately here. +#[no_mangle] +pub extern "C" fn js_native_module_esm_export_value(module: f64, property: f64) -> f64 { + native_module_export_value(module, property, true) +} + +/// Snapshot-backed value used for named ESM imports from builtins. CommonJS +/// namespace writes stay isolated until `syncBuiltinESMExports()` copies them. +#[no_mangle] +pub extern "C" fn js_native_module_named_esm_export_value(module: f64, property: f64) -> f64 { + native_module_export_value(module, property, false) +} + pub(crate) fn module_constructor_identity_value() -> f64 { const KEY: &str = "module\0Module"; if let Some(bits) = NATIVE_ESM_EXPORT_VALUES.with(|values| values.borrow().get(KEY).copied()) { diff --git a/test-parity/node-suite/module/methods/sync-builtin-exports.ts b/test-parity/node-suite/module/methods/sync-builtin-exports.ts index 1740d90b2e..e68d150299 100644 --- a/test-parity/node-suite/module/methods/sync-builtin-exports.ts +++ b/test-parity/node-suite/module/methods/sync-builtin-exports.ts @@ -4,13 +4,14 @@ import { createRequire, syncBuiltinESMExports } from "node:module"; const req = createRequire(import.meta.url); const cjsFs = req("node:fs"); const original = cjsFs.readFile; -const replacement = function parityReadFile() {}; +const replacement = function parityReadFile() { return "replacement result"; }; try { - console.log("initial identity:", fsDefault === cjsFs, readFile === original); + console.log("initial identity:", fsDefault === cjsFs); cjsFs.readFile = replacement; console.log("before sync:", readFile === original, readFile === replacement); console.log("return:", String(syncBuiltinESMExports())); console.log("after sync:", readFile === original, readFile === replacement); + console.log("after sync call:", (readFile as () => string)()); } finally { cjsFs.readFile = original; syncBuiltinESMExports(); From 8fbaa4b65e8d1957960626421f75691857e03acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 05:51:04 +0200 Subject: [PATCH 9/9] fix(fs): expose embedded asset metadata (cherry picked from commit 5782a8daf1e3763c5b71d3d00e9340ad8f335763) --- changelog.d/9945-embedded-fs-metadata.md | 1 + crates/perry-runtime/src/embedded.rs | 165 +++++++++++++++++- crates/perry-runtime/src/fs/dirent.rs | 29 +++ crates/perry-runtime/src/fs/mod.rs | 8 +- crates/perry-runtime/src/fs/stats.rs | 31 ++++ .../perry/tests/issue_5731_embedded_assets.rs | 17 +- 6 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 changelog.d/9945-embedded-fs-metadata.md diff --git a/changelog.d/9945-embedded-fs-metadata.md b/changelog.d/9945-embedded-fs-metadata.md new file mode 100644 index 0000000000..1d58fbcea8 --- /dev/null +++ b/changelog.d/9945-embedded-fs-metadata.md @@ -0,0 +1 @@ +Fixed `statSync`, `lstatSync`, `existsSync`, and `readdirSync` to expose files and inferred directories from the embedded `$perryfs` filesystem. diff --git a/crates/perry-runtime/src/embedded.rs b/crates/perry-runtime/src/embedded.rs index e5380f9ce7..d99600970d 100644 --- a/crates/perry-runtime/src/embedded.rs +++ b/crates/perry-runtime/src/embedded.rs @@ -20,6 +20,7 @@ //! The global never frees (matching Perry's "embedded data lives for the life of //! the process" model), mirroring the `crate::shared_sab` registry pattern. +use std::collections::BTreeMap; use std::sync::{Mutex, OnceLock}; use crate::object::{js_object_alloc, js_object_set_field_by_name, ObjectHeader}; @@ -43,6 +44,21 @@ struct EmbeddedAsset { bytes: &'static [u8], } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct EmbeddedMetadata { + pub(crate) is_file: bool, + pub(crate) is_directory: bool, + pub(crate) size: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct EmbeddedDirEntry { + pub(crate) relative_path: String, + pub(crate) name: String, + pub(crate) parent_path: String, + pub(crate) is_directory: bool, +} + static EMBEDDED_ASSETS: OnceLock>> = OnceLock::new(); fn registry() -> &'static Mutex> { @@ -59,6 +75,15 @@ fn normalize_key(path: &str) -> String { p.strip_prefix("./").unwrap_or(p).to_string() } +fn perry_virtual_key(path: &str) -> Option<(String, String)> { + let unified = path.replace('\\', "/"); + if unified == "$perryfs" || unified == VIRTUAL_PREFIX { + return Some((String::new(), "$perryfs".to_string())); + } + let key = unified.strip_prefix(VIRTUAL_PREFIX)?.trim_matches('/'); + Some((key.to_string(), format!("$perryfs/{key}"))) +} + /// Register an embedded asset. Called once per file from the generated /// `__attribute__((constructor))` before the runtime starts. Both `name_ptr` /// and `bytes_ptr` point at static literals in the binary, so the recorded @@ -99,6 +124,93 @@ pub fn lookup(path: &str) -> Option<&'static [u8]> { reg.iter().find(|a| a.name == key).map(|a| a.bytes) } +/// Metadata for an embedded file or an inferred `$perryfs` directory. +/// Directories are implicit: every prefix before a registered asset exists. +pub(crate) fn metadata(path: &str) -> Option { + let key = normalize_key(path); + let reg = registry().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(asset) = reg.iter().find(|asset| asset.name == key) { + return Some(EmbeddedMetadata { + is_file: true, + is_directory: false, + size: asset.bytes.len(), + }); + } + let (directory_key, _) = perry_virtual_key(path)?; + let prefix = if directory_key.is_empty() { + String::new() + } else { + format!("{directory_key}/") + }; + reg.iter() + .any(|asset| asset.name.starts_with(&prefix)) + .then_some(EmbeddedMetadata { + is_file: false, + is_directory: true, + size: 0, + }) +} + +/// Sorted children of an inferred `$perryfs` directory. Recursive entries use +/// paths relative to the requested directory, matching Node's string result; +/// `name` and `parent_path` retain the pieces needed to build `fs.Dirent`. +pub(crate) fn read_dir(path: &str, recursive: bool) -> Option> { + let (directory_key, display_path) = perry_virtual_key(path)?; + let prefix = if directory_key.is_empty() { + String::new() + } else { + format!("{directory_key}/") + }; + let reg = registry().lock().unwrap_or_else(|e| e.into_inner()); + let mut paths = BTreeMap::::new(); + for asset in reg.iter() { + let Some(remainder) = asset.name.strip_prefix(&prefix) else { + continue; + }; + if remainder.is_empty() { + continue; + } + let parts = remainder.split('/').collect::>(); + let end = if recursive { parts.len() } else { 1 }; + for index in 1..=end { + let relative_path = parts[..index].join("/"); + let is_directory = index < parts.len(); + paths + .entry(relative_path) + .and_modify(|known_directory| *known_directory |= is_directory) + .or_insert(is_directory); + if !recursive { + break; + } + } + } + if paths.is_empty() { + return None; + } + Some( + paths + .into_iter() + .map(|(relative_path, is_directory)| { + let (parent_relative, name) = relative_path + .rsplit_once('/') + .unwrap_or(("", relative_path.as_str())); + let parent_path = if parent_relative.is_empty() { + display_path.clone() + } else { + format!("{display_path}/{parent_relative}") + }; + let name = name.to_string(); + EmbeddedDirEntry { + relative_path, + name, + parent_path, + is_directory, + } + }) + .collect(), + ) +} + /// True if `path` is an embedded-asset virtual path (carries the `$perryfs/` /// or `/$bunfs/root/` prefix), independent of whether it actually resolves. /// `fs` uses this to treat an unresolved virtual path as missing rather than @@ -106,7 +218,9 @@ pub fn lookup(path: &str) -> Option<&'static [u8]> { /// [`lookup`]. pub fn is_virtual_path(path: &str) -> bool { let unified = path.replace('\\', "/"); - unified.starts_with(VIRTUAL_PREFIX) || unified.starts_with(BUNFS_ROOT_PREFIX) + unified == "$perryfs" + || unified.starts_with(VIRTUAL_PREFIX) + || unified.starts_with(BUNFS_ROOT_PREFIX) } /// Snapshot of `(name, size)` for every embedded asset, in registration order. @@ -308,8 +422,15 @@ mod tests { fn register_and_lookup_by_both_paths() { const NAME: &[u8] = b"embed-test/asset.txt"; const DATA: &[u8] = b"embedded-bytes"; + const NESTED_NAME: &[u8] = b"embed-test/nested/two.bin"; unsafe { js_register_embedded_asset(NAME.as_ptr(), NAME.len(), DATA.as_ptr(), DATA.len()); + js_register_embedded_asset( + NESTED_NAME.as_ptr(), + NESTED_NAME.len(), + DATA.as_ptr(), + DATA.len(), + ); } // Found by bare key, by `$perryfs/` virtual path, and via backslashes. assert_eq!(lookup("embed-test/asset.txt"), Some(DATA)); @@ -317,10 +438,52 @@ mod tests { assert_eq!(lookup("$perryfs\\embed-test\\asset.txt"), Some(DATA)); // `is_virtual_path` is a pure prefix test; presence is `lookup`. assert!(is_virtual_path("$perryfs/anything")); + assert!(is_virtual_path("$perryfs")); assert!(is_virtual_path("/$bunfs/root/assets/help.zst")); assert!(!is_virtual_path("not/registered.txt")); assert!(lookup("not/registered.txt").is_none()); assert!(lookup("$perryfs/not-registered").is_none()); + + assert_eq!( + metadata("$perryfs/embed-test/asset.txt"), + Some(EmbeddedMetadata { + is_file: true, + is_directory: false, + size: DATA.len(), + }) + ); + assert_eq!( + metadata("$perryfs/embed-test/nested"), + Some(EmbeddedMetadata { + is_file: false, + is_directory: true, + size: 0, + }) + ); + assert_eq!( + metadata("$perryfs"), + Some(EmbeddedMetadata { + is_file: false, + is_directory: true, + size: 0, + }) + ); + let entries = read_dir("$perryfs/embed-test", false).expect("virtual directory exists"); + assert_eq!( + entries + .iter() + .map(|entry| (entry.name.as_str(), entry.is_directory)) + .collect::>(), + vec![("asset.txt", false), ("nested", true)] + ); + let recursive = read_dir("$perryfs/embed-test", true).expect("virtual directory exists"); + assert_eq!( + recursive + .iter() + .map(|entry| entry.relative_path.as_str()) + .collect::>(), + vec!["asset.txt", "nested", "nested/two.bin"] + ); } #[test] diff --git a/crates/perry-runtime/src/fs/dirent.rs b/crates/perry-runtime/src/fs/dirent.rs index 6fdf20a9ec..c0985eb3b3 100644 --- a/crates/perry-runtime/src/fs/dirent.rs +++ b/crates/perry-runtime/src/fs/dirent.rs @@ -39,6 +39,18 @@ impl DirentKind { } } + fn embedded(is_directory: bool) -> Self { + Self { + is_file: !is_directory, + is_dir: is_directory, + is_symlink: false, + is_block_device: false, + is_character_device: false, + is_fifo: false, + is_socket: false, + } + } + #[cfg(feature = "regex-engine")] pub(crate) fn is_file(self) -> bool { self.is_file @@ -404,6 +416,23 @@ pub extern "C" fn js_fs_readdir_sync(path_value: f64, options_value: f64) -> f64 let recursive = options_bool_field(options_value, b"recursive"); let encoding_buffer = readdir_encoding_buffer(options_value); + if let Some(entries) = crate::embedded::read_dir(&path_str, recursive) { + let mut arr = js_array_alloc(entries.len() as u32); + for entry in &entries { + let value = if with_file_types { + build_dirent_object( + &entry.name, + &entry.parent_path, + DirentKind::embedded(entry.is_directory), + ) + } else { + bytes_to_readdir_value(entry.relative_path.as_bytes(), encoding_buffer) + }; + arr = js_array_push_f64(arr, value); + } + return f64::from_bits(i64::cast_unsigned(arr as i64)); + } + match fs::read_dir(&path_str) { Ok(entries) => { if recursive && !with_file_types { diff --git a/crates/perry-runtime/src/fs/mod.rs b/crates/perry-runtime/src/fs/mod.rs index 71e7958fdf..7875ec484e 100644 --- a/crates/perry-runtime/src/fs/mod.rs +++ b/crates/perry-runtime/src/fs/mod.rs @@ -654,10 +654,10 @@ pub extern "C" fn js_fs_exists_sync(path_value: f64) -> i32 { None => return 0, }; - // #5731 — a registered embedded asset exists for the life of the - // process; an unresolved `$perryfs/...` path does not (and must not - // fall through to a disk check of the literal virtual path). - if crate::embedded::lookup(&path_str).is_some() { + // #5731/#9941 — a registered embedded file or inferred directory + // exists for the life of the process; an unresolved `$perryfs/...` + // path must not fall through to a disk check of the virtual spelling. + if crate::embedded::metadata(&path_str).is_some() { return 1; } if crate::embedded::is_virtual_path(&path_str) { diff --git a/crates/perry-runtime/src/fs/stats.rs b/crates/perry-runtime/src/fs/stats.rs index c1c4755296..c6ed17464c 100644 --- a/crates/perry-runtime/src/fs/stats.rs +++ b/crates/perry-runtime/src/fs/stats.rs @@ -446,6 +446,31 @@ fn metadata_special_file_predicates(meta: Option<&fs::Metadata>) -> (bool, bool, (false, false, false, false) } +unsafe fn embedded_stats(path: &str, bigint: bool) -> Option { + let meta = crate::embedded::metadata(path)?; + let mode = if meta.is_directory { + 0o040555 + } else { + 0o100444 + }; + Some(build_stats_object( + meta.is_file, + meta.is_directory, + false, + meta.size as u64, + mode, + -1.0, + -1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + bigint, + None, + )) +} + /// `fs.statSync(path)` — returns a Stats-like object with Node-compatible /// predicate methods and scalar fields, or throws a Node-shaped fs Error when /// metadata lookup fails. @@ -467,6 +492,9 @@ pub extern "C" fn js_fs_stat_sync_options(path_value: f64, options_value: f64) - ) } }; + if let Some(stats) = embedded_stats(&path_str, bigint) { + return stats; + } match fs::metadata(&path_str) { Ok(meta) => { let is_file = meta.is_file(); @@ -529,6 +557,9 @@ pub extern "C" fn js_fs_lstat_sync_options(path_value: f64, options_value: f64) ) } }; + if let Some(stats) = embedded_stats(&path_str, bigint) { + return stats; + } match fs::symlink_metadata(&path_str) { Ok(meta) => { let ft = meta.file_type(); diff --git a/crates/perry/tests/issue_5731_embedded_assets.rs b/crates/perry/tests/issue_5731_embedded_assets.rs index e1316f1c1d..4862c0820a 100644 --- a/crates/perry/tests/issue_5731_embedded_assets.rs +++ b/crates/perry/tests/issue_5731_embedded_assets.rs @@ -4,7 +4,8 @@ //! At runtime they are reachable three ways, all exercised here: //! * `import { embeddedFiles } from "perry"` — `{ name, size, type }` per asset //! * `import { readEmbedded } from "perry"` — bytes as a `Buffer` -//! * `node:fs` (`readFileSync` / `existsSync`) via the `$perryfs/` path +//! * `node:fs` reads, existence, metadata, and directory listing through the +//! `$perryfs/` virtual filesystem //! plus `isStandaloneExecutable` (always `true` in a compiled binary). //! //! Asset embedding is host-targeted: Unix-like systems compile a `cc` object; @@ -51,7 +52,15 @@ console.log("viaFs:", fs.readFileSync("$perryfs/dist/index.html", "utf8")); const html = files.find(f => f.name === "dist/index.html"); console.log("type:", html.type, "size:", html.size); console.log("exists:", fs.existsSync("$perryfs/dist/assets/app.js")); +console.log("existsDir:", fs.existsSync("$perryfs/dist/assets")); console.log("existsMissing:", fs.existsSync("$perryfs/nope.txt")); +const embeddedStat = fs.statSync("$perryfs/dist/assets/app.js"); +console.log("stat:", embeddedStat.isFile(), embeddedStat.size); +console.log("statDir:", fs.statSync("$perryfs/dist/assets").isDirectory()); +console.log("rootEntries:", fs.readdirSync("$perryfs").join(",")); +console.log("distEntries:", fs.readdirSync("$perryfs/dist").join(",")); +const assetEntry = fs.readdirSync("$perryfs/dist", { withFileTypes: true })[0]; +console.log("dirent:", assetEntry.name, assetEntry.isDirectory(), assetEntry.parentPath); try { readEmbedded("nope.txt"); console.log("throwMissing: no"); } catch (e) { console.log("throwMissing: yes"); } "#, @@ -100,7 +109,13 @@ catch (e) { console.log("throwMissing: yes"); } viaFs: HELLO_EMBED\n\ type: text/html; charset=utf-8 size: 11\n\ exists: true\n\ + existsDir: true\n\ existsMissing: false\n\ + stat: true 14\n\ + statDir: true\n\ + rootEntries: dist\n\ + distEntries: assets,index.html\n\ + dirent: assets true $perryfs/dist\n\ throwMissing: yes\n", "unexpected runtime output" );