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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/gate-failure-watch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Scheduled Gate Failure Watch

# A completed red gate on main is not an alert by itself: #9830 measured one
# correctly failing scheduled workflow that stayed red for nineteen days. This
# observer turns that result into one durable issue per workflow. Repeated reds
# update the same issue with the current rows and their delta; the next green
# closes it.

on:
workflow_run:
workflows:
- Auto-Optimize App Patterns
- CI
- Gate Freshness
- GC Moving Witnesses
- gc-native-roots
- GC Parse-Churn Layout Gate
- GC Ptr<Shape> OFF-arm witness
- GC Ratchet
- GC Root Dominance
- TLS Budget
- eh-transport
- llvm-inprocess
types: [completed]
pull_request:
paths:
- .github/workflows/*.yml
- scripts/gate_failure_watch.json
- scripts/gate_failure_watch.py
- scripts/gate_freshness.json

permissions:
contents: read

concurrency:
group: scheduled-gate-failure-watch-${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || github.run_id }}
cancel-in-progress: false

jobs:
validate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Self-test the failure observer
run: python3 scripts/gate_failure_watch.py --self-test
- name: Check watched-workflow configuration
run: python3 scripts/gate_failure_watch.py --check-config

observe:
if: >-
github.event_name == 'workflow_run' &&
(
github.event.workflow_run.event == 'schedule' ||
(
(github.event.workflow_run.event == 'workflow_dispatch' ||
github.event.workflow_run.event == 'repository_dispatch') &&
github.event.workflow_run.head_branch == 'main'
) ||
(
github.event.workflow_run.event == 'push' &&
(github.event.workflow_run.head_branch == 'main' ||
startsWith(github.event.workflow_run.head_branch, 'v'))
)
)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: read
issues: write
steps:
# workflow_run carries a write-capable token. Execute only the trusted
# default-branch script, never the triggering workflow's checkout.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: main
persist-credentials: false
- name: Open, update, or close the workflow's failure issue
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: python3 scripts/gate_failure_watch.py
54 changes: 54 additions & 0 deletions changelog.d/9860-intl-segmenter-view-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
### Added

- **`Intl.Segmenter` view mode: five runtime entry points that answer a
grapheme loop's questions without materialising a record or a substring.**
The compiler half (PR #9859) proves that a
`for (let {segment: O} of X.segment(q))` loop never lets the record or `O`
escape, and then drives a cursor instead of building either.

```
js_segments_view_open(segmenter, input) -> cursor | 0.0
js_segments_view_next(cursor) -> 1.0 | 0.0 (allocation-free)
js_segments_view_code_point_at(cursor, k) -> number | undefined (allocation-free)
js_segments_view_segment(cursor) -> string (materialise-on-miss)
js_segments_view_regexp_test(cursor, regex) -> true | false | undefined
```

The cursor is an **ordinary GC object** whose slot 0 holds the input as a
traced value, so the collector rewrites it like any other field — no
registered root, no side table, no new scanner. Every entry point re-derives
its `&str` on entry and drops it before returning.

`open` **declines with no observable effect**, in a fixed order: a
non-pristine `Intl.Segmenter`, a replaced `segment`, a non-grapheme
granularity, an input that is not ALREADY a string primitive (checked before
any coercion, because `build_segments` runs user `toString` and throws on a
Symbol), a non-UTF-8 (WTF-8 lone surrogate) input, or an empty one. It never
throws and never allocates before the final step; the compiler then evaluates
`X.segment(q)` exactly once in its original position.

`_code_point_at`'s `k` is **segment-relative and segment-bounded** — `k` past
the segment's end is `undefined` even though the input continues — and decodes
from the cursor's byte offset, so `k = 0` is O(1) rather than a walk from
index 0.

`_regexp_test` matches against a **bounded haystack whose bounds are the
string's ends**, so `^`, `$` and lookbehind are segment-local; it is
three-valued and returns `undefined` ("I decline, materialise and call the
normal path") for a global or sticky regex, whose `test` is stateful in
`lastIndex`, and for a patched `RegExp.prototype.test`.

Affected files:

- `crates/perry-runtime/src/intl/segments_view.rs` — the entry points.
- `crates/perry-runtime/src/regex.rs` — `regexp_test_str_bounded`, the
bounded-haystack primitive.
- `crates/perry-runtime/src/object/regex_proto_thunks.rs` —
`regexp_prototype_test_is_canonical`, the allocation-free proof that
`RegExp.prototype.test` is still the builtin.

Measured: the loop this exists for is 60-85 % of claude-code's active
main-thread CPU and allocates ~420,000 times per 400-character reply. The
falsifier is a unit counter — 200 `next` + `code_point_at` steps move
`arena_in_use_bytes` by **zero**, with the minor-cycle count pinned so a
collection cannot manufacture the zero.
4 changes: 4 additions & 0 deletions changelog.d/9872-mock-timers-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed

- Match Node 26 mock-timer validation by accepting default primitive options
and non-negative infinite clock advances.
1 change: 1 addition & 0 deletions changelog.d/9876-v8-constructor-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Make the `node:v8` class exports throw Node-compatible `TypeError` values when called without `new`, including the expected `ERR_CONSTRUCT_CALL_REQUIRED` code for `Serializer` and `Deserializer`.
3 changes: 3 additions & 0 deletions changelog.d/9879-dynamic-import-live-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Dynamic imports now expose aliased local `var`, `let`, and `const` exports
instead of resolving them as `undefined`. Namespace reads also preserve live
bindings when an exported mutable variable is reassigned after import.
1 change: 1 addition & 0 deletions changelog.d/9882-util-mime-setters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed `node:util` `MIMEType` setters to lowercase type/subtype values and keep `essence` in sync.
38 changes: 36 additions & 2 deletions crates/perry-codegen/src/codegen/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ use super::closure::{
use super::ctor_arity::synthesized_ctor_param_count;
use super::entry::compile_module_entry;
use super::helpers::{
function_body_returns_generator_object, sanitize, scoped_fn_name, unknown_func_wrapper_name,
function_body_returns_generator_object, namespace_live_getter_wrapper_symbol, sanitize,
scoped_fn_name, unknown_func_wrapper_name,
};
use super::indexed_method_artifacts::{compile_indexed_method_clones, IndexedMethodArtifactsCtx};
use super::method::{
Expand Down Expand Up @@ -1347,9 +1348,42 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
let ns_name = format!("__perry_ns_{}", module_prefix);
// Hex double literal for TAG_UNDEFINED (0x7FFC_0000_0000_0001).
llmod.add_global(&ns_name, DOUBLE, "0x7FFC000000000001");
for entry in &cross_module.namespace_entries {
for (entry_index, entry) in cross_module.namespace_entries.iter().enumerate() {
let (gname, byte_len) = llmod.add_string_constant(&entry.name);
namespace_key_globals.push((gname, byte_len));

let wrapper_name = namespace_live_getter_wrapper_symbol(module_prefix, entry_index);
let getter_name = match &entry.kind {
crate::NamespaceEntryKind::LocalVar { global_name } => {
let wrapper = llmod.define_function(
&wrapper_name,
DOUBLE,
vec![(I64, "%this_closure".to_string())],
);
let _ = wrapper.create_block("entry");
let blk = wrapper.block_mut(0).unwrap();
let value = blk.load(DOUBLE, &format!("@{global_name}"));
blk.ret(DOUBLE, &value);
continue;
}
crate::NamespaceEntryKind::ForeignVar {
source_prefix,
source_local,
} => format!("perry_fn_{}__{}", source_prefix, sanitize(source_local)),
_ => continue,
};
if !llmod.has_function(&getter_name) {
llmod.declare_function(&getter_name, DOUBLE, &[]);
}
let wrapper = llmod.define_function(
&wrapper_name,
DOUBLE,
vec![(I64, "%this_closure".to_string())],
);
let _ = wrapper.create_block("entry");
let blk = wrapper.block_mut(0).unwrap();
let value = blk.call(DOUBLE, &getter_name, &[]);
blk.ret(DOUBLE, &value);
}
}
// For each `Expr::DynamicImport` target this module dispatches to,
Expand Down
49 changes: 33 additions & 16 deletions crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use std::collections::HashMap;

use crate::module::LlModule;
use crate::types::{DOUBLE, I32, I64, PTR};
use crate::types::{DOUBLE, I32, I64, I8, PTR};

use super::opts::{NamespaceEntry, NamespaceEntryKind};

Expand Down Expand Up @@ -1348,14 +1348,15 @@ pub(super) fn register_module_globals_as_gc_roots(
///
/// The IR sequence per call:
///
/// 1. Alloca three parallel stack arrays sized `[N x ?]` — keys (ptr),
/// key_lens (i32), values (double).
/// 1. Alloca four parallel stack arrays sized `[N x ?]` — keys (ptr),
/// key_lens (i32), values (double), live-binding flags (i8).
/// 2. For each entry i in `namespace_entries`:
/// - Store `getelementptr inbounds [L x i8], ptr @.strK, i64 0, i64 0`
/// into `keys[i]` and `L` into `key_lens[i]`.
/// - Compute the value JSValue per `NamespaceEntryKind` and store
/// into `values[i]`.
/// 3. Call `js_create_namespace(N, ptr keys, ptr key_lens, ptr values)`.
/// 3. Call `js_create_namespace(N, ptr keys, ptr key_lens, ptr values,
/// ptr live_flags)`.
/// 4. Store the result into `@__perry_ns_<module_prefix>`.
///
/// Always emits the `js_create_namespace` call + store, even when
Expand All @@ -1364,6 +1365,13 @@ pub(super) fn register_module_globals_as_gc_roots(
/// non-NaN `@__perry_ns_<prefix>` to load). The runtime tolerates
/// `n == 0` and returns an empty NaN-boxed object. The caller is
/// responsible for ensuring `key_globals.len() == entries.len()`.
pub(super) fn namespace_live_getter_wrapper_symbol(
module_prefix: &str,
entry_index: usize,
) -> String {
format!("__perry_ns_get_{module_prefix}__{entry_index}")
}

pub(super) fn emit_namespace_populator(
ctx: &mut crate::expr::FnCtx<'_>,
entries: &[NamespaceEntry],
Expand All @@ -1382,13 +1390,15 @@ pub(super) fn emit_namespace_populator(
let buf_len = n.max(1);
let blk = ctx.block();

// Alloca the three parallel buffers.
// Alloca the four parallel buffers.
let keys_buf = blk.next_reg();
blk.emit_raw(format!("{} = alloca [{} x ptr]", keys_buf, buf_len));
let lens_buf = blk.next_reg();
blk.emit_raw(format!("{} = alloca [{} x i32]", lens_buf, buf_len));
let vals_buf = blk.next_reg();
blk.emit_raw(format!("{} = alloca [{} x double]", vals_buf, buf_len));
let live_buf = blk.next_reg();
blk.emit_raw(format!("{} = alloca [{} x i8]", live_buf, buf_len));

// #7210 (2): `vals_buf` is a plain stack alloca, not a shadow slot the
// collector scans. Each entry's value is a NaN-boxed JSValue that can be
Expand Down Expand Up @@ -1418,12 +1428,26 @@ pub(super) fn emit_namespace_populator(
let len_slot = blk.gep(I32, &lens_buf, &[(I64, &idx_str)]);
blk.store(I32, &format!("{}", key_len), &len_slot);

let is_live_binding = matches!(
entry.kind,
NamespaceEntryKind::LocalVar { .. } | NamespaceEntryKind::ForeignVar { .. }
);
let live_slot = blk.gep(I8, &live_buf, &[(I64, &idx_str)]);
blk.store(I8, if is_live_binding { "1" } else { "0" }, &live_slot);

// Materialise the value per kind. We drop the `blk` borrow so
// each sub-emission can re-borrow ctx mutably for runtime calls
// / declares; then root it in this scope's group.
let val_str = match &entry.kind {
NamespaceEntryKind::LocalVar { global_name } => {
ctx.block().load(DOUBLE, &format!("@{}", global_name))
NamespaceEntryKind::LocalVar { .. } | NamespaceEntryKind::ForeignVar { .. } => {
let wrapper = namespace_live_getter_wrapper_symbol(module_prefix, i);
let blk = ctx.block();
let handle = blk.call(
I64,
"js_closure_alloc_singleton",
&[(PTR, &format!("@{}", wrapper))],
);
crate::expr::nanbox_pointer_inline(blk, &handle)
}
NamespaceEntryKind::LocalFunction { wrap_symbol } => {
let blk = ctx.block();
Expand All @@ -1440,14 +1464,6 @@ pub(super) fn emit_namespace_populator(
let bits = crate::nanbox::INT32_TAG | (*class_id as u64 & 0xFFFF_FFFF);
crate::nanbox::double_literal(f64::from_bits(bits))
}
NamespaceEntryKind::ForeignVar {
source_prefix,
source_local,
} => {
let getter = format!("perry_fn_{}__{}", source_prefix, sanitize(source_local));
ctx.pending_declares.push((getter.clone(), DOUBLE, vec![]));
ctx.block().call(DOUBLE, &getter, &[])
}
NamespaceEntryKind::ForeignFunction {
source_prefix,
source_local,
Expand Down Expand Up @@ -1518,7 +1534,7 @@ pub(super) fn emit_namespace_populator(
})
.expect("emit_namespace_populator's rooted group body is infallible");

// Call `js_create_namespace(n, keys, key_lens, values)` and store
// Call `js_create_namespace(n, keys, key_lens, values, live_flags)` and store
// the result into the namespace global. The result is a NaN-boxed
// POINTER_TAG ObjectHeader; the global is already GC-rooted by
// `register_module_globals_as_gc_roots` is NOT — namespace globals
Expand All @@ -1534,6 +1550,7 @@ pub(super) fn emit_namespace_populator(
(PTR, &keys_buf),
(PTR, &lens_buf),
(PTR, &vals_buf),
(PTR, &live_buf),
],
);
let ns_name = format!("__perry_ns_{}", module_prefix);
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/runtime_decls/strings_part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) {
// module's `__perry_ns_<prefix>` global) and from `Expr::DynamicImport`
// (returned wrapped in `js_promise_resolved`). See
// `crates/perry-runtime/src/object.rs::js_create_namespace`.
module.declare_function("js_create_namespace", DOUBLE, &[I32, PTR, PTR, PTR]);
module.declare_function("js_create_namespace", DOUBLE, &[I32, PTR, PTR, PTR, PTR]);
module.declare_function("js_finalize_namespace", DOUBLE, &[DOUBLE]);
module.declare_function("js_promise_then", I64, &[I64, I64, I64]);
module.declare_function("js_promise_resolved_then", I64, &[DOUBLE, I64, I64]);
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-hir/src/lower/module_decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,8 @@ pub(crate) fn lower_module_decl(
Expr::Closure { .. }
| Expr::Object(_)
| Expr::Array(_)
| Expr::SetNew
| Expr::SetNewFromArray(_)
| Expr::Call { .. }
| Expr::New { .. }
| Expr::JsNew { .. }
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/intl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ mod numbering_system;
use numbering_system::{is_well_formed_numbering_system, resolve_numbering_system};
mod canon_aliases;
pub(crate) mod segmenter;
pub mod segments_view;
use canon_aliases::canonicalize_unicode_extension_types;

pub(crate) use date_collator::{
Expand Down
Loading
Loading