diff --git a/.github/workflows/gate-failure-watch.yml b/.github/workflows/gate-failure-watch.yml new file mode 100644 index 0000000000..1251cf70c4 --- /dev/null +++ b/.github/workflows/gate-failure-watch.yml @@ -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 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 diff --git a/changelog.d/9860-intl-segmenter-view-mode.md b/changelog.d/9860-intl-segmenter-view-mode.md new file mode 100644 index 0000000000..99dbeec504 --- /dev/null +++ b/changelog.d/9860-intl-segmenter-view-mode.md @@ -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. diff --git a/changelog.d/9872-mock-timers-validation.md b/changelog.d/9872-mock-timers-validation.md new file mode 100644 index 0000000000..d068f1dac0 --- /dev/null +++ b/changelog.d/9872-mock-timers-validation.md @@ -0,0 +1,4 @@ +### Fixed + +- Match Node 26 mock-timer validation by accepting default primitive options + and non-negative infinite clock advances. diff --git a/changelog.d/9876-v8-constructor-validation.md b/changelog.d/9876-v8-constructor-validation.md new file mode 100644 index 0000000000..fd44026e82 --- /dev/null +++ b/changelog.d/9876-v8-constructor-validation.md @@ -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`. diff --git a/changelog.d/9879-dynamic-import-live-bindings.md b/changelog.d/9879-dynamic-import-live-bindings.md new file mode 100644 index 0000000000..970e90c882 --- /dev/null +++ b/changelog.d/9879-dynamic-import-live-bindings.md @@ -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. diff --git a/changelog.d/9882-util-mime-setters.md b/changelog.d/9882-util-mime-setters.md new file mode 100644 index 0000000000..c773e4637f --- /dev/null +++ b/changelog.d/9882-util-mime-setters.md @@ -0,0 +1 @@ +Fixed `node:util` `MIMEType` setters to lowercase type/subtype values and keep `essence` in sync. diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 6db959f3d1..1e93b7c0a4 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -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::{ @@ -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, diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index bb860ad6e7..39e1bc74e5 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -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}; @@ -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_`. /// /// Always emits the `js_create_namespace` call + store, even when @@ -1364,6 +1365,13 @@ pub(super) fn register_module_globals_as_gc_roots( /// non-NaN `@__perry_ns_` 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], @@ -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 @@ -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(); @@ -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, @@ -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 @@ -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); diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 5017076121..74fc497f05 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -911,7 +911,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // module's `__perry_ns_` 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]); diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 3f63f68aa7..66a1843fa1 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -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 { .. } diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index 88084c0db4..7e195ee4ec 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -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::{ diff --git a/crates/perry-runtime/src/intl/segments_view.rs b/crates/perry-runtime/src/intl/segments_view.rs new file mode 100644 index 0000000000..f33489004b --- /dev/null +++ b/crates/perry-runtime/src/intl/segments_view.rs @@ -0,0 +1,769 @@ +//! `Intl.Segmenter` **view mode** — the runtime half of +//! `INTERFACE_segments_view.md` §9, agreed with the keystroke lane. +//! +//! The compiler proves that a `for (let {segment: O} of X.segment(q))` loop +//! never lets the record or `O` escape, and then drives this cursor instead of +//! building either. Nothing here constructs a `Segments`: `open` takes the +//! segmenter and the input, which is why the view mode never depended on the +//! lazy-`Segments` work (measured and refuted separately). +//! +//! ## The rooting contract, which is the reason this file is small +//! +//! The cursor is an **ordinary GC object** whose slot 0 holds the input string +//! as a **traced value**, so the collector marks and rewrites it like any other +//! field — no registered root, no side table, no new scanner. Every entry point +//! re-derives its `&str` from that slot on entry and drops it before returning. +//! **No address derived from the input outlives a single entry point, and the +//! only thing that crosses the loop body is a GC pointer the collector +//! maintains.** `_next` and `_code_point_at` allocate nothing at all, so inside +//! them the question cannot even arise; `open` and `_segment` allocate and +//! carry the obligation explicitly. + +use crate::object::ObjectHeader; +use crate::string::StringHeader; +use crate::value::JSValue; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Class id for the view cursor. It is the brand: a load, where a +/// `get_string_field(obj, "__brand")` check would allocate a key string on a +/// path that runs per loop entry. +pub const SEGMENTS_CURSOR_CLASS_ID: u32 = 0xFFFF_000E; + +const F_INPUT: u32 = 0; +const F_BYTE_START: u32 = 1; +const F_UTF16_START: u32 = 2; +const F_BYTE_END: u32 = 3; +const F_UTF16_LEN: u32 = 4; +const CURSOR_FIELDS: u32 = 5; + +// --- counters (PERRY_SEGVIEW_DIAG=1) --------------------------------------- + +static OPENS: AtomicU64 = AtomicU64::new(0); +static DECLINE_NOT_SEGMENTER: AtomicU64 = AtomicU64::new(0); +static DECLINE_NOT_GRAPHEME: AtomicU64 = AtomicU64::new(0); +static DECLINE_SEGMENT_PATCHED: AtomicU64 = AtomicU64::new(0); +static DECLINE_NOT_STRING: AtomicU64 = AtomicU64::new(0); +static DECLINE_NOT_UTF8: AtomicU64 = AtomicU64::new(0); +static DECLINE_EMPTY: AtomicU64 = AtomicU64::new(0); +static NEXTS: AtomicU64 = AtomicU64::new(0); +static CODE_POINT_ATS: AtomicU64 = AtomicU64::new(0); +static MATERIALISE_SEGMENT: AtomicU64 = AtomicU64::new(0); +static REGEXP_TEST_ACCEPTED: AtomicU64 = AtomicU64::new(0); +static REGEXP_TEST_DECLINED: AtomicU64 = AtomicU64::new(0); + +fn diag_on() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("PERRY_SEGVIEW_DIAG").is_ok()) +} + +#[inline(always)] +fn bump(c: &AtomicU64) { + if diag_on() { + c.fetch_add(1, Ordering::Relaxed); + } +} + +/// One line, on demand. A decline that names its own reason is the difference +/// between "the tier did not fire" and "the tier fired and found nothing". +pub fn report_segview_counters() { + if !diag_on() { + return; + } + eprintln!( + "[segview] opens={} declines: not_segmenter={} not_grapheme={} segment_patched={} \ + not_string={} not_utf8={} empty={} | nexts={} code_point_at={} materialise_segment={} \ + regexp_test: accepted={} declined={}", + OPENS.load(Ordering::Relaxed), + DECLINE_NOT_SEGMENTER.load(Ordering::Relaxed), + DECLINE_NOT_GRAPHEME.load(Ordering::Relaxed), + DECLINE_SEGMENT_PATCHED.load(Ordering::Relaxed), + DECLINE_NOT_STRING.load(Ordering::Relaxed), + DECLINE_NOT_UTF8.load(Ordering::Relaxed), + DECLINE_EMPTY.load(Ordering::Relaxed), + NEXTS.load(Ordering::Relaxed), + CODE_POINT_ATS.load(Ordering::Relaxed), + MATERIALISE_SEGMENT.load(Ordering::Relaxed), + REGEXP_TEST_ACCEPTED.load(Ordering::Relaxed), + REGEXP_TEST_DECLINED.load(Ordering::Relaxed), + ); +} + +// --- cursor plumbing -------------------------------------------------------- + +#[inline(always)] +fn cursor_ptr(value: f64) -> Option<*mut ObjectHeader> { + let obj = unsafe { crate::object::object_ptr_from_value(value) }? as *mut ObjectHeader; + if unsafe { (*obj).class_id } != SEGMENTS_CURSOR_CLASS_ID { + return None; + } + Some(obj) +} + +#[inline(always)] +fn num_field(obj: *mut ObjectHeader, index: u32) -> usize { + let bits = crate::object::js_object_get_field(obj, index); + let n = JSValue::from_bits(bits.bits()).to_number(); + if n.is_finite() && n >= 0.0 { + n as usize + } else { + 0 + } +} + +#[inline(always)] +fn set_num_field(obj: *mut ObjectHeader, index: u32, value: usize) { + crate::object::js_object_set_field(obj, index, JSValue::number(value as f64)); +} + +/// Run `f` with the cursor's input as a `&str`. The borrow is derived here and +/// dropped at the end of the call; a short (SSO) string is decoded into the +/// caller's stack buffer, so neither case allocates and neither case leaks an +/// address. +#[inline] +fn with_input(cursor: *mut ObjectHeader, f: impl FnOnce(&str) -> R) -> Option { + let value = crate::object::js_object_get_field(cursor, F_INPUT); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let bytes = + unsafe { crate::string::js_string_key_bytes(JSValue::from_bits(value.bits()), &mut sso) }?; + let text = std::str::from_utf8(bytes).ok()?; + Some(f(text)) +} + +#[cfg(feature = "intl-segmenter")] +fn next_boundary(text: &str, from: usize) -> Option { + if from >= text.len() { + return None; + } + let mut c = unicode_segmentation::GraphemeCursor::new(from, text.len(), true); + match c.next_boundary(text, 0) { + Ok(Some(next)) if next > from => Some(next), + _ => None, + } +} + +#[cfg(not(feature = "intl-segmenter"))] +fn next_boundary(text: &str, from: usize) -> Option { + text[from..].chars().next().map(|c| from + c.len_utf8()) +} + +// --- entry points ----------------------------------------------------------- + +/// `open(segmenter, input)` — a cursor positioned BEFORE the first segment, or +/// `0.0` to mean "take the spec path you already emit". +/// +/// **The decline path has no observable effect of any kind.** The compiler +/// emits `open(X, q)` first and, on a decline, evaluates `X.segment(q)` exactly +/// once in its original position — so a decline must not coerce, allocate, +/// advance or throw. In particular `input` must ALREADY be a string primitive: +/// `build_segments` coerces with `js_jsvalue_to_string`, which runs user +/// `toString`/`valueOf` and **throws on a Symbol**, and doing that here would +/// either run user code twice or move the TypeError out of the spec path. +/// Nothing before the final step allocates. +#[no_mangle] +pub extern "C" fn js_segments_view_open(segmenter: f64, input: f64) -> f64 { + // 1. a pristine Intl.Segmenter whose `segment` is still the builtin. + let Some(obj) = (unsafe { crate::object::object_ptr_from_value(segmenter) }) else { + bump(&DECLINE_NOT_SEGMENTER); + return 0.0; + }; + let obj = obj as *mut ObjectHeader; + if !intl_kind_is_segmenter(obj) { + bump(&DECLINE_NOT_SEGMENTER); + return 0.0; + } + if !segment_method_is_canonical(obj) { + bump(&DECLINE_SEGMENT_PATCHED); + return 0.0; + } + // 2. grapheme only (§4): a resumable word cursor is not equivalent to + // segmenting the whole string, and nothing measured needs one. + if !granularity_is_grapheme(obj) { + bump(&DECLINE_NOT_GRAPHEME); + return 0.0; + } + // 3. an ALREADY-string input, checked before any coercion could happen. + let jv = JSValue::from_bits(input.to_bits()); + if !jv.is_string() { + bump(&DECLINE_NOT_STRING); + return 0.0; + } + // 4. valid UTF-8 and non-empty. A WTF-8 lone surrogate is repaired by + // `segmenter_input_text` on the spec path by COPYING, which a borrowing + // cursor cannot do. + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let Some(bytes) = (unsafe { crate::string::js_string_key_bytes(jv, &mut sso) }) else { + bump(&DECLINE_NOT_STRING); + return 0.0; + }; + if bytes.is_empty() { + bump(&DECLINE_EMPTY); + return 0.0; + } + if std::str::from_utf8(bytes).is_err() { + bump(&DECLINE_NOT_UTF8); + return 0.0; + } + // 5. only now allocate. The input is held in a rooted handle ACROSS the + // cursor allocation and re-read from it afterwards: this is the + // #9539/#9445 shape in its simplest form — allocate, then store a value + // that predates the allocation. + let scope = crate::gc::RuntimeHandleScope::new(); + let input_h = scope.root_nanbox_f64(input); + let cursor = crate::object::js_object_alloc(SEGMENTS_CURSOR_CLASS_ID, CURSOR_FIELDS); + if cursor.is_null() { + return 0.0; + } + crate::object::js_object_set_field( + cursor, + F_INPUT, + JSValue::from_bits(input_h.get_nanbox_f64().to_bits()), + ); + set_num_field(cursor, F_BYTE_START, 0); + set_num_field(cursor, F_UTF16_START, 0); + set_num_field(cursor, F_BYTE_END, 0); + set_num_field(cursor, F_UTF16_LEN, 0); + bump(&OPENS); + crate::value::js_nanbox_pointer(cursor as i64) +} + +/// Advance to the next grapheme boundary. `1.0` if a segment is now current, +/// `0.0` at the end. **Allocation-free by contract**: three integer field +/// writes and a UAX #29 boundary scan, no arena allocation, no owned `String`, +/// no descriptor insert — which is also why it cannot collect. +#[no_mangle] +pub extern "C" fn js_segments_view_next(cursor: f64) -> f64 { + let Some(c) = cursor_ptr(cursor) else { + return 0.0; + }; + let from = num_field(c, F_BYTE_END); + let utf16_start = num_field(c, F_UTF16_START) + num_field(c, F_UTF16_LEN); + let step = with_input(c, |text| { + next_boundary(text, from).map(|next| (next, super::segmenter::utf16_len(&text[from..next]))) + }) + .flatten(); + let Some((next, seg_u16)) = step else { + return 0.0; + }; + set_num_field(c, F_BYTE_START, from); + set_num_field(c, F_UTF16_START, utf16_start); + set_num_field(c, F_BYTE_END, next); + set_num_field(c, F_UTF16_LEN, seg_u16 as usize); + bump(&NEXTS); + 1.0 +} + +/// `segment.codePointAt(k)` for the CURRENT segment, without materialising it. +/// +/// `k` is a UTF-16 offset **relative to the segment start** and is bounded by +/// the **segment**, not the input: `k` at or past the segment's UTF-16 length +/// is `undefined` even though the input has more code units there. Decoding +/// starts from the cursor's BYTE offset, so `k = 0` is O(1) — calling +/// `js_string_code_point_at` on the input instead would walk from index 0 on +/// any non-ASCII string and make the loop quadratic. +#[no_mangle] +pub extern "C" fn js_segments_view_code_point_at(cursor: f64, k: f64) -> f64 { + let undef = f64::from_bits(crate::value::TAG_UNDEFINED); + let Some(c) = cursor_ptr(cursor) else { + return undef; + }; + if !k.is_finite() || k < 0.0 || k.fract() != 0.0 { + return undef; + } + let k = k as usize; + if k >= num_field(c, F_UTF16_LEN) { + return undef; + } + let start = num_field(c, F_BYTE_START); + let end = num_field(c, F_BYTE_END); + bump(&CODE_POINT_ATS); + with_input(c, |text| { + let seg = &text[start..end]; + let mut utf16_pos = 0usize; + for ch in seg.chars() { + let units = ch.len_utf16(); + if utf16_pos + units > k { + if units == 1 || utf16_pos == k { + // A BMP code point, or the START of a surrogate pair, + // which per spec is the whole code point. + return u32::from(ch) as f64; + } + // `k` lands on the low surrogate half: return the bare unit, + // exactly as `js_string_code_point_at` does. + let v = u32::from(ch) - 0x10000; + return (0xDC00 + (v & 0x3FF)) as f64; + } + utf16_pos += units; + } + undef + }) + .unwrap_or(undef) +} + +/// Materialise the current segment. The compiler emits this for a use it +/// cannot answer from the view — the per-use materialise-on-miss. +#[no_mangle] +pub extern "C" fn js_segments_view_segment(cursor: f64) -> f64 { + let undef = f64::from_bits(crate::value::TAG_UNDEFINED); + let Some(c) = cursor_ptr(cursor) else { + return undef; + }; + let start = num_field(c, F_BYTE_START); + let end = num_field(c, F_BYTE_END); + bump(&MATERIALISE_SEGMENT); + // The allocation happens INSIDE the borrow, so the borrow must not outlive + // it: take the bytes out first, then allocate from a copy on the stack path + // `js_string_from_bytes` performs. Nothing derived from the input survives + // this call. + let made = with_input(c, |text| { + let seg = &text[start..end]; + crate::string::js_string_from_bytes(seg.as_ptr(), seg.len() as u32) + }); + match made { + Some(ptr) if !ptr.is_null() => { + f64::from_bits(JSValue::string_ptr(ptr as *mut StringHeader).bits()) + } + _ => undef, + } +} + +/// `regex.test(segment)` without materialising the segment. **Three-valued**: +/// `true` / `false` / **`undefined` = "I decline"**, on which the compiler +/// materialises and calls the ordinary path. +/// +/// It declines for a global or sticky regex, because `test` is then stateful +/// (`lastIndex` must be consulted and advanced) and that bookkeeping is written +/// against a `StringHeader`. It declines for a patched `RegExp.prototype.test` +/// or an own `test`, because `is RegExp` at the call site does not rule those +/// out and a view-mode test would silently bypass user code. +/// +/// When it accepts, the haystack is a **slice whose bounds are the string's +/// ends**, so `^`, `$` and lookbehind are segment-local — the same answer the +/// materialised call would give, not "a match starting at an offset". +#[no_mangle] +pub extern "C" fn js_segments_view_regexp_test(cursor: f64, regex: f64) -> f64 { + let undef = f64::from_bits(crate::value::TAG_UNDEFINED); + let Some(c) = cursor_ptr(cursor) else { + return undef; + }; + let jv = JSValue::from_bits(regex.to_bits()); + if !jv.is_pointer() { + bump(®EXP_TEST_DECLINED); + return undef; + } + let re = jv.as_pointer::(); + if !crate::regex::is_valid_regex_ptr(re) { + bump(®EXP_TEST_DECLINED); + return undef; + } + // `is RegExp` at the call site does not rule out a patched + // `RegExp.prototype.test`, so the runtime re-checks and declines. + // + // Both helpers below are `#[cfg(feature = "regex-engine")]`. This entry + // point is NOT gated with them: it is `#[no_mangle]`, so the symbol has to + // exist in every configuration or a binary that emits a call to it fails to + // link. Without the engine the fast path simply declines, which is the same + // contract every other decline here has — the caller materialises and calls + // `RegExp.prototype.test` itself. + #[cfg(not(feature = "regex-engine"))] + { + let _ = (c, re); + bump(®EXP_TEST_DECLINED); + return undef; + } + #[cfg(feature = "regex-engine")] + { + if !crate::object::regex_proto_thunks::regexp_prototype_test_is_canonical(regex) { + bump(®EXP_TEST_DECLINED); + return undef; + } + let start = num_field(c, F_BYTE_START); + let end = num_field(c, F_BYTE_END); + let verdict = with_input(c, |text| { + crate::regex::regexp_test_str_bounded(re, &text[start..end]) + }) + .flatten(); + match verdict { + Some(v) => { + bump(®EXP_TEST_ACCEPTED); + f64::from_bits(JSValue::bool(v).bits()) + } + None => { + bump(®EXP_TEST_DECLINED); + undef + } + } + } +} + +// Keepalive anchors. The compiler emits calls to these only when the view tier +// fires, so without a reference the bundle link's stub localization can drop +// them before the lowering that needs them is ever compiled — the same reason +// `js_for_of_next` carries `KEEP_JS_FOR_OF_NEXT`. +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_SEGMENTS_VIEW_OPEN: extern "C" fn(f64, f64) -> f64 = js_segments_view_open; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_SEGMENTS_VIEW_NEXT: extern "C" fn(f64) -> f64 = js_segments_view_next; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_SEGMENTS_VIEW_CODE_POINT_AT: extern "C" fn(f64, f64) -> f64 = + js_segments_view_code_point_at; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_SEGMENTS_VIEW_SEGMENT: extern "C" fn(f64) -> f64 = js_segments_view_segment; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_SEGMENTS_VIEW_REGEXP_TEST: extern "C" fn(f64, f64) -> f64 = + js_segments_view_regexp_test; + +// --- the `open` predicates, all non-allocating after the first intern ------- + +fn interned(name: &[u8]) -> *const StringHeader { + crate::string::intern_ascii_literal(name) +} + +fn string_field_is(obj: *mut ObjectHeader, key: &[u8], expected: &[u8]) -> bool { + let k = interned(key); + if k.is_null() { + return false; + } + let value = crate::object::js_object_get_field_by_name(obj, k); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + match unsafe { crate::string::js_string_key_bytes(value, &mut sso) } { + Some(bytes) => bytes == expected, + None => false, + } +} + +fn intl_kind_is_segmenter(obj: *mut ObjectHeader) -> bool { + string_field_is(obj, b"__intlKind", b"Segmenter") +} + +fn granularity_is_grapheme(obj: *mut ObjectHeader) -> bool { + string_field_is(obj, b"__intlGranularity", b"grapheme") +} + +/// Is `obj.segment` still the builtin? One inherited lookup catches BOTH an own +/// shadow on the instance and a replaced `Intl.Segmenter.prototype.segment`, +/// because the lookup resolves whatever the call would have resolved. +fn segment_method_is_canonical(obj: *mut ObjectHeader) -> bool { + let k = interned(b"segment"); + if k.is_null() { + return false; + } + let value = crate::object::js_object_get_field_by_name(obj, k); + let jv = JSValue::from_bits(value.bits()); + if !jv.is_pointer() { + return false; + } + let closure = jv.as_pointer::(); + if closure.is_null() { + return false; + } + let entry = crate::closure::get_valid_func_ptr(closure); + entry == super::segmenter::segmenter_segment_thunk as *const u8 + || entry == super::segmenter::segmenter_bound_segment_thunk as *const u8 +} + +#[cfg(test)] +mod view_mode_tests { + use super::*; + + fn js_string(s: &str) -> f64 { + let ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + f64::from_bits(JSValue::string_ptr(ptr as *mut StringHeader).bits()) + } + + /// A real `Intl.Segmenter` instance, built by the runtime's own + /// constructor path so the test cannot pass against a hand-made object the + /// production code would reject. + fn grapheme_segmenter() -> f64 { + let options = crate::object::js_object_alloc(0, 1); + let key = crate::string::js_string_from_bytes(b"granularity".as_ptr(), 11); + crate::object::js_object_set_field_by_name(options, key, js_string("grapheme")); + // The runtime's OWN constructor path, so the instance carries the same + // internal fields and the same own bound `segment` a real + // `new Intl.Segmenter(...)` produces. A hand-made object would test the + // predicates against something production never sees. + super::super::make_instance( + std::ptr::null(), + super::super::KIND_SEGMENTER, + js_string("en"), + crate::value::js_nanbox_pointer(options as i64), + ) + } + + fn is_undefined(v: f64) -> bool { + JSValue::from_bits(v.to_bits()).is_undefined() + } + + /// Walk the cursor and collect (index, code point at 0, segment string). + fn walk(cursor: f64) -> Vec<(usize, u32, String)> { + let mut out = Vec::new(); + while js_segments_view_next(cursor) == 1.0 { + let c = cursor_ptr(cursor).expect("cursor"); + let cp = js_segments_view_code_point_at(cursor, 0.0); + let seg = js_segments_view_segment(cursor); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let bytes = unsafe { + crate::string::js_string_key_bytes(JSValue::from_bits(seg.to_bits()), &mut sso) + } + .expect("segment string"); + out.push(( + num_field(c, F_UTF16_START), + cp as u32, + String::from_utf8_lossy(bytes).into_owned(), + )); + } + out + } + + /// The view must agree with `graphemes(true)` — the same segmentation the + /// spec path uses — on the shapes cc actually renders. + #[test] + fn view_walk_matches_the_spec_segmentation() { + let input = "a\u{301}b\u{1f469}\u{200d}\u{1f4bb}\u{1f1fa}\u{1f1f8}c"; + let cursor = js_segments_view_open(grapheme_segmenter(), js_string(input)); + assert!( + cursor != 0.0, + "open must accept a pristine grapheme segmenter" + ); + let got = walk(cursor); + + #[cfg(feature = "intl-segmenter")] + { + use unicode_segmentation::UnicodeSegmentation; + let mut want = Vec::new(); + let mut idx = 0usize; + for g in input.graphemes(true) { + want.push(( + idx, + g.chars().next().map(u32::from).unwrap_or(0), + g.to_string(), + )); + idx += super::super::segmenter::utf16_len(g) as usize; + } + assert_eq!(got, want, "view segmentation must equal graphemes(true)"); + } + assert!(!got.is_empty()); + } + + /// THE FALSIFIER. The two in-loop entry points must move the arena by + /// ZERO, with the minor count pinned so a collection cannot manufacture the + /// zero. This is the whole point of the view mode. + #[test] + fn next_and_code_point_at_allocate_nothing() { + let input = "a\u{301}b\u{1f469}\u{200d}\u{1f4bb}c d e f g h i j k l m n o p"; + let scope = crate::gc::RuntimeHandleScope::new(); + let cursor_h = scope.root_nanbox_f64(js_segments_view_open( + grapheme_segmenter(), + js_string(input), + )); + assert!(cursor_h.get_nanbox_f64() != 0.0); + // Warm: the first call may lazily build anything it builds. + js_segments_view_next(cursor_h.get_nanbox_f64()); + js_segments_view_code_point_at(cursor_h.get_nanbox_f64(), 0.0); + + let minors_before = crate::gc::instruments::copying_minor_cycles(); + let bytes_before = crate::arena::arena_in_use_bytes(); + let mut steps = 0usize; + for _ in 0..200 { + if js_segments_view_next(cursor_h.get_nanbox_f64()) != 1.0 { + // Re-open rather than stop: a short input would otherwise make + // this test pass by doing nothing. + break; + } + let cp = js_segments_view_code_point_at(cursor_h.get_nanbox_f64(), 0.0); + assert!(!is_undefined(cp), "every segment has a code point at 0"); + steps += 1; + } + let bytes_after = crate::arena::arena_in_use_bytes(); + assert!( + steps > 5, + "the walk must actually have stepped (got {steps})" + ); + assert_eq!( + crate::gc::instruments::copying_minor_cycles(), + minors_before, + "a collection inside the window would make a zero delta prove nothing" + ); + assert_eq!( + bytes_after.saturating_sub(bytes_before), + 0, + "next + code_point_at allocated {} bytes over {steps} steps", + bytes_after.saturating_sub(bytes_before) + ); + } + + /// The rooting obligation of §9e, exercised rather than asserted: `open` + /// allocates the cursor while holding the input, so a collection landing in + /// that window must not leave a dead value in the traced slot. Force a + /// collection immediately before each `open` and then READ the input back + /// through the cursor: with the handle removed (`PERRY_SABOTAGE_SEGVIEW= + /// norooting`) this is the test that fails. + #[test] + fn open_survives_a_collection_between_its_two_allocations() { + for round in 0..40 { + let input = format!("a\u{301}b{round}\u{1f600}c"); + let s = js_string(&input); + // Churn, so the cursor allocation below is likely to be the one + // that trips the collector, and collect explicitly as well. + let scope = crate::gc::RuntimeHandleScope::new(); + let s_h = scope.root_nanbox_f64(s); + for _ in 0..64 { + let _ = crate::object::js_object_alloc(0, 4); + } + crate::gc::js_gc_collect(); + let cursor = js_segments_view_open(grapheme_segmenter(), s_h.get_nanbox_f64()); + assert!(cursor != 0.0, "open must accept round {round}"); + let mut seen = String::new(); + while js_segments_view_next(cursor) == 1.0 { + let seg = js_segments_view_segment(cursor); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let bytes = unsafe { + crate::string::js_string_key_bytes(JSValue::from_bits(seg.to_bits()), &mut sso) + } + .expect("segment string"); + seen.push_str(&String::from_utf8_lossy(bytes)); + } + assert_eq!( + seen, input, + "the cursor's input slot must survive a collection inside open (round {round})" + ); + } + } + + /// `k` is bounded by the SEGMENT, not the input: reading past the end of a + /// one-unit segment must be `undefined` even though the input continues. + /// A view that clamped to the input would answer the NEXT grapheme. + #[test] + fn code_point_at_is_bounded_by_the_segment() { + let cursor = js_segments_view_open(grapheme_segmenter(), js_string("ab")); + assert!(cursor != 0.0); + assert_eq!(js_segments_view_next(cursor), 1.0); + assert_eq!( + js_segments_view_code_point_at(cursor, 0.0), + 'a' as u32 as f64 + ); + assert!( + is_undefined(js_segments_view_code_point_at(cursor, 1.0)), + "k past the segment end must be undefined, not the next grapheme" + ); + assert!(is_undefined(js_segments_view_code_point_at(cursor, -1.0))); + assert!(is_undefined(js_segments_view_code_point_at(cursor, 0.5))); + } + + /// A surrogate pair is ONE grapheme and `codePointAt(0)` is the whole code + /// point; `k = 1` is the bare low surrogate, exactly as + /// `js_string_code_point_at` answers on the materialised substring. + #[test] + fn code_point_at_matches_the_materialised_answer_on_a_surrogate_pair() { + let cursor = js_segments_view_open(grapheme_segmenter(), js_string("\u{1f600}x")); + assert_eq!(js_segments_view_next(cursor), 1.0); + assert_eq!(js_segments_view_code_point_at(cursor, 0.0), 0x1f600 as f64); + assert_eq!(js_segments_view_code_point_at(cursor, 1.0), 0xDE00 as f64); + let seg = js_segments_view_segment(cursor); + let ptr = JSValue::from_bits(seg.to_bits()).as_string_ptr(); + assert_eq!( + crate::string::js_string_code_point_at(ptr, 0), + js_segments_view_code_point_at(cursor, 0.0), + "the view must answer exactly what the materialised segment does" + ); + assert_eq!( + crate::string::js_string_code_point_at(ptr, 1), + js_segments_view_code_point_at(cursor, 1.0) + ); + } + + /// Every decline in §9f, and the one that matters most: a non-string input + /// must be refused BEFORE any coercion, and `open` must never throw — the + /// compiler evaluates `X.segment(q)` itself on a decline. + #[test] + fn open_declines_without_side_effects() { + let seg = grapheme_segmenter(); + assert_eq!(js_segments_view_open(seg, js_string("")), 0.0, "empty"); + assert_eq!( + js_segments_view_open(seg, f64::from_bits(crate::value::TAG_UNDEFINED)), + 0.0, + "undefined input must decline, not coerce to \"undefined\"" + ); + assert_eq!(js_segments_view_open(seg, 42.0), 0.0, "number input"); + let obj = crate::object::js_object_alloc(0, 0); + assert_eq!( + js_segments_view_open(seg, crate::value::js_nanbox_pointer(obj as i64)), + 0.0, + "an object input must decline before running toString" + ); + assert_eq!( + js_segments_view_open(crate::value::js_nanbox_pointer(obj as i64), js_string("a")), + 0.0, + "a non-Segmenter receiver must decline" + ); + // A lone surrogate is WTF-8: the spec path repairs it by copying, a + // borrowing cursor cannot, so it declines. + let wtf8 = crate::string::js_string_from_bytes(b"a\xED\xA0\x80b".as_ptr(), 5); + assert_eq!( + js_segments_view_open( + seg, + f64::from_bits(JSValue::string_ptr(wtf8 as *mut StringHeader).bits()) + ), + 0.0, + "invalid UTF-8 must decline" + ); + } + + /// `_regexp_test` answers the same as the materialised call for a plain + /// regex, and DECLINES (three-valued `undefined`) for a global one, whose + /// `test` is stateful in `lastIndex`. + #[cfg(feature = "regex-engine")] + #[test] + fn regexp_test_matches_the_materialised_call_and_declines_when_stateful() { + let cursor = js_segments_view_open(grapheme_segmenter(), js_string("a1")); + assert_eq!(js_segments_view_next(cursor), 1.0); + + let plain = crate::regex::js_regexp_construct(js_string("^[a-z]$"), js_string("")); + let plain_v = f64::from_bits(JSValue::pointer(plain as *const u8).bits()); + let seg = js_segments_view_segment(cursor); + let seg_ptr = JSValue::from_bits(seg.to_bits()).as_string_ptr(); + let materialised = crate::regex::js_regexp_test(plain, seg_ptr) != 0; + let viewed = js_segments_view_regexp_test(cursor, plain_v); + assert!(!is_undefined(viewed), "a plain regex must be accepted"); + assert_eq!( + crate::value::js_is_truthy(viewed) != 0, + materialised, + "the view answer must equal the materialised answer" + ); + + let global = crate::regex::js_regexp_construct(js_string("[a-z]"), js_string("g")); + let global_v = f64::from_bits(JSValue::pointer(global as *const u8).bits()); + assert!( + is_undefined(js_segments_view_regexp_test(cursor, global_v)), + "a global regex is stateful in lastIndex and must DECLINE" + ); + } + + /// The anchors are segment-local: `^`/`$` must bind to the segment's ends, + /// not the input's. A start-offset match instead of a bounded haystack + /// would make this pass for the first segment and fail for the second. + #[cfg(feature = "regex-engine")] + #[test] + fn regexp_test_anchors_are_segment_local() { + let cursor = js_segments_view_open(grapheme_segmenter(), js_string("ab")); + let anchored = crate::regex::js_regexp_construct(js_string("^b$"), js_string("")); + let v = f64::from_bits(JSValue::pointer(anchored as *const u8).bits()); + assert_eq!(js_segments_view_next(cursor), 1.0); // "a" + assert_eq!( + crate::value::js_is_truthy(js_segments_view_regexp_test(cursor, v)), + 0, + "^b$ must not match the segment \"a\"" + ); + assert_eq!(js_segments_view_next(cursor), 1.0); // "b" + assert_ne!( + crate::value::js_is_truthy(js_segments_view_regexp_test(cursor, v)), + 0, + "^b$ MUST match the segment \"b\" — the haystack's bounds are the \ + segment's ends, so the anchors are segment-local" + ); + } +} diff --git a/crates/perry-runtime/src/node_submodules/test.rs b/crates/perry-runtime/src/node_submodules/test.rs index 8b3b032340..cb70c0b994 100644 --- a/crates/perry-runtime/src/node_submodules/test.rs +++ b/crates/perry-runtime/src/node_submodules/test.rs @@ -228,7 +228,7 @@ extern "C" fn mock_timers_tick(_closure: *const ClosureHeader, ms: f64) -> f64 { let delay = if is_undefined_value(ms) { 1.0 } else { - validate_mock_timer_number("time", ms) + validate_mock_timer_number("time", ms, false) }; crate::timer::js_mock_timers_tick(delay); undefined_value() @@ -240,7 +240,7 @@ extern "C" fn mock_timers_run_all(_closure: *const ClosureHeader) -> f64 { } extern "C" fn mock_timers_set_time(_closure: *const ClosureHeader, ms: f64) -> f64 { - let time = validate_mock_timer_number("time", ms); + let time = validate_mock_timer_number("time", ms, false); crate::timer::js_mock_timers_set_time(time); undefined_value() } @@ -250,15 +250,15 @@ extern "C" fn mock_timers_reset(_closure: *const ClosureHeader) -> f64 { undefined_value() } -fn validate_mock_timer_number(arg: &str, value: f64) -> f64 { +fn validate_mock_timer_number(arg: &str, value: f64, reject_nan: bool) -> f64 { let js = JSValue::from_bits(value.to_bits()); if !crate::fs::validate::is_numeric(js) { throw_invalid_arg_type(arg, "number", value); } let n = crate::builtins::js_number_coerce(value); - if !n.is_finite() || n < 0.0 { + if n < 0.0 || (reject_nan && n.is_nan()) { let message = format!( - "The \"{}\" argument must be a non-negative finite number. Received {}", + "The \"{}\" argument must be a non-negative number. Received {}", arg, crate::fs::validate::describe_received(value) ); @@ -271,16 +271,13 @@ fn parse_mock_timer_options(options: f64) -> (u32, f64) { let mut apis_value = options; let mut now = 0.0; let js = JSValue::from_bits(options.to_bits()); - if js.is_undefined() { + if js.is_undefined() || js.is_null() || !js.is_pointer() { return (crate::timer::MOCK_TIMERS_ALL_APIS, now); } if !is_array_value(options) { - if js.is_null() || !js.is_pointer() { - throw_invalid_arg_type("options", "object", options); - } apis_value = object_property(options, b"apis").unwrap_or(undefined_value()); if let Some(now_value) = object_property(options, b"now") { - now = validate_mock_timer_number("options.now", now_value); + now = validate_mock_timer_number("options.now", now_value, true); } } if JSValue::from_bits(apis_value.to_bits()).is_undefined() { diff --git a/crates/perry-runtime/src/node_submodules/test_unit_tests.rs b/crates/perry-runtime/src/node_submodules/test_unit_tests.rs index a54218a18c..c27a4bc471 100644 --- a/crates/perry-runtime/src/node_submodules/test_unit_tests.rs +++ b/crates/perry-runtime/src/node_submodules/test_unit_tests.rs @@ -175,3 +175,21 @@ fn mock_timers_exposes_dispose_as_reset() { assert!(is_callable_value(symbol_method)); assert_ne!(symbol_method.to_bits(), reset.to_bits()); } + +#[test] +fn mock_timers_accepts_null_and_primitives_as_default_options() { + for options in [f64::from_bits(crate::value::TAG_NULL), 1.0] { + let (apis, now) = parse_mock_timer_options(options); + + assert_eq!(apis, crate::timer::MOCK_TIMERS_ALL_APIS); + assert_eq!(now, 0.0); + } +} + +#[test] +fn mock_timer_clock_values_accept_positive_infinity() { + assert_eq!( + validate_mock_timer_number("time", f64::INFINITY, false), + f64::INFINITY + ); +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index c2a3430c19..914f7e0989 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -163,7 +163,7 @@ mod prototype_helpers; mod reflect_support; mod reserved_floor; pub(crate) use reserved_floor::{ensure_reserved_floor_keys, reserved_slot_floor_for_class_id}; -mod regex_proto_thunks; +pub(crate) mod regex_proto_thunks; // #6812 object-owned overflow storage + the legacy thread-local side table. // Split out of this file to stay under the 2000-line CI cap; the sibling // `object::*` modules reach these through `use super::*`, so re-export the diff --git a/crates/perry-runtime/src/object/namespace_create.rs b/crates/perry-runtime/src/object/namespace_create.rs index fc31b8b11b..d6fc2d7bf4 100644 --- a/crates/perry-runtime/src/object/namespace_create.rs +++ b/crates/perry-runtime/src/object/namespace_create.rs @@ -29,7 +29,8 @@ pub extern "C" fn js_finalize_namespace(value: f64) -> f64 { /// Issue #100: build a module-namespace object (the value an `await /// import("./foo.ts")` resolves to) from parallel arrays of keys and -/// values. +/// values. Entries whose parallel `live_flags` byte is non-zero carry a +/// zero-argument getter closure instead of a snapshot value. /// /// Keys are length-prefixed UTF-8 (Perry strings are not guaranteed /// null-terminated), passed as parallel `*const *const u8` (data @@ -58,6 +59,7 @@ pub extern "C" fn js_create_namespace( keys: *const *const u8, key_lens: *const i32, values: *const f64, + live_flags: *const u8, ) -> f64 { let count = if n < 0 { 0 } else { n as usize }; unsafe { @@ -106,7 +108,33 @@ pub extern "C" fn js_create_namespace( let key_hdr = crate::string::js_string_from_bytes(key_data, key_len_u); obj = obj_handle.get_raw_mut_ptr::(); let val = value_handles[i].get_nanbox_f64(); - js_object_set_field_by_name(obj, key_hdr, val); + if !live_flags.is_null() && *live_flags.add(i) != 0 { + let key_handle = scope.root_string_ptr(key_hdr); + obj_handle.with_mut_ptr::(|current_obj| { + key_handle.with_const_ptr::(|current_key| { + js_object_define_accessor( + crate::value::js_nanbox_pointer(current_obj as i64), + crate::value::js_nanbox_string(current_key as i64), + val, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + }); + }); + let key = String::from_utf8_lossy(std::slice::from_raw_parts( + key_data, + key_len_u as usize, + )) + .into_owned(); + obj_handle.with_mut_ptr::(|current_obj| { + set_property_attrs( + current_obj as usize, + key, + PropertyAttrs::new(false, true, false), + ); + }); + } else { + js_object_set_field_by_name(obj, key_hdr, val); + } } // NaN-box POINTER_TAG and return. diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_v_z.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_v_z.rs index c3e9c250db..199210e86a 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_v_z.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_v_z.rs @@ -39,6 +39,14 @@ pub(crate) unsafe fn nm_dispatch_v8(ctx: &NmCtx, module_name: &str, method_name: typed_kind ); match (module_name, method_name) { + ("v8", name @ ("Serializer" | "Deserializer")) => { + let message = format!("Class constructor {name} cannot be invoked without 'new'"); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_CONSTRUCT_CALL_REQUIRED") + } + ("v8", name @ ("DefaultSerializer" | "DefaultDeserializer" | "GCProfiler")) => { + let message = format!("Class constructor {name} cannot be invoked without 'new'"); + crate::node_submodules::diagnostics::throw_type_error_no_code(message.as_bytes()) + } ("v8", "serialize") => crate::node_v8::js_v8_serialize(arg(0)), ("v8", "deserialize") => crate::node_v8::js_v8_deserialize(arg(0)), ("v8", "getHeapStatistics") => crate::node_v8::js_v8_get_heap_statistics(), diff --git a/crates/perry-runtime/src/object/regex_proto_thunks.rs b/crates/perry-runtime/src/object/regex_proto_thunks.rs index 0c073bb321..db5dba4de6 100644 --- a/crates/perry-runtime/src/object/regex_proto_thunks.rs +++ b/crates/perry-runtime/src/object/regex_proto_thunks.rs @@ -316,6 +316,41 @@ fn regex_instance_or_throw(method: &str) -> *const crate::regex::RegExpHeader { )) } +/// Is `RegExp.prototype.test` still the builtin, for the regex `value`? +/// +/// The `Intl.Segmenter` view mode answers `regex.test(segment)` without +/// materialising the segment, so it must not silently bypass a user +/// replacement. Same allocation-free proof as +/// `iterator_prototypes::prototype_next_is_canonical`: the prototype's OWN +/// `test` slot still holds a closure whose native entry is this module's +/// thunk, AND no accessor descriptor is recorded for `"test"` (a +/// `defineProperty(proto, "test", {get})` leaves the old closure in the data +/// slot). Any other state returns `false` and the caller declines. +#[cfg(feature = "regex-engine")] +pub(crate) fn regexp_prototype_test_is_canonical(value: f64) -> bool { + let proto = super::js_object_get_prototype_of(value); + let jv = crate::value::JSValue::from_bits(proto.to_bits()); + if !jv.is_pointer() { + return false; + } + let proto_obj = jv.as_pointer::() as *mut ObjectHeader; + if proto_obj.is_null() { + return false; + } + let own = super::js_object_get_own_field_or_undef(proto, b"test".as_ptr(), 4); + let own_jv = crate::value::JSValue::from_bits(own.to_bits()); + if !own_jv.is_pointer() { + return false; + } + let closure = own_jv.as_pointer::(); + if closure.is_null() + || crate::closure::get_valid_func_ptr(closure) != regex_proto_test_thunk as *const u8 + { + return false; + } + !super::descriptor_state::may_have_descriptor_entry(proto_obj as usize, "test", true) +} + /// Install the real (brand-checking) `exec`/`test`/`toString`/`compile` /// prototype methods. `compile` is only installed here when the `regex-engine` /// feature is on; the fallback no-op (for builds without an engine) is installed diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 81d25639f2..73dd933c05 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -1471,6 +1471,48 @@ fn regexp_pattern_is_regexp_like(pattern: f64) -> bool { } } +/// `regex.test(haystack)` where `haystack` is a **bounded slice whose bounds +/// ARE the string's ends** — the primitive the `Intl.Segmenter` view mode needs +/// so a segment can be tested without being materialised. +/// +/// Passing a sub-slice rather than a start offset is the whole point: `^` +/// anchors at the slice start, `$` at its end, and a lookbehind cannot see the +/// preceding grapheme, which is exactly what `test` on the materialised +/// substring means. A start-offset match would be silently wrong for an +/// anchored pattern, which is what claude-code's `oR_` is. +/// +/// Returns `None` — "I decline, materialise and call the normal path" — for a +/// **global or sticky** regex, because `test` is then stateful: it must consult +/// and advance `lastIndex`, and that bookkeeping (`regexp_find_advancing`) is +/// written against a `StringHeader`, not a slice. Answering it from a slice +/// would either lose the update or invent one. +// Its body reaches `diag_note_op` and `exec::`, both engine-gated, and its only +// caller (`js_segments_view_regexp_test`) references it only under this feature. +#[cfg(feature = "regex-engine")] +pub(crate) fn regexp_test_str_bounded(re: *const RegExpHeader, hay: &str) -> Option { + if !is_valid_regex_ptr(re) { + return None; + } + unsafe { + if (*re).global || (*re).sticky { + return None; + } + if crate::hot_diag::regex_on() { + diag_note_op(re, crate::hot_diag::RegexOp::Test); + } + if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + return Some(repeat_matcher.regex.find(hay).is_some()); + } + if let Some(fre) = lookup_fancy_regex(re) { + return match fre.is_match(hay) { + Ok(v) => Some(v), + Err(_) => None, + }; + } + Some(lazy::header_std_regex(re).is_match(hay)) + } +} + /// Test if a string matches the regex pattern /// regex.test(string) -> boolean #[cfg(feature = "regex-engine")] diff --git a/crates/perry-runtime/src/util_mime.rs b/crates/perry-runtime/src/util_mime.rs index b938c9b256..c4dd45c6ef 100644 --- a/crates/perry-runtime/src/util_mime.rs +++ b/crates/perry-runtime/src/util_mime.rs @@ -3,9 +3,10 @@ use crate::array::{js_array_alloc, js_array_get_f64, js_array_length, js_array_push_f64}; use crate::object::{ js_object_alloc, js_object_get_field_by_name_f64, js_object_get_field_f64, js_object_keys, - js_object_set_field_by_name, js_object_set_field_f64, js_object_set_keys, js_register_class_id, - js_register_class_method, js_register_class_name, js_register_class_setter, - set_builtin_property_attrs, ObjectHeader, PropertyAttrs, + js_object_set_field_by_name, js_object_set_field_f64, js_object_set_keys, + js_register_class_getter, js_register_class_id, js_register_class_method, + js_register_class_name, js_register_class_setter, set_builtin_property_attrs, ObjectHeader, + PropertyAttrs, }; use crate::string::js_string_from_bytes; use crate::value::{js_jsvalue_to_string, js_nanbox_pointer, JSValue}; @@ -300,42 +301,81 @@ fn mime_type_parts(this: *mut ObjectHeader) -> (String, String, *mut ObjectHeade (type_name, subtype, params) } -fn update_mime_type_essence(this: *mut ObjectHeader) { - let type_name = value_to_string(js_object_get_field_f64(this, MIME_TYPE_TYPE)); - let subtype = value_to_string(js_object_get_field_f64(this, MIME_TYPE_SUBTYPE)); - js_object_set_field_f64( - this, - MIME_TYPE_ESSENCE, - string_value(&format!("{type_name}/{subtype}")), - ); +fn update_mime_type_essence(this: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let this = scope.root_nanbox_f64(this); + let Some(obj) = object_ptr_from_value(this.get_nanbox_f64()) else { + return; + }; + let type_name = scope.root_nanbox_f64(js_object_get_field_f64(obj, MIME_TYPE_TYPE)); + let Some(obj) = object_ptr_from_value(this.get_nanbox_f64()) else { + return; + }; + let subtype = scope.root_nanbox_f64(js_object_get_field_f64(obj, MIME_TYPE_SUBTYPE)); + let type_name = value_to_string(type_name.get_nanbox_f64()); + let subtype = value_to_string(subtype.get_nanbox_f64()); + let essence = string_value(&format!("{type_name}/{subtype}")); + let Some(obj) = object_ptr_from_value(this.get_nanbox_f64()) else { + return; + }; + js_object_set_field_f64(obj, MIME_TYPE_ESSENCE, essence); } fn create_mime_type_object(input: &str) -> *mut ObjectHeader { ensure_mime_classes(); let (type_name, subtype, params) = parse_mime(input); let params_obj = create_mime_params_object(params); + let scope = crate::gc::RuntimeHandleScope::new(); + let params_obj = scope.root_nanbox_f64(ptr_value(params_obj)); let obj = js_object_alloc(CLASS_ID_MIME_TYPE, MIME_TYPE_FIELD_COUNT); - let mut keys = js_array_alloc(MIME_TYPE_FIELD_COUNT); - for key in ["type", "subtype", "essence", "params"] { - keys = js_array_push_f64(keys, string_value(key)); - } - js_object_set_keys(obj, keys); - for key in ["type", "subtype", "essence", "params"] { - set_builtin_property_attrs( - obj as usize, - key.to_string(), - PropertyAttrs::new(true, false, true), - ); - } - js_object_set_field_f64(obj, MIME_TYPE_TYPE, string_value(&type_name)); - js_object_set_field_f64(obj, MIME_TYPE_SUBTYPE, string_value(&subtype)); + let obj = scope.root_nanbox_f64(ptr_value(obj)); + let value = string_value(&type_name); js_object_set_field_f64( - obj, + object_ptr_from_value(obj.get_nanbox_f64()).unwrap(), + MIME_TYPE_TYPE, + value, + ); + let value = string_value(&subtype); + js_object_set_field_f64( + object_ptr_from_value(obj.get_nanbox_f64()).unwrap(), + MIME_TYPE_SUBTYPE, + value, + ); + let value = string_value(&format!("{type_name}/{subtype}")); + js_object_set_field_f64( + object_ptr_from_value(obj.get_nanbox_f64()).unwrap(), MIME_TYPE_ESSENCE, - string_value(&format!("{type_name}/{subtype}")), + value, ); - js_object_set_field_f64(obj, MIME_TYPE_PARAMS, ptr_value(params_obj)); - obj + js_object_set_field_f64( + object_ptr_from_value(obj.get_nanbox_f64()).unwrap(), + MIME_TYPE_PARAMS, + params_obj.get_nanbox_f64(), + ); + object_ptr_from_value(obj.get_nanbox_f64()).unwrap() +} + +fn mime_type_get_field(this: f64, field: u32) -> f64 { + let Some(obj) = object_ptr_from_value(this) else { + return undefined(); + }; + js_object_get_field_f64(obj, field) +} + +extern "C" fn mime_type_get_type_vtable(this: f64) -> f64 { + mime_type_get_field(this, MIME_TYPE_TYPE) +} + +extern "C" fn mime_type_get_subtype_vtable(this: f64) -> f64 { + mime_type_get_field(this, MIME_TYPE_SUBTYPE) +} + +extern "C" fn mime_type_get_essence_vtable(this: f64) -> f64 { + mime_type_get_field(this, MIME_TYPE_ESSENCE) +} + +extern "C" fn mime_type_get_params_vtable(this: f64) -> f64 { + mime_type_get_field(this, MIME_TYPE_PARAMS) } extern "C" fn mime_type_to_string_vtable(this: f64) -> f64 { @@ -353,24 +393,38 @@ extern "C" fn mime_type_to_string_vtable(this: f64) -> f64 { } extern "C" fn mime_type_set_type_vtable(this: f64, value: f64) -> f64 { - let Some(obj) = object_ptr_from_value(this) else { + let scope = crate::gc::RuntimeHandleScope::new(); + let this = scope.root_nanbox_f64(this); + let value = scope.root_nanbox_f64(value); + if object_ptr_from_value(this.get_nanbox_f64()).is_none() { return undefined(); - }; - let type_name = value_to_string(value).to_ascii_lowercase(); + } + let type_name = value_to_string(value.get_nanbox_f64()).to_ascii_lowercase(); validate_token("type", &type_name, &type_name); - js_object_set_field_f64(obj, MIME_TYPE_TYPE, string_value(&type_name)); - update_mime_type_essence(obj); + let value = string_value(&type_name); + let Some(obj) = object_ptr_from_value(this.get_nanbox_f64()) else { + return undefined(); + }; + js_object_set_field_f64(obj, MIME_TYPE_TYPE, value); + update_mime_type_essence(this.get_nanbox_f64()); undefined() } extern "C" fn mime_type_set_subtype_vtable(this: f64, value: f64) -> f64 { - let Some(obj) = object_ptr_from_value(this) else { + let scope = crate::gc::RuntimeHandleScope::new(); + let this = scope.root_nanbox_f64(this); + let value = scope.root_nanbox_f64(value); + if object_ptr_from_value(this.get_nanbox_f64()).is_none() { return undefined(); - }; - let subtype = value_to_string(value).to_ascii_lowercase(); + } + let subtype = value_to_string(value.get_nanbox_f64()).to_ascii_lowercase(); validate_token("subtype", &subtype, &subtype); - js_object_set_field_f64(obj, MIME_TYPE_SUBTYPE, string_value(&subtype)); - update_mime_type_essence(obj); + let value = string_value(&subtype); + let Some(obj) = object_ptr_from_value(this.get_nanbox_f64()) else { + return undefined(); + }; + js_object_set_field_f64(obj, MIME_TYPE_SUBTYPE, value); + update_mime_type_essence(this.get_nanbox_f64()); undefined() } @@ -493,6 +547,17 @@ fn register_setter(class_id: u32, name: &'static str, func_ptr: usize) { } } +fn register_getter(class_id: u32, name: &'static str, func_ptr: usize) { + unsafe { + js_register_class_getter( + class_id as i64, + name.as_ptr(), + name.len() as i64, + func_ptr as i64, + ); + } +} + pub fn ensure_mime_classes() { INIT_MIME_CLASSES.call_once(|| unsafe { js_register_class_id(CLASS_ID_MIME_TYPE); @@ -520,6 +585,20 @@ pub fn ensure_mime_classes() { mime_type_to_string_vtable as *const () as usize, 0, ); + for (name, getter) in [ + ("type", mime_type_get_type_vtable as *const () as usize), + ( + "subtype", + mime_type_get_subtype_vtable as *const () as usize, + ), + ( + "essence", + mime_type_get_essence_vtable as *const () as usize, + ), + ("params", mime_type_get_params_vtable as *const () as usize), + ] { + register_getter(CLASS_ID_MIME_TYPE, name, getter); + } register_setter( CLASS_ID_MIME_TYPE, "type", diff --git a/docs/src/testing/ci-gate-scheduling.md b/docs/src/testing/ci-gate-scheduling.md index 63cbd8c708..094e0a54fc 100644 --- a/docs/src/testing/ci-gate-scheduling.md +++ b/docs/src/testing/ci-gate-scheduling.md @@ -215,6 +215,30 @@ queued/in-progress/cancelled runs, and a gate whose only recent results are its `main` arm was dark), and asserts the verdict for each. A green `--self-test` means the detector works, not that nothing was tried. +## A completed red gate must create work (#9830) + +Fresh execution is only half of the contract. A scheduled gate can run, fail +correctly, and stay red in the Actions list without anyone owning the failure. +`gate-failure-watch.yml` observes the completed post-merge workflows in +`scripts/gate_failure_watch.json` and maintains one issue per failing workflow. +The issue records the run URL, head SHA, failed job/step rows, the rows added or +removed since the previous failure, and the last green main SHA. Repeated failures +update the same issue; the next green run comments and closes it. + +The observer accepts scheduled and main-branch dispatch runs, main pushes, and +release-tag pushes. It ignores pull-request and feature-branch runs. Its +`workflow_run` job has `issues: write`, so it always checks out the trusted `main` +copy of the script; pull requests run only the offline self-test and configuration +check with read-only permissions. + +When adding or renaming a post-merge gate, update `gate_failure_watch.json` and the +observer's `workflow_run.workflows` list together. The configuration check also +requires every workflow tracked by `gate_freshness.json` to be watched or explicitly +excluded with a reason. + +For every gate, ask: if this fails on `main` tonight, who finds out, and how? A run +visible only in the Actions list has no owner. + ## The queue in front of the schedule (#7966) A six-hourly sweep only helps if the queue drains faster than six hours. On diff --git a/scripts/gate_failure_watch.json b/scripts/gate_failure_watch.json new file mode 100644 index 0000000000..5cdc528046 --- /dev/null +++ b/scripts/gate_failure_watch.json @@ -0,0 +1,24 @@ +{ + "default_branch": "main", + "tag_pattern": "^v[0-9]", + "workflows": [ + { "path": "auto-opt-app-patterns.yml", "name": "Auto-Optimize App Patterns" }, + { "path": "eh-transport.yml", "name": "eh-transport" }, + { "path": "gate-freshness.yml", "name": "Gate Freshness" }, + { "path": "gc-moving-witnesses.yml", "name": "GC Moving Witnesses" }, + { "path": "gc-native-roots.yml", "name": "gc-native-roots" }, + { "path": "gc-parse-churn-gate.yml", "name": "GC Parse-Churn Layout Gate" }, + { "path": "gc-ptr-shape-off-witness.yml", "name": "GC Ptr OFF-arm witness" }, + { "path": "gc-ratchet.yml", "name": "GC Ratchet" }, + { "path": "gc-root-dominance.yml", "name": "GC Root Dominance" }, + { "path": "llvm-inprocess.yml", "name": "llvm-inprocess" }, + { "path": "test.yml", "name": "CI" }, + { "path": "tls-budget.yml", "name": "TLS Budget" } + ], + "excluded": [ + { + "path": "npm-publish-freshness.yml", + "why": "This workflow already maintains its own sticky failure issue." + } + ] +} diff --git a/scripts/gate_failure_watch.py b/scripts/gate_failure_watch.py new file mode 100644 index 0000000000..545bab98ae --- /dev/null +++ b/scripts/gate_failure_watch.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +"""Maintain one issue per failing scheduled/main gate workflow. + +GitHub's red Actions result is passive state. This observer turns completed +scheduled, main-dispatch, main-push, and release-tag gate failures into a +durable issue. The issue records the run, head SHA, failed job/step rows, the +delta from the prior failed run, and the last green main SHA. A later green run +closes the same issue; a recurrence reopens it instead of creating a duplicate. + +The workflow_run job executes this file from the default branch. Pull requests +exercise only --self-test and --check-config without an issue-writing token. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import subprocess +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parent.parent +CONFIG = ROOT / "scripts/gate_failure_watch.json" +FRESHNESS_CONFIG = ROOT / "scripts/gate_freshness.json" +WATCH_WORKFLOW = ROOT / ".github/workflows/gate-failure-watch.yml" +WORKFLOW_DIR = ROOT / ".github/workflows" +RED_CONCLUSIONS = {"action_required", "failure", "startup_failure", "timed_out"} +ApiRequest = Callable[[str, str, dict[str, str] | None], dict[str, Any]] + + +@dataclass(frozen=True) +class WatchConfig: + branch: str + tag_pattern: str + workflows: dict[str, str] + excluded: dict[str, str] + + +def load_config(path: pathlib.Path = CONFIG) -> WatchConfig: + raw = json.loads(path.read_text()) + workflows = {item["path"]: item["name"] for item in raw["workflows"]} + excluded = {item["path"]: item["why"] for item in raw.get("excluded", [])} + if len(workflows) != len(raw["workflows"]): + raise ValueError("gate_failure_watch.json contains duplicate workflow paths") + if not raw.get("default_branch") or not raw.get("tag_pattern"): + raise ValueError("default_branch and tag_pattern are required") + return WatchConfig(raw["default_branch"], raw["tag_pattern"], workflows, excluded) + + +def workflow_path(run: dict[str, Any]) -> str: + path = str(run.get("path") or "").split("@", 1)[0] + return pathlib.PurePosixPath(path).name + + +def eligible_run(run: dict[str, Any], config: WatchConfig) -> bool: + """Whether RUN is a post-merge result this observer owns.""" + if workflow_path(run) not in config.workflows: + return False + event = run.get("event") + branch = run.get("head_branch") + if event == "schedule": + return branch in (None, config.branch) + if event in {"repository_dispatch", "workflow_dispatch"}: + return branch == config.branch + if event == "push": + return branch == config.branch or bool(re.match(config.tag_pattern, branch or "")) + return False + + +def failure_rows(jobs: Sequence[dict[str, Any]]) -> list[str]: + """Stable failed job/step identifiers, including matrix job names.""" + rows: set[str] = set() + for job in jobs: + if job.get("conclusion") not in RED_CONCLUSIONS: + continue + job_name = str(job.get("name") or "unnamed job") + failed_steps = [ + str(step.get("name") or "unnamed step") + for step in job.get("steps") or [] + if step.get("conclusion") in RED_CONCLUSIONS + ] + if failed_steps: + rows.update(f"{job_name} / {step}" for step in failed_steps) + else: + rows.add(job_name) + return sorted(rows) + + +def _gh_api(method: str, path: str, fields: dict[str, str] | None = None) -> dict[str, Any]: + args = [ + "gh", + "api", + "--method", + method, + "-H", + "Accept: application/vnd.github+json", + ] + for key, value in (fields or {}).items(): + args.extend(["-f", f"{key}={value}"]) + args.append(path) + proc = subprocess.run(args, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError(f"gh api {method} {path} failed: {proc.stderr.strip()}") + return json.loads(proc.stdout) if proc.stdout.strip() else {} + + +def get_jobs(repo: str, run_id: int, request: ApiRequest = _gh_api) -> list[dict[str, Any]]: + response = request( + "GET", + f"repos/{repo}/actions/runs/{run_id}/jobs", + {"filter": "all", "per_page": "100"}, + ) + return list(response.get("jobs") or []) + + +def get_history( + repo: str, workflow_id: int, branch: str, request: ApiRequest = _gh_api +) -> list[dict[str, Any]]: + response = request( + "GET", + f"repos/{repo}/actions/workflows/{workflow_id}/runs", + {"branch": branch, "status": "completed", "per_page": "100"}, + ) + return list(response.get("workflow_runs") or []) + + +def previous_runs( + current: dict[str, Any], history: Sequence[dict[str, Any]], config: WatchConfig +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + current_created = str(current.get("created_at") or "") + prior = [ + run + for run in history + if run.get("id") != current.get("id") + and eligible_run(run, config) + and str(run.get("created_at") or "") < current_created + ] + prior.sort(key=lambda run: str(run.get("created_at") or ""), reverse=True) + failed = next((run for run in prior if run.get("conclusion") in RED_CONCLUSIONS), None) + green = next((run for run in prior if run.get("conclusion") == "success"), None) + return failed, green + + +def issue_marker(path: str) -> str: + return f"[scheduled-gate:{path}]" + + +def _run_link(run: dict[str, Any] | None, fallback: str) -> str: + if run is None: + return fallback + url = run.get("html_url") or "" + sha = str(run.get("head_sha") or "unknown") + label = f"`{sha[:12]}`" + return f"[{label}]({url})" if url else label + + +def _bullet_rows(rows: Sequence[str], empty: str) -> list[str]: + return [f"- `{row}`" for row in rows] if rows else [f"- {empty}"] + + +def issue_body( + path: str, + name: str, + current: dict[str, Any], + current_rows: Sequence[str], + previous: dict[str, Any] | None, + previous_rows: Sequence[str], + last_green: dict[str, Any] | None, +) -> str: + current_set = set(current_rows) + previous_set = set(previous_rows) + added = sorted(current_set - previous_set) + resolved = sorted(previous_set - current_set) + run_url = current.get("html_url") or "" + sha = str(current.get("head_sha") or "unknown") + lines = [ + f"`{name}` has a completed red post-merge run.", + "", + "## Current failure", + "", + f"- Run: [{current.get('id')}]({run_url})", + f"- Head: `{sha}`", + f"- Trigger: `{current.get('event') or 'unknown'}`", + "", + "## Failing rows", + "", + *_bullet_rows(current_rows, "The API reported no failed job or step; inspect the run."), + "", + "## Delta from the previous failed run", + "", + ] + if previous is None: + lines.append("No earlier failed post-merge run was found in the last 100 results.") + else: + lines += [ + f"Previous failure: {_run_link(previous, 'unknown')}", + "", + "New failing rows:", + *_bullet_rows(added, "None."), + "", + "Rows that recovered:", + *_bullet_rows(resolved, "None."), + ] + lines += [ + "", + "## Last green main result", + "", + _run_link(last_green, "No green post-merge run was found in the last 100 results."), + "", + f"_Maintained automatically by `gate-failure-watch.yml` for `{path}`. " + "Repeated failures update this issue; the next green run closes it._", + ] + return "\n".join(lines) + + +def find_issue( + repo: str, marker: str, request: ApiRequest = _gh_api +) -> dict[str, Any] | None: + found = request( + "GET", + "search/issues", + {"q": f'repo:{repo} is:issue in:title "{marker}"', "per_page": "100"}, + ) + matches = [item for item in found.get("items") or [] if marker in item.get("title", "")] + return max(matches, key=lambda item: int(item["number"]), default=None) + + +def sync_failure_issue( + repo: str, + path: str, + name: str, + body: str, + request: ApiRequest = _gh_api, +) -> None: + marker = issue_marker(path) + title = f"{marker} {name} is failing on main" + existing = find_issue(repo, marker, request) + if existing is None: + created = request("POST", f"repos/{repo}/issues", {"title": title, "body": body}) + print(f"opened gate failure issue {created.get('html_url', '')}".rstrip()) + return + number = int(existing["number"]) + request( + "PATCH", + f"repos/{repo}/issues/{number}", + {"state": "open", "title": title, "body": body}, + ) + action = "reopened" if existing.get("state") != "open" else "updated" + print(f"{action} gate failure issue #{number}") + + +def close_failure_issue( + repo: str, + path: str, + name: str, + run: dict[str, Any], + request: ApiRequest = _gh_api, +) -> None: + existing = find_issue(repo, issue_marker(path), request) + if existing is None or existing.get("state") != "open": + print(f"{name}: green; no open failure issue") + return + number = int(existing["number"]) + sha = str(run.get("head_sha") or "unknown") + url = run.get("html_url") or "" + request( + "POST", + f"repos/{repo}/issues/{number}/comments", + {"body": f"Green again at [`{sha[:12]}`]({url}); closing automatically."}, + ) + request( + "PATCH", + f"repos/{repo}/issues/{number}", + {"state": "closed", "state_reason": "completed"}, + ) + print(f"closed gate failure issue #{number}") + + +def handle_event( + payload: dict[str, Any], + repo: str, + config: WatchConfig, + request: ApiRequest = _gh_api, + dry_run: bool = False, +) -> int: + run = payload.get("workflow_run") or {} + path = workflow_path(run) + if not eligible_run(run, config): + print( + f"skip: {path or run.get('name') or 'unknown workflow'} " + f"event={run.get('event')} branch={run.get('head_branch')}" + ) + return 0 + name = config.workflows[path] + conclusion = run.get("conclusion") + if conclusion == "success": + if not dry_run: + close_failure_issue(repo, path, name, run, request) + else: + print(f"dry-run: would close {issue_marker(path)} after green run") + return 0 + if conclusion not in RED_CONCLUSIONS: + print(f"skip: {name} conclusion={conclusion}") + return 0 + + rows = failure_rows(get_jobs(repo, int(run["id"]), request)) + history = get_history(repo, int(run["workflow_id"]), config.branch, request) + previous, last_green = previous_runs(run, history, config) + previous_rows = ( + failure_rows(get_jobs(repo, int(previous["id"]), request)) if previous else [] + ) + body = issue_body(path, name, run, rows, previous, previous_rows, last_green) + print(body) + if not dry_run: + sync_failure_issue(repo, path, name, body, request) + return 0 + + +def check_config() -> int: + try: + import yaml + except ImportError: + print("PyYAML is required for --check-config", file=sys.stderr) + return 2 + + config = load_config() + failures: list[str] = [] + for path, expected_name in config.workflows.items(): + workflow_file = WORKFLOW_DIR / path + if not workflow_file.is_file(): + failures.append(f"watched workflow does not exist: {path}") + continue + actual_name = (yaml.safe_load(workflow_file.read_text()) or {}).get("name") + if actual_name != expected_name: + failures.append(f"{path}: configured name {expected_name!r}, actual {actual_name!r}") + + observer = yaml.load(WATCH_WORKFLOW.read_text(), Loader=yaml.BaseLoader) + trigger_names = set(observer["on"]["workflow_run"]["workflows"]) + configured_names = set(config.workflows.values()) + if trigger_names != configured_names: + failures.append( + "workflow_run trigger/config mismatch: " + f"missing={sorted(configured_names - trigger_names)} " + f"extra={sorted(trigger_names - configured_names)}" + ) + + freshness = json.loads(FRESHNESS_CONFIG.read_text()) + required = { + gate.get("source_workflow", gate["workflow"]) for gate in freshness["gates"] + } + missing = required - set(config.workflows) - set(config.excluded) + if missing: + failures.append(f"freshness-tracked workflows neither watched nor excluded: {sorted(missing)}") + stale_exclusions = set(config.excluded) - required + if stale_exclusions: + failures.append(f"exclusions no longer tracked by gate freshness: {sorted(stale_exclusions)}") + + if failures: + print("gate failure watch configuration FAILED:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + print( + f"gate failure watch configuration: {len(config.workflows)} workflows watched, " + f"{len(config.excluded)} explicitly excluded" + ) + return 0 + + +def self_test() -> int: + config = WatchConfig( + "main", "^v[0-9]", {"gc-ratchet.yml": "GC Ratchet"}, {} + ) + failures: list[str] = [] + + def run(**overrides: Any) -> dict[str, Any]: + base = { + "id": 10, + "workflow_id": 20, + "path": ".github/workflows/gc-ratchet.yml", + "name": "GC Ratchet", + "event": "schedule", + "head_branch": "main", + "head_sha": "a" * 40, + "html_url": "https://example.test/runs/10", + "created_at": "2026-09-06T10:00:00Z", + "conclusion": "failure", + } + base.update(overrides) + return base + + for label, candidate, want in [ + ("scheduled main", run(), True), + ("main dispatch", run(event="workflow_dispatch"), True), + ("release tag", run(event="push", head_branch="v0.5.1"), True), + ("pull request", run(event="pull_request", head_branch="feature"), False), + ("feature dispatch", run(event="workflow_dispatch", head_branch="feature"), False), + ("unknown workflow", run(path=".github/workflows/other.yml"), False), + ]: + if eligible_run(candidate, config) != want: + failures.append(f"eligibility: {label}") + + jobs = [ + { + "name": "matrix (linux)", + "conclusion": "failure", + "steps": [ + {"name": "Build", "conclusion": "success"}, + {"name": "Compare rows", "conclusion": "failure"}, + ], + }, + {"name": "aggregate", "conclusion": "timed_out", "steps": []}, + {"name": "green", "conclusion": "success", "steps": []}, + ] + rows = failure_rows(jobs) + if rows != ["aggregate", "matrix (linux) / Compare rows"]: + failures.append(f"failure row extraction: {rows}") + + history = [ + run(id=10), + run(id=9, head_sha="b" * 40, created_at="2026-09-06T09:00:00Z"), + run( + id=8, + head_sha="c" * 40, + created_at="2026-09-06T08:00:00Z", + conclusion="success", + ), + run(id=7, event="pull_request", head_branch="feature"), + ] + previous, green = previous_runs(run(), history, config) + if previous is None or previous.get("id") != 9: + failures.append("previous failure selection") + if green is None or green.get("id") != 8: + failures.append("last green selection") + + body = issue_body( + "gc-ratchet.yml", + "GC Ratchet", + run(), + ["matrix / new", "shared"], + previous, + ["matrix / old", "shared"], + green, + ) + for text in ["matrix / new", "matrix / old", "`cccccccccccc`", "Delta"]: + if text not in body: + failures.append(f"issue body omitted {text!r}") + + calls: list[tuple[str, str, dict[str, str] | None]] = [] + + def closed_request( + method: str, path: str, fields: dict[str, str] | None = None + ) -> dict[str, Any]: + calls.append((method, path, fields)) + if path == "search/issues": + return { + "items": [ + { + "number": 41, + "title": f"{issue_marker('gc-ratchet.yml')} old", + "state": "closed", + } + ] + } + return {} + + sync_failure_issue("PerryTS/perry", "gc-ratchet.yml", "GC Ratchet", body, closed_request) + if [(method, path) for method, path, _ in calls] != [ + ("GET", "search/issues"), + ("PATCH", "repos/PerryTS/perry/issues/41"), + ]: + failures.append("closed issue was not reused and reopened") + elif calls[-1][2] is None or calls[-1][2].get("state") != "open": + failures.append("recurring failure did not reopen the issue") + + calls.clear() + + def open_request( + method: str, path: str, fields: dict[str, str] | None = None + ) -> dict[str, Any]: + calls.append((method, path, fields)) + if path == "search/issues": + return { + "items": [ + { + "number": 42, + "title": f"{issue_marker('gc-ratchet.yml')} active", + "state": "open", + } + ] + } + return {} + + close_failure_issue( + "PerryTS/perry", + "gc-ratchet.yml", + "GC Ratchet", + run(conclusion="success"), + open_request, + ) + if [(method, path) for method, path, _ in calls] != [ + ("GET", "search/issues"), + ("POST", "repos/PerryTS/perry/issues/42/comments"), + ("PATCH", "repos/PerryTS/perry/issues/42"), + ]: + failures.append("green run did not comment and close the open issue") + elif calls[-1][2] != {"state": "closed", "state_reason": "completed"}: + failures.append("green run did not close the issue as completed") + + if failures: + print("gate failure watch self-test FAILED:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + print("gate failure watch self-test passed") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + parser.add_argument("--check-config", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--event", type=pathlib.Path, default=None) + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "PerryTS/perry")) + args = parser.parse_args(argv) + if args.self_test: + return self_test() + if args.check_config: + return check_config() + event_path = args.event or ( + pathlib.Path(os.environ["GITHUB_EVENT_PATH"]) if "GITHUB_EVENT_PATH" in os.environ else None + ) + if event_path is None: + parser.error("--event or GITHUB_EVENT_PATH is required") + payload = json.loads(event_path.read_text()) + return handle_event(payload, args.repo, load_config(), dry_run=args.dry_run) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 940d8738b7..1bbbb6b148 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -395,6 +395,54 @@ "verdict": "not_a_gc_pointer", "why": "RegExp construction/exec diagnostics (`PERRY_REGEX_DIAG`), off unless armed. Every field is a counter or an `Instant` except `per_pattern: HashMap`, whose VALUE is Rust-owned (an owned prefix `String`, a flags `String`, counters). The KEY is a pattern `StringHeader` address, so it is a heap address \u2014 but it is used ONLY as an opaque grouping id and is never dereferenced: `PatStat::prefix` and `byte_len` are filled from the `&[u8]` argument at first insert, never by reading the key. Nothing here is traced, rooted or rewritten. The one consequence of the address being reused after a pattern dies is that two patterns' diagnostic counters merge into one row \u2014 an inaccuracy in an off-by-default diagnostic, with no collector implication. Distinct from `PASS1_MARKED`, whose addresses ARE walked and which therefore carries `non_moving_snapshot` with a pinned window." }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "name": "DECLINE_EMPTY", + "verdict": "not_a_gc_pointer", + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "name": "DECLINE_NOT_GRAPHEME", + "verdict": "not_a_gc_pointer", + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "name": "DECLINE_NOT_SEGMENTER", + "verdict": "not_a_gc_pointer", + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "name": "DECLINE_NOT_STRING", + "verdict": "not_a_gc_pointer", + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "name": "DECLINE_NOT_UTF8", + "verdict": "not_a_gc_pointer", + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "name": "DECLINE_SEGMENT_PATCHED", + "verdict": "not_a_gc_pointer", + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "name": "MATERIALISE_SEGMENT", + "verdict": "not_a_gc_pointer", + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + }, + { + "file": "crates/perry-runtime/src/intl/segments_view.rs", + "name": "OPENS", + "verdict": "not_a_gc_pointer", + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + }, { "file": "crates/perry-runtime/src/map.rs", "name": "MAP_COMPACTION_LOG", diff --git a/test-files/dynamic_import_alias_binding.ts b/test-files/dynamic_import_alias_binding.ts new file mode 100644 index 0000000000..4cfc65914f --- /dev/null +++ b/test-files/dynamic_import_alias_binding.ts @@ -0,0 +1,22 @@ +var aliasedVar = new Set(["var-before"]); +let aliasedLet = new Set(["let-before"]); +const aliasedConst = new Set(["const"]); + +export const directConst = new Set(["direct"]); + +function check(values: Set, expected: string): boolean { + return values.has(expected); +} + +function reassign(): void { + aliasedVar = new Set(["var-after"]); + aliasedLet = new Set(["let-after"]); +} + +export { + aliasedVar as VAR_SET, + aliasedLet as LET_SET, + aliasedConst as CONST_SET, + check as checkAlias, + reassign, +}; diff --git a/test-files/test_gap_dynamic_import_alias_binding.ts b/test-files/test_gap_dynamic_import_alias_binding.ts new file mode 100644 index 0000000000..ef2d35e430 --- /dev/null +++ b/test-files/test_gap_dynamic_import_alias_binding.ts @@ -0,0 +1,29 @@ +// parity-env: PERRY_GC_SCHEDULE_SEED=9778 PERRY_GC_SCHEDULE_RATE=0.25 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_PROTECT_FROMSPACE=1 + +async function main(): Promise { + const ns = await import("./dynamic_import_alias_binding.ts"); + + console.log( + typeof ns.VAR_SET, + typeof ns.LET_SET, + typeof ns.CONST_SET, + typeof ns.directConst, + typeof ns.checkAlias, + ); + console.log( + ns.checkAlias(ns.VAR_SET, "var-before"), + ns.checkAlias(ns.LET_SET, "let-before"), + ns.checkAlias(ns.CONST_SET, "const"), + ns.checkAlias(ns.directConst, "direct"), + ); + + ns.reassign(); + console.log( + ns.checkAlias(ns.VAR_SET, "var-after"), + ns.checkAlias(ns.LET_SET, "let-after"), + ns.checkAlias(ns.VAR_SET, "var-before"), + ns.checkAlias(ns.LET_SET, "let-before"), + ); +} + +main(); diff --git a/test-parity/node-suite/test/mock-timers/validation.ts b/test-parity/node-suite/test/mock-timers/validation.ts index 6f69fbc0fb..d33f949fc9 100644 --- a/test-parity/node-suite/test/mock-timers/validation.ts +++ b/test-parity/node-suite/test/mock-timers/validation.ts @@ -12,7 +12,10 @@ function codeOf(fn: () => void): string { console.log("runAll disabled:", codeOf(() => mock.timers.runAll())); console.log("tick disabled:", codeOf(() => mock.timers.tick())); console.log("setTime disabled:", codeOf(() => mock.timers.setTime(1))); -console.log("bad options:", codeOf(() => mock.timers.enable(null as any))); +console.log("null options:", codeOf(() => mock.timers.enable(null as any))); +mock.timers.reset(); +console.log("number options:", codeOf(() => mock.timers.enable(1 as any))); +mock.timers.reset(); console.log("bad api type:", codeOf(() => mock.timers.enable({ apis: [1 as any] }))); console.log("bad now:", codeOf(() => mock.timers.enable({ now: -1 }))); mock.timers.enable({ apis: ["Date"], now: 0 });