RFC-0017 — Init Decoupling

  • Status: Accepted, shipped in 4.2.0
  • Author: Mark Truluck
  • Companion: RFC-0015 (factory-only construction) — D7 @@!Foo() no-init sigil

Summary

Decouple the user-visible $> start-event handler from the target language constructor. Every backend now emits three artifacts where it used to emit one:

  1. Bare constructor — framework setup only. State stack, compartment placeholder, domain field defaults. No user $> body, no enter cascade, no transition loop. Per-backend spelling: Counter() / Counter::new() / Counter_new() / start_link/0 / etc.
  2. __frame_init(args) method — runs the user $> body, fires the enter cascade, drains the transition loop. Takes the original ctor params. Per-backend spelling: _frame_init (Python/Dart/JS/TS/Ruby/Lua/PHP/GDScript), __frame_init (Rust/Java/Kotlin/Swift/C#/C/C++), frame_init/(N+1) (Erlang).
  3. __create(args) factory — calls the bare ctor then __frame_init(args) and returns the instance. Per-backend spelling: _create (dynamic backends), __create (typed backends), CreateCounter (Go), Counter_create (C), create/N (Erlang).

What @@Counter(7) (construct) and @@!Counter() (no-initialization) turn into in the generated target code changes accordingly:

  • @@Counter(7) → a call to the new __create factory — Counter::__create(7), or the per-backend equivalent (see Generated calls, per backend below).
  • @@!Counter() → a plain call to the bare constructor.

Why

Before this RFC, the user’s $> body ran inside the language constructor. This forced four backends (Java, Kotlin, Swift, Erlang) to carry a __skipInitialEnter static flag — a runtime sentinel toggled around constructor calls to suppress $> for @@!Foo(). The flag-based approach worked but was a symptom of the underlying coupling, not the fix.

D7’s @@!Foo() originally synthesized per-backend “no-init” helpers (__no_init, _no_init, Foo_alloc, start_link_no_init/0, etc.) that bypassed the constructor via various tricks: __new__ in Python, Object.create(prototype) in JS, Unsafe.allocateInstance candidates for the JVM, marker-class patterns for Swift, skip-flag toggles. Eight different mechanisms for one semantic: “give me an instance without running init.”

Decoupling collapses all of that to a uniform shape: bare construction is the no-init form, by construction. The user’s $> runs only when __frame_init is called explicitly. No skip flags, no synthesized bypass helpers, no Unsafe, no marker classes.

The decouple also resolves a previously-noted Erlang persist wart (restore_state/1 returns a fresh Pid rather than mutating an existing one): the bare start_link/0 now produces a Pid that’s safe to feed into sys:replace_state, so the @@! + restore_state flow is structurally coherent.

What changed in the codegen

Per-backend constructor split

The Constructor IR node in each backend’s emit arm now classifies body statements into three buckets:

  • Cascade triggers (__fire_enter_cascade / __process_transition_loop text match) → emitted in __frame_init only.
  • Statements that mention a constructor param by name → emitted in __frame_init (with the user args bound); a stripped form (param-list args replaced with empty literal) is emitted in the bare ctor so the placeholder compartment is constructed with empty enter_args / state_args. restore_state overwrites these when the @@!-then-restore_state pattern is used; __frame_init overwrites them when the factory path is used.
  • Everything else (domain field defaults, state stack init, __next_compartment = None) → emitted in the bare ctor.

Each backend defines its own *_strip_param_lists helper that recognizes the backend’s collection-literal idiom and substitutes an empty form when every argument is a known param name.

Per-backend factory spelling

The __create factory is emitted by the Constructor arm directly for most backends. Kotlin emits it as a @JvmStatic companion-object member via a NativeBlock injected by generate_kotlin_machinery (companion-object grouping). Swift emits it as a static func. Erlang emits it as a module function alongside start_link/0 and frame_init/(N+1). C emits it as a free function alongside Counter_new and Counter_frame_init.

Removed infrastructure

The following are gone from every backend:

  • __skipInitialEnter static flag (Java, Kotlin, Swift, Dart, GDScript, C++)
  • __no_init / _no_init / Foo_alloc synthesized helpers (all 8 D7 backends that had them)
  • kotlin_type_default_expr, swift_type_default_expr helper functions
  • frame_skip_enter__ record field for Erlang kept — still needed because gen_statem’s state_enter callback mode fires state_enter automatically on init even with [] args. The skip flag now defaults to true on every bare start_link/0 call; the frame_init cast handler clears it and re-fires state_enter via repeat_state.

The legacy restore_state codegen branches that emitted __skipInitialEnter = true/false; around value-init of a fresh instance are removed (5 sites in interface_gen.rs). They were guarded by !uses_new_contract which is unreachable since E814 hard-cut the legacy persist contract.

Generated calls, per backend

When you write @@Counter(7) (construct) or @@!Counter() (no-initialization) in Frame source, framec emits this in the generated target code:

Backend @@Counter(7) @@!Counter()
Python Counter._create(7) Counter()
Rust Counter::__create(7) Counter::new()
Kotlin Counter.__create(7) Counter()
Java Counter.__create(7) new Counter()
Swift Counter.__create(7) Counter()
C# Counter.__create(7) new Counter()
Go CreateCounter(7) NewCounter()
C Counter_create(7) Counter_new()
C++ Counter::__create(7) Counter()
Dart Counter._create(7) Counter()
GDScript Counter._create(7) Counter.new()
JavaScript Counter._create(7) new Counter()
TypeScript Counter._create(7) new Counter()
Ruby Counter._create(7) Counter.new
Lua Counter._create(7) Counter.new()
PHP Counter::_create(7) new Counter()
Erlang counter:create(7) element(2, counter:start_link())

Naming convention: single-underscore (_create / _frame_init) on backends with name-mangling (Python) or library-private convention (Dart, JS/TS, Ruby, PHP, GDScript, Lua); double-underscore (__create / __frame_init) on backends where Frame’s existing __compartment / __fire_enter_cascade infrastructure already uses double-underscore.

D4 invariant

The D4 invariant from RFC-0015 — $> runs exactly once on the factory path and never on the @@! path — is preserved. Verified end-to-end on Python, Rust, Kotlin, Java, Swift, Dart, Go, Erlang with the standard Counter fixture ($>(seed: int) increments fire_count; assertion: value=9, fire_count=1 after @@Counter(7) → bump → bump → save → @@!Counter() → restore_state).

Migration

For Frame source: zero changes. @@Counter(7) and @@!Counter() are unchanged.

For users who called the generated constructor directly (outside the @@ machinery) with arguments — e.g., Counter(7) in Python, new Counter(7) in Java, Counter::new(7) in Rust — that path is now a compile error or ill-formed call. Migrate to the explicit factory:

  • Python: Counter(7)Counter._create(7)
  • Java: new Counter(7)Counter.__create(7)
  • Rust: Counter::new(7)Counter::__create(7)
  • Erlang: counter:start_link(7)counter:create(7)
  • etc. per the table above.

Bare calls like Counter() / new Counter() / Counter::new() still work but now produce a no-init instance. Previously they would have run $>; if your code depended on that, use the factory form.

Companion change

@@!Foo()’s meaning is unchanged — only the per-backend mechanism is simpler. RFC-0015 describes the design; it points here for what @@!Foo() and the factory call become in each target language.

Rejected alternatives

  • Unsafe.allocateInstance for JVM: bypasses constructors entirely. Considered for @@! Java/Kotlin, but the JVM internal-API tax (--add-opens on JDK 9+, removal threats) plus the requirement to manually initialize framework fields made it strictly worse than the bare-constructor approach.
  • Marker-class secondary constructor pattern for JVM/Swift: cleaner than Unsafe but restructures the class shape and forces the user’s natural constructor signature into a secondary position. Bare-ctor split is more uniform across the 17 backends.
  • Keep __skipInitialEnter flag, just split __frame_init out: would have worked but left the runtime flag as dead state on 4 backends. The decouple removes it cleanly.

Files changed

  • framec/src/frame_c/compiler/codegen/backends/{python,rust,java,kotlin,swift,csharp,go,c,cpp,dart,gdscript,javascript,typescript,ruby,lua,php}.rs — 16 Constructor arm rewrites.
  • framec/src/frame_c/compiler/codegen/erlang_system.rs — exports, init clause, frame_init handler emission.
  • framec/src/frame_c/compiler/codegen/system_codegen.rs — removed kotlin_type_default_expr / swift_type_default_expr, removed init_event_code __skipInitialEnter wraps.
  • framec/src/frame_c/compiler/codegen/interface_gen.rs — removed 5 legacy restore_state __skipInitialEnter branches.
  • framec/src/frame_c/compiler/codegen/frame_expansion.rsgenerate_no_initialization dispatch table updated.
  • framec/src/frame_c/compiler/assembler/mod.rsgenerate_constructor factory-spelling table updated.