Skip to content

Lift a spilled base/this constructor argument into the chain initializer#612

Merged
richlander merged 1 commit into
mainfrom
feature/ctor-initializers
Jun 18, 2026
Merged

Lift a spilled base/this constructor argument into the chain initializer#612
richlander merged 1 commit into
mainfrom
feature/ctor-initializers

Conversation

@richlander

Copy link
Copy Markdown
Owner

What

A base(...)/this(...) argument that carries control flow — the ubiquitous base(message ?? "default") / base(code > 0 ? "x" : null) exception shape — gets spilled to a temporary. The printer's chain lift only fires when the call is the body's first statement, so the spilled-argument call stayed an invalid base(temp); body statement (CS0175) that dropped its argument on recompile (ldarg ldarg callldarg call).

The receiver of a constructor's own base/this call is the object under construction — an immutable reference with no observable evaluation effect — so moving an argument's computation past it never reorders anything. The general inliner declines this (it can't yet prove the this receiver isn't a mutable byref struct), exactly as ConstructorChainPass already noted for the receiver itself.

How

  • ConstructorChainArgumentPass (new, runs after structuring folds the ??/?:): inlines each single-use argument spill into the chain call, so the call lands as the body's first statement and the existing lift renders : base(args) / : this(args).
  • Harness (CompileBack): the oracle ignored the lifted initializer and emitted class skeletons with no base type, so lifted chains couldn't recompile. It now renders the initializer and emits a : Base clause for non-generic in-assembly class bases.

Soundness

  • The pass reuses single-store / single-load / address-not-taken safety and only inlines temps in the run immediately preceding the chain call, where the sole prior evaluation is the effect-free under-construction this.
  • The base clause is conservative (TypeDefinition, non-generic, non-object only); generic / out-of-assembly bases stay unspelled, costing at most a base-call diff, never a miscompile. On a broad System.Linq corpus the base clause left recompile-fail unchanged (424 → 424).

Numbers

Compile-back fixture corpus:

metric before after
exact opcode match 147 151 (+4)
opcode diff (docket) 14 13
recompile fail 528 525 (no new failures)

CtorChainSamples base(message), base(message ?? "default"), and this(value.ToString()) are now opcode-exact; the remaining ctor#2 diff is the orthogonal ternary-sense equivalence. Decompiler tests 432 pass; main suite 1140 pass (9 skipped).

Found via the --compile-back oracle (#604).

A base(...)/this(...) argument that carries control flow — the ubiquitous
base(message ?? "default") / base(code > 0 ? "x" : null) exception shape —
gets spilled to a temporary the general inliner declines to fold: the chain
receiver is evaluated first, and TypeRef cannot yet prove it is a class rather
than a mutable byref struct, so it reads as an impure leaf the stored value may
not be reordered past. The printer's lift only fires when the call is the body's
first statement, so the spilled-argument call stayed an invalid `base(temp);`
body statement (CS0175) that dropped its argument on recompile.

But the receiver of a constructor's own base/this call is the object under
construction — an immutable reference with no observable evaluation effect — so
moving an argument's computation past it never reorders anything. New
ConstructorChainArgumentPass (after structuring folds the ??/?:) inlines each
single-use argument spill into the chain call, so the call lands first and the
existing lift renders it as a `: base(args)` / `: this(args)` initializer.

The compile-back harness ignored the lifted initializer and emitted class
skeletons with no base type, so lifted chains could not recompile. It now
renders the initializer and emits a `: Base` clause for non-generic in-assembly
class bases (object/value-type/generic/out-of-assembly bases stay unspelled,
costing at most a base-call diff, never a miscompile).

Soundness: the pass reuses single-store/single-load/address-not-taken safety and
only inlines temps stored in the run immediately preceding the chain call, where
the sole prior evaluation is the effect-free under-construction `this`. The base
clause is conservative (TypeDefinition, non-generic, non-object only). On a
broad System.Linq corpus the base clause left recompile-fail unchanged (424).

Compile-back fixture corpus: exact 147 -> 151 (+4), docket 14 -> 13,
recompile-fail 528 -> 525 (no new failures). CtorChainSamples base(message),
base(message ?? "default"), and this(value.ToString()) now opcode-exact; the
remaining ctor#2 diff is the orthogonal ternary-sense equivalence. Decompiler
tests 432 pass; main suite 1140 pass (9 skipped). Found via #604.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@richlander richlander merged commit feb5ff7 into main Jun 18, 2026
10 checks passed
@richlander richlander deleted the feature/ctor-initializers branch June 18, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant