Skip to content

fix(compile): #1237 materialize inner function declarations inside class methods#1239

Merged
nickna merged 1 commit into
mainfrom
worktree-bug-1237
Jul 4, 2026
Merged

fix(compile): #1237 materialize inner function declarations inside class methods#1239
nickna merged 1 commit into
mainfrom
worktree-bug-1237

Conversation

@nickna

@nickna nickna commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #1237. A function declaration inside a class method body was unreferenceable in compiled mode — every reference threw ReferenceError: Undefined variable at runtime, while the interpreter ran the same program correctly. This affected non-capturing, capturing, top-level-in-method, and block-nested inner functions, on instance, static, and private methods.

Root cause

The three sync class-method emitters — EmitMethod and EmitPrivateMethodBody (ILCompiler.Classes.Methods.cs) and EmitStaticMethodBody (ILCompiler.Classes.Static.cs) — emit the body via a bare foreach (var stmt in method.Body) EmitStatement(stmt) and never wired the inner-function materializer that the plain-function (ILCompiler.Functions.cs) and arrow paths run. So an inner function in a method was collected (its method and display class were emitted) but never materialized into a binding, and every reference fell through to ThrowUndefinedVariable.

Fix

Adds WireInPlaceInnerFunctions(ctx) (ILCompiler.InnerFunctions.cs) and calls it before the body loop in all three method paths. It wires the #1230 in-place materializer (EmitBlockScopedInnerFunctionDeclaration) onto the method context but deliberately leaves HoistedInnerFunctions empty, so every inner function declaration — top-level-of-body or block-nested — is created at its textual position by the statement emitter's Stmt.Function arm.

Why in-place rather than the function/arrow path's top-of-body two-pass hoist: unlike a plain function or arrow, a method has no function-level display class, so hoisting a capturing inner function to the top of the body would snapshot its captured method-locals before the body assigns them (null capture, e.g. k= where k=K is expected). In-place materialization snapshots after the preceding statements run, so captures are correct — matching how arrows inside methods already capture. In-place is also sufficient: the type checker rejects forward references to a method's inner functions (mutual recursion / call-before-declaration both error), so no valid program needs the two-pass pre-materialization here.

3 files, +38 lines.

Tests

New SharpTS.Tests/SharedTests/InnerFunctionInMethodTests.cs — 11 theories × both execution modes (22 cases): non-capturing, method-local const, parameter (value-type double boxing), numeric local (boxing), self-recursion, two inner functions sharing a capture, module top-level-var capture (entry-point DC), loop-body per-iteration, if-block block-const, static method, private method.

Verification

  • xUnit: 15404 / 0 (incl. the 22 new tests)
  • Test262 (interpreter + compiled): 22 / 0 / 4 skip — baseline-green
  • TypeScript conformance: 31 / 0
  • IL --verify: passes on all repros

Out of scope (pre-existing, verified not regressions)

…ass methods

A `function` declaration inside a class method body was unreferenceable in
compiled mode (`ReferenceError: Undefined variable`), while the interpreter ran
it correctly. The three sync class-method emitters (EmitMethod,
EmitPrivateMethodBody, EmitStaticMethodBody) emitted the body via a bare
`foreach EmitStatement` and never wired the inner-function materializer that the
plain-function and arrow paths run, so an inner function was collected (its
method + display class emitted) but never bound to a name.

Add WireInPlaceInnerFunctions(ctx) and call it before the body loop in all three
method paths. It wires the #1230 in-place materializer but leaves
HoistedInnerFunctions empty, so each inner function materializes at its textual
position rather than being hoisted to the top of the body. Methods have no
function-level display class, so a top-of-body hoist would snapshot captured
method-locals as null before the body assigns them; in-place materialization
captures them correctly. In-place is sufficient because the type checker rejects
forward references to a method's inner functions, so no valid program needs the
two-pass hoist here. Capture semantics then match how arrows inside methods
already behave (snapshot at creation).

Regression tests in InnerFunctionInMethodTests.cs (11 theories x both modes)
cover non-capturing, method-local/param/value-type-boxed captures,
self-recursion, shared captures, top-level-var capture, loop-body per-iteration,
if-block, static, and private methods.
@nickna nickna merged commit c572fdb into main Jul 4, 2026
3 of 4 checks passed
@nickna

nickna commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

Follow-up: the CI failure was a real data race, now fixed (commit 3110b3c)

The build (ubuntu-latest) failure on the first push was not a flaky test and not caused by the inner-function change — it was a genuine pre-existing concurrency bug that this commit fixes.

Failure: ClusterModuleTests.RoundRobin_DistributesConnectionsAcrossWorkers(mode: Compiled) — a 60s TimeoutException (a hang, not an assertion failure), Ubuntu-only, intermittent.

Root cause: unsynchronized check-then-act in SharedListenerRegistry.cs:

if (!_started) { _started = true; _listener.Start(); StartAccepting(); }

Under SCHED_RR, both cluster workers call server.listen(samePort) from their own interpreter threads, and ConcurrentDictionary.GetOrAdd hands both the same SharedTcpListener. When the two AddWorker calls overlap, both pass !_started, both call _listener.Start(), and the second Bind on the socket throws. That exception propagates out of ListenAsClusterWorker before it fires the listening callback / process.send('ready'), so the primary's readyCount never reaches 2, the 4 connections are never made, and the module hangs to the 60s timeout. Multi-core + CI load widens the race window, which is why it was intermittent and Compiled-only.

Reproduced locally on the pre-fix build: Fork_WorkersShareNetPort(mode: Compiled) hung 61s on the first loop iteration, next iteration passed in 7s.

Fix: wrap the start-once transition in a lock (double-checked; publish _started only after Start() succeeds) in both SharedTcpListener and SharedHttpListener, and guard Stop() with the same lock.

Verification: 40/40 iterations of the three port-sharing tests pass post-fix (no hang), full ClusterModule/Net/Http suite 130/0.

This is logically independent of the #1237 inner-function fix (first commit); it's bundled here because it's what was blocking this PR's CI. Happy to split it into its own PR if preferred.

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.

compiled: inner function declarations inside a class method are unreferenceable

1 participant