diff --git a/changelog.d/8454-string-accumulators.md b/changelog.d/8454-string-accumulators.md new file mode 100644 index 0000000000..4812e16e54 --- /dev/null +++ b/changelog.d/8454-string-accumulators.md @@ -0,0 +1,6 @@ +### Performance + +- Keep string accumulators stored in async/closure variable boxes, captures, + and module globals on Perry's amortized append path. Ordinary reads and + local-to-local assignments still demote extracted aliases before later + in-place growth. diff --git a/crates/perry-codegen/src/codegen/declared_string_add_tests.rs b/crates/perry-codegen/src/codegen/declared_string_add_tests.rs index bc73cfe5ed..cb9d241998 100644 --- a/crates/perry-codegen/src/codegen/declared_string_add_tests.rs +++ b/crates/perry-codegen/src/codegen/declared_string_add_tests.rs @@ -106,8 +106,7 @@ fn ir(params: Vec, body: Expr) -> String { } fn function_ir(module: Module) -> String { - let ir = - String::from_utf8(compile_module(&module, ir_opts()).unwrap()).expect("LLVM IR is UTF-8"); + let ir = module_ir(module); // An ordinary typed parameter may now produce a public guard wrapper plus // proof-bearing and generic clones (#8079). This suite's subject remains // the annotation-distrusting body, so inspect the generic clone when one @@ -134,6 +133,10 @@ fn function_ir(module: Module) -> String { ir[start..end].to_string() } +fn module_ir(module: Module) -> String { + String::from_utf8(compile_module(&module, ir_opts()).unwrap()).expect("LLVM IR is UTF-8") +} + fn add(left: Expr, right: Expr) -> Expr { Expr::Binary { op: BinaryOp::Add, @@ -394,6 +397,208 @@ fn a_self_append_chain_keeps_an_opaque_numeric_head_pair_intact() { ); } +#[test] +fn a_module_global_self_append_uses_the_amortized_path_and_demotes_extractions() { + const GLOBAL: u32 = 10; + let value = add( + add(Expr::LocalGet(GLOBAL), Expr::String("[".to_string())), + Expr::String("]".to_string()), + ); + let mut module = module_with(probe_fn_with_body( + Vec::new(), + vec![ + Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(Expr::LocalSet(GLOBAL, Box::new(value)))], + }, + Stmt::Return(Some(Expr::LocalGet(GLOBAL))), + ], + )); + module.init.push(Stmt::Let { + id: GLOBAL, + name: "module_accumulator".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String(String::new())), + }); + let ir = function_ir(module); + + assert!( + ir.contains("call i64 @js_string_append("), + "a module root is binding storage and can retain the unique string owner:\n{ir}" + ); + assert!( + ir.contains("call void @js_string_addref_if_heap_string("), + "returning the global extracts an alias and must demote it first:\n{ir}" + ); +} + +#[test] +fn a_boxed_local_self_append_uses_the_amortized_path() { + const ACC: u32 = 10; + const READER: u32 = 11; + let value = add(Expr::LocalGet(ACC), Expr::String("long-part".to_string())); + let module = module_with(probe_fn_with_body( + Vec::new(), + vec![ + Stmt::Let { + id: ACC, + name: "accumulator".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String(String::new())), + }, + // Capturing a local that is also mutated in this scope makes the + // source binding a shared variable box. + Stmt::Let { + id: READER, + name: "reader".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Closure { + func_id: 2, + params: Vec::new(), + return_type: Type::String, + body: vec![Stmt::Return(Some(Expr::LocalGet(ACC)))], + captures: vec![ACC], + mutable_captures: vec![ACC], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }), + }, + Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(Expr::LocalSet(ACC, Box::new(value)))], + }, + Stmt::Return(Some(Expr::LocalGet(ACC))), + ], + )); + let ir = function_ir(module); + + assert!( + ir.contains("call i64 @js_string_append("), + "a variable box must retain the accumulator owner across iterations:\n{ir}" + ); + assert!( + ir.contains("call i64 @js_box_get_bits(") && ir.contains("call void @js_box_set_bits("), + "the append result must be read from and written back to the box:\n{ir}" + ); +} + +#[test] +fn a_boxed_capture_self_append_uses_the_amortized_path() { + const ACC: u32 = 10; + const APPENDER: u32 = 11; + let module = module_with(probe_fn_with_body( + Vec::new(), + vec![ + Stmt::Let { + id: ACC, + name: "accumulator".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String(String::new())), + }, + Stmt::Let { + id: APPENDER, + name: "append".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Closure { + func_id: 2, + params: Vec::new(), + return_type: Type::String, + body: vec![ + Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(Expr::LocalSet( + ACC, + Box::new(add( + Expr::LocalGet(ACC), + Expr::String("long-part".to_string()), + )), + ))], + }, + Stmt::Return(Some(Expr::LocalGet(ACC))), + ], + captures: vec![ACC], + mutable_captures: vec![ACC], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }), + }, + Stmt::Return(Some(Expr::LocalGet(APPENDER))), + ], + )); + let ir = module_ir(module); + + assert!( + ir.contains("call i64 @js_string_append("), + "a captured variable box must reach the append helper:\n{ir}" + ); + assert!( + ir.contains("call i64 @js_closure_get_capture_bits(") + && ir.contains("call void @js_box_set_bits("), + "the captured owner must be dereferenced and written through its box:\n{ir}" + ); +} + +#[test] +fn a_module_global_numeric_capable_head_pair_does_not_select_append() { + const GLOBAL: u32 = 10; + let value = add( + add(Expr::LocalGet(GLOBAL), Expr::Number(1.0)), + Expr::String("x".to_string()), + ); + let mut module = module_with(probe_fn_with_body( + Vec::new(), + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(Expr::LocalSet(GLOBAL, Box::new(value)))], + }], + )); + module.init.push(Stmt::Let { + id: GLOBAL, + name: "lying_accumulator".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::Number(42.0)), + }); + let ir = function_ir(module); + + assert!( + !ir.contains("call i64 @js_string_append("), + "the newly eligible storage must not weaken the numeric-head guard:\n{ir}" + ); +} + +#[test] +fn assigning_one_local_to_another_demotes_a_possible_string_alias() { + let module = module_with(probe_fn_with_body( + vec![str_param(), param(2, "snapshot", Type::String)], + vec![ + Stmt::Expr(Expr::LocalSet(2, Box::new(Expr::LocalGet(1)))), + Stmt::Return(Some(Expr::Number(0.0))), + ], + )); + let ir = function_ir(module); + + assert!( + ir.contains("call void @js_string_addref_if_heap_string("), + "assignment aliases need the same demote as declaration aliases:\n{ir}" + ); +} + // ------------------------------------------------------- untouched tiers #[test] diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index 08f61579b5..d0ae044c3f 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -8,7 +8,9 @@ use anyhow::Result; use perry_hir::types::Type as HirType; use perry_hir::{BinaryOp, Expr, UpdateOp}; -use crate::lower_string_concat::{flatten_string_add_chain, lower_string_self_append}; +use crate::lower_string_concat::{ + can_lower_string_self_append, flatten_string_add_chain, lower_string_self_append, +}; use crate::nanbox::double_literal; use crate::native_value::MaterializationReason; use crate::type_analysis::{is_map_expr, is_set_expr, receiver_class_name}; @@ -21,6 +23,25 @@ use super::{ lower_pod_local_reassignment, materialize_pod_local, nanbox_string_inline, FnCtx, }; +/// A box, closure cell, or module root is the storage for the source binding, +/// not an alias of the string it currently owns. An ordinary read extracts a +/// second copy of that value, so demote a heap string before it can outlive the +/// cell and be silently changed by a later in-place append (#8432). +/// +/// The append lowering reads these targets directly and therefore deliberately +/// bypasses this rule. Limit the call to declared-string bindings: only those +/// bindings can select in-place append, and erased annotations remain safe +/// because the runtime helper checks the live tag. +fn demote_extracted_string_binding(ctx: &mut FnCtx<'_>, id: u32, value: &str) { + let persistent_binding = ctx.closure_captures.contains_key(&id) + || (ctx.boxed_vars.contains(&id) && !ctx.module_globals.contains_key(&id)) + || ctx.module_globals.contains_key(&id); + if persistent_binding && matches!(ctx.local_type_hint(&id), Some(HirType::String)) { + ctx.block() + .call_void("js_string_addref_if_heap_string", &[(DOUBLE, value)]); + } +} + /// #1380: method names addressable on a `Set` instance, used by the /// `typeof set.` fold to report "function" (Set method values are /// not materialized as real function objects). Includes the ES2024 @@ -404,14 +425,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &closure_ptr), (I32, &idx_str)], ); let bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]); - return Ok(blk.bitcast_i64_to_double(&bits)); + let value = blk.bitcast_i64_to_double(&bits); + demote_extracted_string_binding(ctx, *id, &value); + return Ok(value); } let bits = ctx.block().call( I64, "js_closure_get_capture_bits", &[(I64, &closure_ptr), (I32, &idx_str)], ); - return Ok(ctx.block().bitcast_i64_to_double(&bits)); + let value = ctx.block().bitcast_i64_to_double(&bits); + demote_extracted_string_binding(ctx, *id, &value); + return Ok(value); } // Boxed local in enclosing function: load the slot (box // pointer), deref via js_box_get_bits. @@ -434,7 +459,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let blk = ctx.block(); let box_ptr = blk.load(I64, &slot); let bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]); - return Ok(blk.bitcast_i64_to_double(&bits)); + let value = blk.bitcast_i64_to_double(&bits); + demote_extracted_string_binding(ctx, *id, &value); + return Ok(value); } } // Repsel Phase 1: a canonical-i32 local's ONLY storage is the i32 @@ -458,10 +485,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; return Ok(v); } - Ok(ctx.block().load(DOUBLE, &slot)) + let value = ctx.block().load(DOUBLE, &slot); + demote_extracted_string_binding(ctx, *id, &value); + Ok(value) } else if let Some(global_name) = ctx.module_globals.get(id).cloned() { let g_ref = format!("@{}", global_name); - Ok(ctx.block().load(DOUBLE, &g_ref)) + let value = ctx.block().load(DOUBLE, &g_ref); + demote_extracted_string_binding(ctx, *id, &value); + Ok(value) } else { // Soft fallback: the HIR sometimes carries stale // local references that don't correspond to any @@ -498,26 +529,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `js_string_concat_chain` would copy the growing `x` prefix on // every loop iteration, turning the otherwise-amortized append // path back into O(n^2) work (#8394). - // The fast path requires a plain alloca slot in `ctx.locals` — - // module globals (use `@global` loads), closure captures (use - // `js_closure_{get,set}_capture_bits`), and boxed vars (use - // `js_box_set_bits` through a heap cell) all need different store - // mechanics, so they fall through to the regular `LocalSet` - // path below. Issue #319: without the `ctx.locals.contains_key` - // / closure_captures / boxed_vars guards, a closure-captured - // string-typed local that does `s = s + t` aborted codegen - // with `string self-append: local N not in scope` because the - // helper's `ctx.locals.get(id)` lookup whiffed. + // The append helper abstracts over plain slots, module roots, + // closure captures, and variable boxes. Ordinary reads from the + // latter three storage families demote a heap string to shared; + // this owner read deliberately bypasses that extraction rule so + // the binding can retain uniqueness across iterations (#8432). // #7841: the tag-dispatched helper validates the destination's // current value before choosing append versus ordinary JS `+`. // This is therefore a dispatch hint, not a binding proof; using // the stable-only query would disable the optimization for every // self-append because this `LocalSet` is itself a reassignment. if matches!(ctx.local_type_hint(id), Some(HirType::String)) - && !ctx.module_globals.contains_key(id) - && !ctx.closure_captures.contains_key(id) - && !ctx.boxed_vars.contains(id) - && ctx.locals.contains_key(id) + && can_lower_string_self_append(ctx, *id) { if let Expr::Binary { op: BinaryOp::Add, @@ -615,6 +638,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } let v = lower_expr(ctx, value)?; + // `target = source` creates the same string-buffer alias as a + // `let target = source` initializer. The declaration path has + // demoted this shape since #5552, but assignment aliases were + // previously missed. Async lowering expresses mid-body snapshot + // variables as LocalSet, making that gap observable as the saved + // string growing in place with its boxed accumulator (#8432). + if matches!(value.as_ref(), Expr::LocalGet(source_id) if source_id != id) { + ctx.block() + .call_void("js_string_addref_if_heap_string", &[(DOUBLE, &v)]); + } // Closure captures first (write through the runtime), then // locals, then module globals. if let Some(&capture_idx) = ctx.closure_captures.get(id) { diff --git a/crates/perry-codegen/src/lower_string_concat.rs b/crates/perry-codegen/src/lower_string_concat.rs index 198616ee8f..dcfa3fabf6 100644 --- a/crates/perry-codegen/src/lower_string_concat.rs +++ b/crates/perry-codegen/src/lower_string_concat.rs @@ -15,16 +15,126 @@ use anyhow::{anyhow, Result}; use perry_hir::Expr; -use crate::expr::{lower_expr, nanbox_string_inline, unbox_str_handle, FnCtx}; +use crate::expr::{ + current_closure_ptr_value, emit_root_nanbox_store_on_block, emit_write_barrier, lower_expr, + nanbox_string_inline, unbox_str_handle, FnCtx, +}; use crate::type_analysis::is_string_expr; use crate::types::{DOUBLE, I32, I64}; use crate::rooting::{operand_may_collect, with_operands_rooted, with_rooted_group, Repr}; +/// Storage used by a source-level binding that can own an appendable string. +/// +/// Heap cells and module roots are variable storage, not aliases of the value +/// they contain. Keeping that distinction here lets them retain the unique +/// string bit until an ordinary `LocalGet` extracts the value (#8432). +enum StringAppendTarget { + LocalSlot(String), + BoxedLocal(String), + Captured { index: u32, boxed: bool }, + ModuleGlobal(String), +} + +impl StringAppendTarget { + fn for_local(ctx: &FnCtx<'_>, local_id: u32) -> Option { + if let Some(&index) = ctx.closure_captures.get(&local_id) { + return Some(Self::Captured { + index, + boxed: ctx.boxed_vars.contains(&local_id), + }); + } + if ctx.boxed_vars.contains(&local_id) && !ctx.module_globals.contains_key(&local_id) { + return ctx.locals.get(&local_id).cloned().map(Self::BoxedLocal); + } + if let Some(slot) = ctx.locals.get(&local_id).cloned() { + return Some(Self::LocalSlot(slot)); + } + ctx.module_globals + .get(&local_id) + .map(|name| Self::ModuleGlobal(format!("@{name}"))) + } + + fn load(&self, ctx: &mut FnCtx<'_>) -> Result { + match self { + Self::LocalSlot(slot) | Self::ModuleGlobal(slot) => Ok(ctx.block().load(DOUBLE, slot)), + Self::BoxedLocal(box_slot) => { + let blk = ctx.block(); + let box_ptr = blk.load(I64, box_slot); + let bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]); + Ok(blk.bitcast_i64_to_double(&bits)) + } + Self::Captured { index, boxed } => { + let closure_ptr = + current_closure_ptr_value(ctx, "captured string self-append load")?; + let index = index.to_string(); + let bits = ctx.block().call( + I64, + "js_closure_get_capture_bits", + &[(I64, &closure_ptr), (I32, &index)], + ); + if *boxed { + let value_bits = ctx.block().call(I64, "js_box_get_bits", &[(I64, &bits)]); + Ok(ctx.block().bitcast_i64_to_double(&value_bits)) + } else { + Ok(ctx.block().bitcast_i64_to_double(&bits)) + } + } + } + } + + fn store(&self, ctx: &mut FnCtx<'_>, value: &str) -> Result<()> { + match self { + Self::LocalSlot(slot) => ctx.block().store(DOUBLE, value, slot), + Self::ModuleGlobal(slot) => emit_root_nanbox_store_on_block(ctx.block(), value, slot), + Self::BoxedLocal(box_slot) => { + let blk = ctx.block(); + let box_ptr = blk.load(I64, box_slot); + let value_bits = blk.bitcast_double_to_i64(value); + blk.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, &value_bits)]); + emit_write_barrier(ctx, &box_ptr, &value_bits); + } + Self::Captured { index, boxed } => { + // The rhs and append helper can collect. Re-read the current + // closure root here rather than retaining its movable pointer + // from the load above (#7055). + let closure_ptr = + current_closure_ptr_value(ctx, "captured string self-append store")?; + let index = index.to_string(); + if *boxed { + let box_ptr = ctx.block().call( + I64, + "js_closure_get_capture_bits", + &[(I64, &closure_ptr), (I32, &index)], + ); + let value_bits = ctx.block().bitcast_double_to_i64(value); + ctx.block() + .call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, &value_bits)]); + emit_write_barrier(ctx, &box_ptr, &value_bits); + } else { + let value_bits = ctx.block().bitcast_double_to_i64(value); + ctx.block().call_void( + "js_closure_set_capture_bits", + &[(I64, &closure_ptr), (I32, &index), (I64, &value_bits)], + ); + emit_write_barrier(ctx, &closure_ptr, &value_bits); + } + } + } + Ok(()) + } +} + +/// Whether `local_id` has storage that can be read-modify-written by the +/// amortized string append lowering. +pub(crate) fn can_lower_string_self_append(ctx: &FnCtx<'_>, local_id: u32) -> bool { + StringAppendTarget::for_local(ctx, local_id).is_some() +} + /// Lower the `str = str + rhs` self-append pattern. Uses the in-place /// `js_string_append` runtime function (refcount=1 → mutate in place, -/// otherwise allocate). The returned pointer is stored back to the local -/// slot — `js_string_append` may realloc when growing past capacity. +/// otherwise allocate). The returned pointer is stored back to the binding's +/// slot/cell/root — `js_string_append` may realloc when growing past capacity. /// /// This is the load-bearing optimization for the canonical `let str = ""; /// for (...) str = str + "a"` string-build pattern. @@ -33,17 +143,14 @@ pub(crate) fn lower_string_self_append( local_id: u32, rhs: &Expr, ) -> Result { - let slot = ctx - .locals - .get(&local_id) - .ok_or_else(|| anyhow!("string self-append: local {} not in scope", local_id))? - .clone(); + let target = StringAppendTarget::for_local(ctx, local_id) + .ok_or_else(|| anyhow!("string self-append: local {} not in scope", local_id))?; // A declared string type is permission to select this lowering, not proof // that the slot contains a string. Use the same inline tag dispatch for // canonical and ordinary boxed locals: this keeps the true-string append // arm direct and makes the annotation-lie arm choose the real JS `+`. - lower_tag_dispatched_str_self_append(ctx, rhs, &slot) + lower_tag_dispatched_str_self_append(ctx, rhs, &target) } /// Repsel Phase 3a: is this expression PROVEN to lower to a heap-tagged @@ -148,7 +255,7 @@ pub(crate) fn str_operand_handle_tag_dispatched( fn lower_tag_dispatched_str_self_append( ctx: &mut FnCtx<'_>, rhs: &Expr, - slot: &str, + target: &StringAppendTarget, ) -> Result { use crate::nanbox::{ POINTER_MASK_I64, POINTER_TAG_TOP16_I64 as TAG_POINTER, @@ -161,7 +268,7 @@ fn lower_tag_dispatched_str_self_append( // ToString call. A pointer rhs needs Add's default-hint ToPrimitive, so // it joins the dynamic arm; primitive rhs values keep the old direct // coercion + in-place append sequence. - let lhs_box = ctx.block().load(DOUBLE, slot); + let lhs_box = target.load(ctx)?; let protect_lhs = operand_may_collect(ctx, rhs); return with_rooted_group(ctx, 0, |ctx, group| { let lhs_root = group.adopt_emitted(ctx, Repr::Boxed, &lhs_box, protect_lhs); @@ -198,7 +305,7 @@ fn lower_tag_dispatched_str_self_append( let lhs_after_coercion = if protect_lhs { group.reread_emitted(ctx, lhs_root) } else { - ctx.block().load(DOUBLE, slot) + target.load(ctx)? }; let bits_d_after = ctx.block().bitcast_double_to_i64(&lhs_after_coercion); let h_d = ctx.block().and(I64, &bits_d_after, POINTER_MASK_I64); @@ -223,7 +330,7 @@ fn lower_tag_dispatched_str_self_append( DOUBLE, &[(&box_append, &append_pred), (&box_dynamic, &dynamic_pred)], ); - ctx.block().store(DOUBLE, &new_box, slot); + target.store(ctx, &new_box)?; Ok(new_box) }); } @@ -242,7 +349,7 @@ fn lower_tag_dispatched_str_self_append( // dest heap, rhs lie → dynamic `+` (cold) // dest other → js_string_concat_box (SSO-aware and // total: lies delegate to dynamic `+`) - let lhs_box = ctx.block().load(DOUBLE, slot); + let lhs_box = target.load(ctx)?; let protect_lhs = operand_may_collect(ctx, rhs); with_rooted_group(ctx, 0, |ctx, group| { let lhs_root = group.adopt_emitted(ctx, Repr::Boxed, &lhs_box, protect_lhs); @@ -295,7 +402,7 @@ fn lower_tag_dispatched_str_self_append( let lhs_after_materialize = if protect_lhs { group.reread_emitted(ctx, lhs_root) } else { - ctx.block().load(DOUBLE, slot) + target.load(ctx)? }; let bits_d_after = ctx.block().bitcast_double_to_i64(&lhs_after_materialize); let h_d_after = ctx.block().and(I64, &bits_d_after, POINTER_MASK_I64); @@ -336,7 +443,7 @@ fn lower_tag_dispatched_str_self_append( (&box_other, &dother_pred), ], ); - ctx.block().store(DOUBLE, &new_box, slot); + target.store(ctx, &new_box)?; Ok(new_box) }) } diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 28858aaed0..27c25c38b6 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -105,6 +105,14 @@ "classification": "runtime-validated", "reason": "The hint selects a typed-array store helper that checks the runtime GC kind, or a write-invalidated buffer-view fact." }, + { + "path": "crates/perry-codegen/src/expr/literals_vars.rs", + "function": "demote_extracted_string_binding", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The hint limits demotion to bindings eligible for in-place append; js_string_addref_if_heap_string validates the current value's live tag before touching string ownership." + }, { "path": "crates/perry-codegen/src/expr/literals_vars.rs", "function": "lower", diff --git a/test-files/test_issue_8432_string_accumulators.ts b/test-files/test_issue_8432_string_accumulators.ts new file mode 100644 index 0000000000..465ef04338 --- /dev/null +++ b/test-files/test_issue_8432_string_accumulators.ts @@ -0,0 +1,64 @@ +// #8432: strings owned by module roots, closure/box cells, and async +// activation boxes must reach the amortized append path. Ordinary reads still +// extract aliases and must demote the owner before later in-place growth. + +const PART = "[abc]"; + +let moduleAccumulator = ""; + +function buildModuleGlobal(n: number): string { + moduleAccumulator = ""; + let snapshot = ""; + for (let i = 0; i < n; i++) { + moduleAccumulator = moduleAccumulator + "[" + "abc" + "]"; + if (i === (n >> 1)) snapshot = moduleAccumulator; + } + return [ + moduleAccumulator.length, + snapshot.length, + snapshot.slice(-5), + moduleAccumulator.slice(-5), + ].join(":"); +} + +function buildCaptured(n: number): string { + let accumulator = ""; + let snapshot = ""; + const append = () => { + accumulator = accumulator + PART; + }; + + for (let i = 0; i < n; i++) { + append(); + if (i === (n >> 1)) snapshot = accumulator; + } + return [accumulator.length, snapshot.length, snapshot === accumulator].join(":"); +} + +async function buildAsync(n: number): Promise { + let accumulator = ""; + let snapshot = ""; + for (let i = 0; i < n; i++) { + accumulator = accumulator + "[" + "abc" + "]"; + if (i === (n >> 1)) snapshot = accumulator; + } + + // Touch the completed bytes at a nontrivial stride so the fixture observes + // materialized string contents, not only metadata. + let checksum = 0; + for (let i = 0; i < accumulator.length; i += 997) { + checksum += accumulator.charCodeAt(i); + } + return [ + accumulator.length, + snapshot.length, + snapshot === accumulator, + checksum, + accumulator.slice(0, 5), + accumulator.slice(-5), + ].join(":"); +} + +console.log("global", buildModuleGlobal(2_000)); +console.log("capture", buildCaptured(2_000)); +buildAsync(2_000).then((result) => console.log("async", result));