Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 4 additions & 12 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3491,15 +3491,9 @@ jobs:
# Proper fix: hoist well-known-binding lookup out of
# build_optimized_libs into link.rs so it runs even when
# auto-optimize is skipped. Tracked separately.
# #8475: the three fastify-importing snippets cannot run under the
# harness's PERRY_NO_AUTO_OPTIMIZE=1 speed flag — `perry compile`
# hard-errors on `import 'fastify'` there, because the prebuilt
# stdlib is not built with `external-fastify-pump` and the request
# loop would hang. Excluded on the same terms as the HTTP aggregate
# above until the harness can run this class in a second,
# auto-optimizing pass. COVERAGE GAP, tracked in #8475 — not a
# statement that these snippets work.
cmd_exclude_gallery: "./scripts/run_doc_tests.sh --verbose --skip-xcompile --filter-exclude ui/gallery.ts --filter-exclude stdlib/http/snippets.ts --filter-exclude getting-started/npm_packages.ts --filter-exclude stdlib/http/fastify_json.ts --filter-exclude stdlib/overview/snippets.ts"
# Fastify examples declare `requires: auto-optimize`; the harness
# builds their specialized libraries and counts them in this run.
cmd_exclude_gallery: "./scripts/run_doc_tests.sh --verbose --skip-xcompile --filter-exclude ui/gallery.ts --filter-exclude stdlib/http/snippets.ts"
cmd_gallery: "./scripts/run_doc_tests.sh --verbose --skip-xcompile --filter ui/gallery.ts"
# Repeat `--xcompile-only-target=…` per target rather than a
# single comma-delimited value because PowerShell splits even
Expand Down Expand Up @@ -3542,9 +3536,7 @@ jobs:
shell: pwsh
# The well-known HTTP aggregate remains excluded on every host
# while its no-auto ext-archive routing issue is tracked.
# #8475: same fastify / PERRY_NO_AUTO_OPTIMIZE exclusion as the
# macOS entry above. COVERAGE GAP, tracked there.
cmd_exclude_gallery: "./scripts/run_doc_tests.ps1 --verbose --skip-xcompile --filter-exclude ui/gallery.ts --filter-exclude stdlib/http/snippets.ts --filter-exclude getting-started/npm_packages.ts --filter-exclude stdlib/http/fastify_json.ts --filter-exclude stdlib/overview/snippets.ts"
cmd_exclude_gallery: "./scripts/run_doc_tests.ps1 --verbose --skip-xcompile --filter-exclude ui/gallery.ts --filter-exclude stdlib/http/snippets.ts"
cmd_gallery: "./scripts/run_doc_tests.ps1 --verbose --skip-xcompile --filter ui/gallery.ts"
cmd_xcompile_blocking: "./scripts/run_doc_tests.ps1 --verbose --xcompile-only --xcompile-only-target=web --xcompile-only-target=wasm"
cmd_xcompile_advisory: "./scripts/run_doc_tests.ps1 --verbose --xcompile-only"
Expand Down
25 changes: 25 additions & 0 deletions changelog.d/9808-element-shape-transfer-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
**A relocated array whose element-shape proof does not exist no longer takes
the side table to find that out** (#9792).

`transfer_element_shape` runs from `layout_transfer` for every relocated
array — growth forwarding, a copying minor, an old-gen defrag. It already
computes `had_bit` for free from two header words it has read anyway, and
then took `ELEMENT_SHAPES`' `RefCell` and hashed both addresses regardless,
for two removes that on the overwhelmingly common path remove nothing. It now
returns when neither address advertises a proof.

The gate has exactly one safe shape and both halves are pinned by
`a_transfer_skips_the_table_only_when_neither_address_advertises_a_proof`.
Skipping on `!had_bit` alone would be wrong: a destination that still
advertises a proof describes storage the move has just replaced, so that case
keeps the full fail-closed path.

What skipping leaves behind is a record at an address whose bit is clear, and
that state was already part of the design rather than new: the bit is the sole
authority for a read (`element_shape_proof` returns `None` before touching the
table, and `note_element_store` is gated on the same bit), and `establish`
draws every identity from `ELEMENT_SHAPE_PROOF_SEQ` rather than from whatever
record sits at the address — precisely so a survivor cannot donate its epoch
to the next array proven there. `prune_dead_element_shape_owners` drops it on
the next collection, the same footprint-only guarantee a fail-closed transfer
already relied on. The test asserts both defences directly.
10 changes: 10 additions & 0 deletions changelog.d/9818-primitive-string-property-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Fix primitive string property reads with computed keys. Named properties now
consult `String.prototype` and preserve the original method value, so reflective
read-then-call code and method identity checks work. Inherited accessors receive
the primitive string as `this`; object and symbol keys follow `ToPropertyKey`.
Character indices and `length` keep precedence over prototype properties, and
boxed strings retain their own-property lookup before their custom prototype.

Cover typed and untyped receivers, short strings, borrowed methods, inherited
accessors, symbol keys, key coercion, and prototype mutation in runtime unit
tests and a Node parity fixture. Direct method-call lowering is unchanged.
25 changes: 25 additions & 0 deletions changelog.d/9819-regex-flags-no-alloc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Performance

- **Constructing a `RegExp` no longer allocates its flags string.** A JS regex
literal evaluates to a fresh `RegExp` object every time it is reached, and
`js_regexp_new` materialized the canonical flags twice per construction: once
as a Rust `String` from `validate_and_canonicalize_flags`, and once as a fresh
GC `StringHeader` for `flags_ptr`. On the claude-code TUI that is **161,897
constructions per 400-character reply** (`PERRY_REGEX_DIAG`) — ~5.2 MB of
identical one- and two-byte GC strings per reply, ~44 MB on a 3300-character
one, and ~1.4 million allocations.

Neither copy is needed. There are eight legal flags, each may appear once, so
the canonical form is at most eight ASCII bytes and now lives inline in a
`CanonicalFlags` value instead of on the heap. And JS strings are immutable
with no identity semantics, so when the caller's flags text already IS the
canonical text — which it is for a literal, whose flags the author wrote in
spec order — the header shares the caller's string rather than duplicating
it. Only a non-canonical spelling (`/x/ig` → `"gi"`) or a computed
`new RegExp(p, f)` still materializes one; the new `flags_alloc` counter in
`PERRY_REGEX_DIAG` reports how often that happens.

This is a **below-the-line** allocation fix by the campaign's own ~10 % rule:
at ~2-3 % of arena traffic per turn it cannot change the collection schedule,
and the cc rig is expected to read flat. It is worth doing because the
allocation is pure waste, not because it moves a benchmark.
6 changes: 6 additions & 0 deletions changelog.d/9820-fastify-doc-test-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Restore the three Fastify documentation examples to the host doc-test CI run.
The examples declare `requires: auto-optimize` in their banners, which lets the
harness rebuild their specialized runtime libraries while ordinary examples
continue using prebuilt archives. Required examples participate in the normal
pass/fail report; compiler failures remain gate failures. Their existing
compile-only setting avoids starting servers or connecting to external services.
1 change: 1 addition & 0 deletions changelog.d/9822-retained-growth-verifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fix a false evacuation-verifier abort when a copying minor encounters a retained, non-moving array-growth alias, such as Solid's effect dependency array. Verification still follows the full forwarding chain and rejects nursery evacuation originals; old-page evacuation retains its strict checks.
28 changes: 28 additions & 0 deletions changelog.d/9823-for-in-deferred-shadow-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
**`for-in` no longer allocates a heap string and a hash entry for every own
name at every prototype level** (#9823).

`js_for_in_keys_value` kept a `HashSet<String>` of every own name — enumerable
or not — at every level of the prototype chain, so that a name owned closer to
the receiver hides the same name further along it (ECMA-262 14.7.5, 12.6.4-2).
It built that set unconditionally, which meant materialising a second key array
per level (all own names, on top of the enumerable ones) and turning every name
at every level into an owned `String` purely so it could be hashed.

That set can only filter a level at or below the first prototype, and a level
that contributes no enumerable keys of its own never consults it. It is now
built on demand — at the moment a prototype level actually has an enumerable
key to filter — from exactly the levels already walked, so the emitted key
sequence is unchanged.

On the compiled claude-code TUI, one 400-character reply: **159,947 `String`
allocations and 159,947 hash inserts become zero**, and the key arrays
materialised per call halve from 4.00 to 2.00. Across 17,281 `for-in` loops in
that reply, **no key was emitted from a prototype level at all**, so the set
that cost all of that filtered nothing. The strings totalled 1.91 MB, which is
why an allocation-byte ranking never surfaced this: the cost was 160,000
mallocs, memcpys, hashes and frees, not the bytes they held. The collection
schedule is unchanged (41 vs 43 copying minors, 46 vs 48 budgeted full-cycle
steps).

`PERRY_ENUM_DIAG=<path>` reports the counters above. `PERRY_FORIN_LAZY_SHADOW=0`
restores the eager set.
40 changes: 35 additions & 5 deletions crates/perry-doc-tests/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ use serde::Serialize;

mod image_diff;
mod lint;
#[cfg(test)]
mod tests;

#[derive(Parser, Debug)]
#[command(name = "doc-tests", about = "Perry documentation-example test harness")]
Expand Down Expand Up @@ -357,6 +359,7 @@ struct Example {
platforms: BTreeSet<String>,
targets: BTreeSet<String>,
compile_only: bool,
requires_auto_optimize: bool,
widget_bundle_id: Option<String>,
}

Expand Down Expand Up @@ -402,6 +405,7 @@ fn discover_examples(root: &Path) -> Result<Vec<Example>> {
platforms: banner.platforms,
targets: banner.targets,
compile_only: banner.compile_only,
requires_auto_optimize: banner.requires_auto_optimize,
widget_bundle_id: banner.widget_bundle_id,
});
}
Expand All @@ -419,6 +423,7 @@ struct Banner {
/// single-program timeout. Catches API/TS drift without the
/// integration-test overhead.
compile_only: bool,
requires_auto_optimize: bool,
/// Required for any `*-widget` / `wearos-tile` target — passed as
/// `--app-bundle-id` on the perry compile invocation.
widget_bundle_id: Option<String>,
Expand Down Expand Up @@ -455,6 +460,18 @@ fn read_banner(path: &Path) -> Result<Banner> {
if v.eq_ignore_ascii_case("false") || v == "0" || v.eq_ignore_ascii_case("no") {
b.compile_only = true;
}
} else if let Some(rest) = body.strip_prefix("requires:") {
for requirement in rest.split(',').map(str::trim) {
match requirement {
"auto-optimize" => b.requires_auto_optimize = true,
_ => {
return Err(anyhow!(
"{}: unknown doc-example requirement `{requirement}`",
path.display()
))
}
}
}
} else if let Some(rest) = body.strip_prefix("widget-bundle-id:") {
let v = rest.trim();
if !v.is_empty() {
Expand Down Expand Up @@ -494,7 +511,7 @@ fn run_one(
});

if !no_compile {
if let Err(e) = compile(perry_bin, &ex.path, &bin_path) {
if let Err(e) = compile(perry_bin, ex, &bin_path) {
return ExampleReport {
file: rel.to_string(),
kind: ex.kind,
Expand Down Expand Up @@ -733,6 +750,7 @@ fn cross_compile_one(
};

let mut cmd = Command::new(perry_bin);
configure_compile_environment(&mut cmd, ex);
cmd.arg("compile")
.arg(&ex.path)
.arg("--target")
Expand Down Expand Up @@ -807,13 +825,25 @@ fn cross_compile_one(
}
}

fn compile(perry_bin: &Path, src: &Path, out: &Path) -> Result<()> {
let out_status = Command::new(perry_bin)
.arg(src)
fn configure_compile_environment(cmd: &mut Command, example: &Example) {
// The host wrappers select prebuilt libraries for the ordinary examples.
// Fastify requires a specialized stdlib with its request pump, so remove
// the override only from this compiler child. Never mutate the harness's
// environment: subsequent examples still benefit from the prebuilt libs.
if example.requires_auto_optimize {
cmd.env_remove("PERRY_NO_AUTO_OPTIMIZE");
}
}

fn compile(perry_bin: &Path, example: &Example, out: &Path) -> Result<()> {
let mut cmd = Command::new(perry_bin);
configure_compile_environment(&mut cmd, example);
let out_status = cmd
.arg(&example.path)
.arg("-o")
.arg(out)
.output()
.with_context(|| format!("launching perry for {}", src.display()))?;
.with_context(|| format!("launching perry for {}", example.path.display()))?;
if !out_status.status.success() {
return Err(anyhow!(
"perry {}: {}",
Expand Down
41 changes: 41 additions & 0 deletions crates/perry-doc-tests/src/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use super::*;

#[test]
fn fastify_examples_request_specialized_compilation_without_being_skipped() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/examples");
for file in [
"getting-started/npm_packages.ts",
"stdlib/http/fastify_json.ts",
"stdlib/overview/snippets.ts",
] {
let banner = read_banner(&root.join(file)).unwrap();
assert!(
banner.requires_auto_optimize,
"{file}: must rebuild the Fastify pump"
);
assert!(
banner.compile_only,
"{file}: requires external services to run"
);
for host in ["macos", "linux", "windows"] {
assert!(
banner.platforms.contains(host),
"{file}: {host} must compile it"
);
}
}
}

#[test]
fn unknown_requirement_is_an_error_instead_of_silently_disabling_coverage() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("typo.ts");
std::fs::write(
&path,
"// requires: auto-optmize\nconsole.log('example');\n",
)
.unwrap();
let error = read_banner(&path).unwrap_err().to_string();
assert!(error.contains("typo.ts"));
assert!(error.contains("unknown doc-example requirement `auto-optmize`"));
}
109 changes: 109 additions & 0 deletions crates/perry-doc-tests/tests/compiler_environment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#![cfg(unix)]

use std::os::unix::fs::PermissionsExt;
use std::process::Command;

fn run_examples(reject_required: bool) -> (std::process::Output, serde_json::Value, String) {
let dir = tempfile::tempdir().unwrap();
let examples = dir.path().join("examples");
std::fs::create_dir(&examples).unwrap();
std::fs::write(
examples.join("required.ts"),
"// requires: auto-optimize\n// run: false\nconsole.log('required');\n",
)
.unwrap();
std::fs::write(
examples.join("ordinary.ts"),
"// run: false\nconsole.log('ordinary');\n",
)
.unwrap();
std::fs::write(
examples.join("z_after.ts"),
"// run: false\nconsole.log('after');\n",
)
.unwrap();
let compiler = dir.path().join("compiler.sh");
std::fs::write(
&compiler,
r#"#!/bin/sh
case "$1" in
*/required.ts)
if test "${PERRY_NO_AUTO_OPTIMIZE+x}" = x; then
echo 'required example inherited PERRY_NO_AUTO_OPTIMIZE' >&2
exit 7
fi
echo required:auto >> "$PERRY_DOC_TEST_COMPILER_LOG"
if test "$PERRY_DOC_TEST_REJECT_REQUIRED" = 1; then
echo 'required example compilation failed' >&2
exit 17
fi
;;
*/ordinary.ts|*/z_after.ts)
if test "$PERRY_NO_AUTO_OPTIMIZE" != 1; then
echo 'ordinary example lost its prebuilt-archive setting' >&2
exit 8
fi
echo "$(basename "$1" .ts):prebuilt" >> "$PERRY_DOC_TEST_COMPILER_LOG"
;;
*) exit 9 ;;
esac
"#,
)
.unwrap();
std::fs::set_permissions(&compiler, std::fs::Permissions::from_mode(0o755)).unwrap();
let report = dir.path().join("report.json");
let log = dir.path().join("compiler.log");
let output = Command::new(env!("CARGO_BIN_EXE_doc-tests"))
.current_dir(env!("CARGO_MANIFEST_DIR"))
.args(["--skip-xcompile", "--examples-dir"])
.arg(&examples)
.arg("--perry")
.arg(&compiler)
.arg("--json")
.arg(&report)
.env("PERRY_NO_AUTO_OPTIMIZE", "1")
.env("PERRY_DOC_TEST_COMPILER_LOG", &log)
.env(
"PERRY_DOC_TEST_REJECT_REQUIRED",
if reject_required { "1" } else { "0" },
)
.output()
.unwrap();
let report = serde_json::from_slice(&std::fs::read(report).unwrap()).unwrap();
(output, report, std::fs::read_to_string(log).unwrap())
}

#[test]
fn required_compilation_removes_only_its_own_no_auto_override() {
let (output, report, calls) = run_examples(false);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
assert_eq!(report["passed"], 3);
assert_eq!(report["failed"], 0);
assert_eq!(report["skipped"], 0);
assert_eq!(
calls,
"ordinary:prebuilt\nrequired:auto\nz_after:prebuilt\n"
);
}

#[test]
fn required_compilation_failures_are_counted_and_fail_the_harness() {
let (output, report, calls) = run_examples(true);
assert_eq!(output.status.code(), Some(1));
assert_eq!(report["passed"], 2);
assert_eq!(report["failed"], 1);
assert_eq!(report["skipped"], 0);
assert_eq!(report["results"][1]["status"], "compile_fail");
assert!(report["results"][1]["detail"]
.as_str()
.unwrap()
.contains("exit=17"));
assert_eq!(
calls,
"ordinary:prebuilt\nrequired:auto\nz_after:prebuilt\n"
);
}
Loading
Loading