Skip to content

fix: popSafeIndexScope leaking entries across loop bodies in self-hosted build#510

Closed
cs01 wants to merge 1 commit intomainfrom
fix/safe-index-scope-pop
Closed

fix: popSafeIndexScope leaking entries across loop bodies in self-hosted build#510
cs01 wants to merge 1 commit intomainfrom
fix/safe-index-scope-pop

Conversation

@cs01
Copy link
Copy Markdown
Owner

@cs01 cs01 commented Apr 14, 2026

Main-branch regression from #506

Main has been red since #506 (bounds-check elimination) merged at 22:40 UTC. Every subsequent merge (#507, #508) has inherited the same failure:

FAIL semantic/binary-type-in-function [stage1]:
  Expected compilation to fail for tests/fixtures/semantic/binary-type-in-function.ts,
  but it succeeded

The test fixture is a one-liner:

function foo(): number {
  return "hello" - 5;
}

Stage0 (node-driven chad) correctly catches this as cannot use '-' between 'string' and 'number'. Stage1 (chad compiled by chad) silently accepts it and goes to linking. The asymmetry meant the self-hosted build of chad had a semantic-analysis hole specifically for binary-op type errors inside function bodies.

Root cause

popSafeIndexScope in src/codegen/llvm-generator.ts used arr.length = N array truncation to pop safe-index entries added within a loop scope:

popSafeIndexScope(): void {
  if (this.safeIndexFrames.length === 0) return;
  const frameStart = this.safeIndexFrames[this.safeIndexFrames.length - 1];
  this.safeIndexFrames.pop();
  this.safeIndexVars.length = frameStart;    // ← silently a no-op in stage1
  this.safeIndexArrays.length = frameStart;  // ← silently a no-op in stage1
}

The self-hosted native compiler implements arr.length = 0 (full clear) but not arr.length = N (non-zero truncation) — the assignment is parsed and type-checked but codegen emits nothing for it. So when chad compiles itself:

  1. Loop A for (let i = 0; i < arr.length; i++) enters, pushes safe-index (i, arr), scope records frameStart=0.
  2. Loop A exits, popSafeIndexScope runs, the frame stack pops, BUT safeIndexVars.length = 0 fails to actually shrink the array. The (i, arr) entry stays forever.
  3. Any subsequent arr[i] access anywhere in the module with matching short names (i into arr, stmts into stmts, etc. — really common in compiler code) now triggers the "safe index" path and emits a bounds-check-free GEP.
  4. In checkBinaryTypesDeep's binWalkStmts/binWalkStmt/checkBinExpr chain, one of these over-eager eliminations corrupts control flow enough that the binary-op type check is effectively skipped on function-body traversal. The specific miscompilation is subtle (it's not a crash — the check is silently unreachable), but the end result is that "hello" - 5 inside a function body slips through.

checkMissingReturns and checkArgumentCounts are NOT affected because their walking code uses different variable names and doesn't hit the leaked entries.

This is a double-bug: (1) the chad native compiler silently drops non-zero length assignments instead of erroring, and (2) #506 happened to be the first caller that relied on non-zero length assignment for correctness. Every other arr.length = N in chad's own codebase uses = 0 (full clear, which DOES work), so nothing else broke.

Fix

Replace the length-assignment with an explicit pop loop:

popSafeIndexScope(): void {
  if (this.safeIndexFrames.length === 0) return;
  const frameStart = this.safeIndexFrames[this.safeIndexFrames.length - 1];
  this.safeIndexFrames.pop();
  while (this.safeIndexVars.length > frameStart) {
    this.safeIndexVars.pop();
    this.safeIndexArrays.pop();
  }
}

Array.prototype.pop() is implemented correctly by chad-native (every other codegen path uses it), so the pop loop works in both stage0 and stage1.

Verification

Before fix: stage1 chad silently accepts "hello" - 5 inside a function body.
After fix: stage1 chad correctly emits cannot use '-' between 'string' and 'number' and exits 1.

$ bash scripts/verify.sh --quick
==> Building TypeScript compiler  ✓ tsc
==> Stage 0: Building native compiler  ✓ built .build/chad  ✓ stage 0 smoke test
==> Starting tests (background)
==> Self-hosting: Stage 1  ✓ built stage1  ✓ stage 1 smoke test
==> Waiting for tests  ✓ all tests passed
Verification PASSED

All 777/777 tests pass, stage1 self-hosting smoke test passes, stage1 chad catches the previously-missed type error.

Diff

One function in src/codegen/llvm-generator.ts, 14 insertions / 3 deletions. No new dependencies, no API changes, no test fixture changes. Pure bug fix.

Follow-up: the native compiler should error on non-zero length assignment

Shipping the fix first because main is red, but the deeper issue is that chad-native silently drops arr.length = nonZero instead of erroring at compile time. Either:

  • implement it properly (pop-until-target-length), OR
  • emit a compile error pointing users at arr.pop() or explicit slicing

Either fix would have caught #506 before it landed. I'd vote for the compile error — "unsupported: use .pop() or .splice()" — since chad has a generally honest "if we don't implement it, say so" posture.

Tracking that as a separate followup.

@github-actions
Copy link
Copy Markdown
Contributor

Benchmark Results (Linux x86-64)

Benchmark C ChadScript Go Node Place
Binary Trees 1.365s 1.254s 2.720s 1.198s 🥈
Cold Start 1.3ms 0.8ms 1.3ms 27.2ms 🥇
Fibonacci 0.814s 0.762s 1.559s 3.188s 🥇
File I/O 0.122s 0.093s 0.089s 0.207s 🥈
JSON Parse/Stringify 0.004s 0.005s 0.017s 0.016s 🥈
Matrix Multiply 0.460s 1.008s 0.640s 0.356s #4
Monte Carlo Pi 0.389s 0.410s 0.404s 2.248s 🥉
N-Body Simulation 1.673s 2.120s 2.199s 2.387s 🥈
Quicksort 0.215s 0.246s 0.213s 0.261s 🥉
SQLite 0.347s 0.370s 0.427s 🥈
Sieve of Eratosthenes 0.015s 0.028s 0.020s 0.039s 🥉
String Manipulation 0.010s 0.018s 0.016s 0.037s 🥉

CLI Tool Benchmarks

Benchmark ChadScript grep node xxd Place
Hex Dump 0.430s 1.006s 0.133s 🥈
Recursive Grep 0.020s 0.011s 0.099s 🥈

@cs01 cs01 closed this Apr 14, 2026
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