Skip to content

A using in a switch case is disposed where the switch ends; break in an if in a case compiles - #592

Merged
ASDAlexander77 merged 1 commit into
mainfrom
fix-switch-case-using
Oct 17, 2026
Merged

ASDAlexander77 merged 1 commit into
mainfrom
fix-switch-case-using

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Builds on the scope-exit walk from #591.

Problem

switch (i) { case 0: { using c = new D("case"); } } (or case 0: using c = ...; break;) crashed MLIRGen with 0xC0000005. The cases were generated with a copy of the enclosing context that shared its usingVars/ownedVars, while their names lived only in the switch's symbol table scope, so the dispose at the end of the enclosing scope looked c up and didn't find it.

While testing, a second crash turned up: a break inside an if in a case (case 0: if (x) break;) had no jump target in SwitchOpLowering, so lowering it crashed.

Fix

  • The switch has its own scope (GenContext::isSwitchScope, its own lists, parentBlockContext). It is exited at the start of the merge block, which every normal way out reaches: a break at the end of a case, the last case falling off its end, and no case matching. A hoisted using from a case that didn't run has a dispose guard that is still off. A hoisted owned local there is still null.
  • break vs continue: new DisposeDepth::BreakScope. An unlabelled break stops at the switch scope, while a continue passes through it to the loop body.
  • SwitchOpLowering gives an unlabelled BreakOp nested in a case a jump target, the continuation. That jump skips the merge block, whose cleanup the break already did on its way out.
  • A case's lone { } block that declares a using is no longer inlined, so it is disposed before a case falling through runs the next one, as in JS.
  • A switch whose cases declare a using (or hold a typed catch, on Itanium) is wrapped in a catch-less TryOp, the same way mlirGenBlockWithUnwindCleanup wraps a block. The switch's storage is hoisted in front of that TryOp, so an exception after a case's using is stored runs the switch's cleanup. Without this the verifier flagged the unwind leak inside a using loop body.
  • The safe-cast lambda for switch (o.prop) cleared insertIntoParentScope on the shared switch context instead of on its own copy. Every later case then declared its variables in a per-case scope, and they were gone by the merge.
  • Ownership verifier: a hoisted owned slot (consumed VariableOp with no initializer) is now treated as acquired at its stores, not at the hoisted declaration, where the slot is null (VariableOpLowering). This also removes a false report for a break that comes before a declaration in a try body.

Verified

  • Dispose order matches under gc, rc and none for: a case with using + break, falling through, no case matched, braced cases falling through, break from an if, a switch in a loop whose body has a using (continue / break / default), and a switch on a property.
  • --emit=mlir-affine --verify-ownership is quiet under gc/rc/own.
  • New test 00switch_case_scope_exit.ts: compile + JIT, and added to TSLANG_CORPUS.
  • Full ctest -C Release -j 8 --timeout 300: 100% passed, 4118/4118 (Windows, VS 2026 Release).

Not fixed here (pre-existing)

  • Every scope disposes its usings in declaration order, but JS disposes them in reverse. The test avoids two usings in one scope.
  • let s = ...; { using a = new D(); return s; }: the verifier reports s as unreleased on the unwind path, with a plain block as well as with a switch.

🤖 Generated with Claude Code

… in an `if` in a case compiles

The cases of a switch were generated with a copy of the enclosing context that shared its
dispose and release lists, while their names lived only in the switch's symbol table scope.
Any `using` in a case crashed the compiler at the end of the enclosing scope, which looked
the name up and did not find it.

The switch now has a scope of its own (GenContext::isSwitchScope, its own lists), exited at
the start of the merge block: a `break` at the end of a case, the last case falling off, and
no case matching all come through there. A `using` in a case the path did not run has a
dispose guard that is still off, and an owned local there is still null.

- `break` and `continue` walk outwards differently now (DisposeDepth::BreakScope): an
  unlabelled `break` stops at the switch; a `continue` passes through it to the loop.
- SwitchOpLowering gives a `break` written inside a case (in an `if`, say) a jump target.
  It had none, and lowering it crashed.
- A case's lone `{ }` block that declares a `using` keeps its scope, so it is disposed
  before a case falling through runs the next one.
- A switch whose cases declare a `using` (or hold a typed catch, on Itanium) is wrapped in a
  catch-less TryOp as a block is, so an exception after a case's `using` runs its cleanup.
- The safe-cast narrowing of a switch on a property cleared insertIntoParentScope on the
  switch's context instead of its own copy, so later cases' variables were gone by the merge.
- The ownership verifier takes a hoisted owned slot (no initializer) as acquired at its
  stores rather than at the hoisted declaration, where the slot is still null. This also
  stops it reporting a `break` ahead of a declaration in a try body.

Test: 00switch_case_scope_exit.ts (compile + JIT, and in the rc/none corpus).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit a158c18 into main Oct 17, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the fix-switch-case-using branch October 17, 2026 16:32
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