Skip to content

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

@proggeramlug

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 main
class path
(crates/perry-codegen/src/lower_call/new.rs, the lowered_args
loop reached after the ctx.classes lookup). Three earlier branches of the
same 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) — lowers output, then
    options, then for extra in args.iter().skip(2) { lower_expr(...) }, and
    passes output/options live into js_readline_promises_readline_new. A
    later 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 to js_new_function_construct.
  • new Function(...) with a dynamic body — the identical loop, into
    js_function_ctor_from_strings.

crates/perry-codegen/src/lower_call/builtin.rs (untouched by #6983): several
built-in constructors lower args[0] then args[1] sequentially via plain
lower_expr. lower_builtin_new is called from inside lower_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 / _end in lower_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, and operand_is_reloadable for operands that read a
registered 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=8 with a shape like
new Function(fresh(0), "return " + churn(N)), and under the evacuating arm
from #6977 (PERRY_GC_INCREMENTAL=0 + the above).

Related: #6969, #6983, #6951, #6972, #6975, #6981, #6968.

Activity

  1. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    The new.rs half is fixed in #7699. builtin.rs is 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-return before the main class loop ever adopts into the enclosing scope, so lower_new_impl's open_rooted_group was open around them and empty — lexical containment is not protection when nothing is adopted, and RootedGroup::release truncates from the group's own lowest slot, so an empty group emits nothing at all.

    • new Readline(…) — output sat in a bare SSA register across options' lowering and across every trailing argument's;
    • new <importedFn>(…) — func_double across every argument, and argument i across 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_args helper so the next edit reads it.

    Two details worth recording:

    lower_js_args_array is confirmed to be no rescue and is untouched: alloca_entry_array emits a bare LLVM alloca with no js_shadow_slot_bind and no shadow-slot reservation (the opt-in is expr/scalar_slot_root.rs's root_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.rs

    lower_builtin_new(ctx, class_name, args) takes no rooting context at all, and is called from lower_new_impl_inner before that function's group is populated — so these are structurally unreachable from a new.rs-level fix. Each needs its own rooting::with_operands_rooted over 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_view
    typed-array views (Int8Array…Float16Array, ≥2 args) 171–195 js_typed_array_view
    DataView 200–228 js_data_view_new
    RegExp 229–252 js_regexp_construct
    Event 374–396 js_event_new
    CustomEvent 397–419 js_custom_event_new
    DOMException 420–441 js_dom_exception_new
    Console (2-arg form) 442–464 js_console_new2
    DatabaseSync 612–630 js_node_sqlite_database_sync_new
    StatementSync 631–649 js_node_sqlite_statement_sync_new
    Session 650–668 js_node_sqlite_session_new
    CronJob 739–768 js_cron_job_new
    AsyncResource 803–821 js_async_resource_new
    SuppressedError 848–870 js_suppressed_error_new
    Response (init fields) 914–1029 js_response_new
    Blob 1038–1064 js_blob_new
    File 1066–1106 js_file_new
    Headers (per-key loop, accumulator + pair) 1108–1137 js_headers_set
    Request (init fields) 1147–1276 js_request_new / _from_init
    ReadableStream 1282–1337 js_readable_stream_new_with_strategy_and_source_type
    WritableStream 1339–1412 js_writable_stream_new_with_sink_type
    TransformStream 1414–1477 js_transform_stream_new
    TextDecoderStream 1489–1509 js_stream_web_text_decoder_stream_new

    Ruled 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 with let _ = lower_expr(...), so nothing is retained across the loop to go stale. Same for new.rs's own Crypto | CryptoKey | SubtleCrypto arm 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.

    CronJob is the one that needs care rather than a mechanical wrap: its first operand is a raw i64 from get_raw_string_ptr (which is lower_expr plus a real js_get_string_pointer_unified call), so it is #7280 taxonomy (a) and wants the derivation moved below the later operands, not a slot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions