Skip to content

default-derived ctor forwarding part 2: HIR fixed-arity synthesis truncates args (rest/extends-expr parents); runtime construct dispatchers need sig-cap counts + rest packing #5957

Closed

Description

@proggeramlug

Follow-up to #806 / the #5917 behavioral-matrix fixture test_issue_806_default_derived_constructor_forwarding. The static no-own-ctor walk mis-split is fixed (PR incoming), which covers ctor-free subclasses of capturing ancestors reached via extends_name — but two sibling shapes remain broken, with the mechanisms fully traced:

Shape 1: fixed-arity default-ctor synthesis truncates/drops user args

synthesize_class_captures (crates/perry-hir/src/lower_decl/class_captures.rs, the "spec default ctor" arm ~L414-483) does NOT synthesize constructor(...args) { super(...args) }. It walks the nearest pending-ancestor ctor's user arity and mints that many FIXED params (__perry_dflt_arg_i, is_rest: false), forwarding them positionally. Consequences, both reproduced:

  • Rest-param ancestor counts as arity 1 → class Drain extends Sink {} where Sink { constructor(...parts) } captures an outer local: new Drain("a","b","c") forwards only "a" (--trace llvm shows the new-site calling Drain_constructor(this, %arg, cap) with the first arg raw). Output P[a] instead of P[a,b,c].
  • extends-EXPR parent (mixin: class W extends WithSuffix(Logged) {}): the arity walk keys on extends_name — None here — so arity resolves to 0 and the synthesized ctor forwards NOTHING. Also applies to ancestors not in pending_classes (module-level parents of a function-nested capturing subclass) per the "no-arg baseline" fallback.

Fix: synthesize the real spec ctor — one is_rest: true param + Expr::SuperCallSpread([Spread(LocalGet(pid))]). The arity walk disappears. This routes the forwarding through the spread-super machinery, which is where part 2 below matters.

Shape 2: runtime construct dispatchers guess the user/cap split and can't pack rest

crates/perry-runtime/src/object/class_constructors.rs has four ctor dispatchers. Two (replay_registered_class_constructor, the heap-class-object dispatcher) already pack rest via lookup_closure_rest (#wall3). The two SUPER dispatchers do neither:

  • js_super_construct_apply (~L347) and run_class_constructor_on_this_flat (~L649) bind user_params = total_params − caps.len() raw args, no rest packing — a rest-param parent binds its rest slot to the FIRST raw arg (which super(...args) then spreads as a string/array-like: the observed P[a]).
  • All four compute the user/cap split as total_params − snapshot_len (or max(ctor_caps, snapshot)), which assumes cap params ride in the ctor signature. Dynamic-parent classes compile CAPLESS ctor signatures ((this, seed) — the parent is fetched via the per-cid registry) while a decl-site snapshot IS registered (extends-expr refs join the capture union), so user_params = 1 − 1 = 0 and the user arg is dropped: new WrappedLogged("alpha") → js_fetch_or_value_super → class-object construct dispatch → seed = undefined → the fixture's super-args.log: undefined:wrapped.

Fix: register the SIGNATURE cap-param count alongside the ctor — extend js_register_class_constructor(cid, fn_ptr, param_count) with a 4th arg (n_sig_caps, counted at the ctor_triples emit in codegen/string_pool.rs ~L655 from __perry_cap_* params; 0 for arity-override/codegen-synthesized ctors, which are capture-free by the synthesized-ctor invariant). All four dispatchers then compute user_params = total − n_sig_caps and fill exactly n_sig_caps trailing slots from the snapshot; the two super dispatchers additionally adopt the #wall3 rest packing (lookup_closure_rest on the resolved fn ptr).

Acceptance tests (in-tree, #[ignore]d)

crates/perry/tests/issue_806_default_derived_ctor_forwarding.rs:

  • default_derived_forwards_through_capturing_mixin (shape 2 + shape 1's extends-expr variant)
  • walk_into_rest_param_capturing_ctor (shape 1 rest + shape 2 rest packing)

Un-ignore both when this lands. Note the template/inline-init cid sharing (both __anon_class_* copies register ctor+snapshot under one cid, last-wins) is by design (replay) and becomes harmless once the split stops depending on snapshot length.

Activity

  1. added 2 commits that reference this issue on Jul 4, 2026
  2. added a commit that references this issue on Sep 19, 2026
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

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions