Repository navigation
gc: constructor arguments on lower_new's non-class branches (Readline / imported ctor / new Function) and builtin.rs are still not precise roots #6986
Description
Activity
The
new.rshalf is fixed in #7699.builtin.rsis not, and I am leaving this issue open for it — see the inventory at the bottom.What landed
The three branches this issue names all early-
returnbefore the main class loop ever adopts into the enclosing scope, solower_new_impl'sopen_rooted_groupwas open around them and empty — lexical containment is not protection when nothing is adopted, andRootedGroup::releasetruncates from the group's own lowest slot, so an empty group emits nothing at all.new Readline(…)—outputsat in a bare SSA register acrossoptions' lowering and across every trailing argument's;new <importedFn>(…)—func_doubleacross every argument, and argumentiacross the arguments after it;new Function(…)with a dynamic body — the same loop.
All three now adopt into the group as each operand is produced and re-read at the call. Interleaved, not appended: this issue's own trap ("rooting the finished list publishes an already-dangling argument 0 to the scanner, which turns a silent DIFF into a SIGSEGV") is restated on the shared
adopt_constructor_argshelper so the next edit reads it.Two details worth recording:
- the helper keeps
lower_constructor_argrather thanRootedGroup::lower, because it clearsctx.discard_expr_valuefor the operand — fix(codegen): a typed-array element store used as an expression evaluates to 0 — discard_expr_value leaks into operand position #7590: that flag means "this STATEMENT's value is discarded", is not cleared on recursion, and lowering an operand under it can evaluate a typed-array store to0; - the
undefinedfillers for absent readline arguments stay literals, sonew Readline()still costs no slot.
lower_js_args_arrayis confirmed to be no rescue and is untouched:alloca_entry_arrayemits a bare LLVMallocawith nojs_shadow_slot_bindand no shadow-slot reservation (the opt-in isexpr/scalar_slot_root.rs'sroot_entry_alloca, which nothing on these paths calls). It copies whatever bits it is handed, stale or not, so the repair has to happen before it runs — which is where it now is.Still open:
lower_call/builtin.rslower_builtin_new(ctx, class_name, args)takes no rooting context at all, and is called fromlower_new_impl_innerbefore that function's group is populated — so these are structurally unreachable from anew.rs-level fix. Each needs its ownrooting::with_operands_rootedover the argument list.The arms that lower 2+ argument expressions sequentially and then use the earlier register(s):
constructor lines callee Uint8Array(≥2 args)141–165 js_uint8array_viewtyped-array views ( Int8Array…Float16Array, ≥2 args)171–195 js_typed_array_viewDataView200–228 js_data_view_newRegExp229–252 js_regexp_constructEvent374–396 js_event_newCustomEvent397–419 js_custom_event_newDOMException420–441 js_dom_exception_newConsole(2-arg form)442–464 js_console_new2DatabaseSync612–630 js_node_sqlite_database_sync_newStatementSync631–649 js_node_sqlite_statement_sync_newSession650–668 js_node_sqlite_session_newCronJob739–768 js_cron_job_newAsyncResource803–821 js_async_resource_newSuppressedError848–870 js_suppressed_error_newResponse(init fields)914–1029 js_response_newBlob1038–1064 js_blob_newFile1066–1106 js_file_newHeaders(per-key loop, accumulator + pair)1108–1137 js_headers_setRequest(init fields)1147–1276 js_request_new/_from_initReadableStream1282–1337 js_readable_stream_new_with_strategy_and_source_typeWritableStream1339–1412 js_writable_stream_new_with_sink_typeTransformStream1414–1477 js_transform_stream_newTextDecoderStream1489–1509 js_stream_web_text_decoder_stream_newRuled out on inspection, so they are not missing from the list:
EventEmitter,EventEmitterAsyncResource,BlockList,SocketAddress,X509Certificate,PerformanceObserver,StringDecoder,Decimal,CountQueuingStrategy,ByteLengthQueuingStrategy,CompressionStream,ReadableStreamBYOBReader,WeakMap,WeakSet— each lowers its extra arguments withlet _ = lower_expr(...), so nothing is retained across the loop to go stale. Same fornew.rs's ownCrypto | CryptoKey | SubtleCryptoarm and the empty-placeholder fallback: they emit plenty of IR, but no value survives to be consumed later, which is a different thing from a zero-emission false positive.CronJobis the one that needs care rather than a mechanical wrap: its first operand is a rawi64fromget_raw_string_ptr(which islower_exprplus a realjs_get_string_pointer_unifiedcall), so it is #7280 taxonomy (a) and wants the derivation moved below the later operands, not a slot.- added 4 commits that reference this issue
on Aug 9, 2026
Found while fixing #6969 in #6983, and deliberately left out of that PR's scope.
#6983 roots the constructor arguments built by
lower_new_impl_inner's mainclass path (
crates/perry-codegen/src/lower_call/new.rs, thelowered_argsloop reached after the
ctx.classeslookup). Three earlier branches of thesame function build multi-argument lists the same way and are still unrooted, as
are the multi-argument built-in constructors in
lower_call/builtin.rs.Sites
crates/perry-codegen/src/lower_call/new.rs:new Readline(...)(node:readline/promises) — lowersoutput, thenoptions, thenfor extra in args.iter().skip(2) { lower_expr(...) }, andpasses
output/optionslive intojs_readline_promises_readline_new. Alater argument's lowering can collect while the first two sit in SSA
registers.
new <importedFn>(...)—for a in args { lowered_args.push(lower_constructor_arg(ctx, a)?) }then
lower_js_args_array, which is a plain alloca pack with no rooting(
expr/helpers.rs), passed tojs_new_function_construct.new Function(...)with a dynamic body — the identical loop, intojs_function_ctor_from_strings.crates/perry-codegen/src/lower_call/builtin.rs(untouched by #6983): severalbuilt-in constructors lower
args[0]thenargs[1]sequentially via plainlower_expr.lower_builtin_newis called from insidelower_new_impl_inner,so these are on the same path. I did not audit each one; the shape is the same.
Why these are cheap to fix
#6983 already wraps the whole function in an expression-scope temp-root barrier
(
temp_root_scope_begin/_endinlower_new_impl), and it is a stack cut —so these branches are already inside a scope that releases correctly on every
return path. What is missing is only the per-argument push + re-read. The
primitives are
temp_root::operand_needs_root,temp_root_push_double,temp_root_get_double, andoperand_is_reloadablefor operands that read aregistered root.
One trap, learned the hard way in #6983: the push must be interleaved with
the lowering loop, not appended after it. Rooting the finished list publishes an
already-dangling argument 0 to the scanner, which turns a silent DIFF into a
SIGSEGV — strictly worse than not rooting at all.
Detection
Same as the rest of the family: invisible with the conservative native-stack
scan on. Reproduces under
PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_HEAP_LIMIT=8with a shape likenew Function(fresh(0), "return " + churn(N)), and under the evacuating armfrom #6977 (
PERRY_GC_INCREMENTAL=0+ the above).Related: #6969, #6983, #6951, #6972, #6975, #6981, #6968.