Skip to content

compile: fence the LOADNIL merge peephole at jump targets - #39

Open
wolfy-j wants to merge 1 commit into
mainfrom
fix/loadnil-merge-jump-fence-main
Open

compile: fence the LOADNIL merge peephole at jump targets#39
wolfy-j wants to merge 1 commit into
mainfrom
fix/loadnil-merge-jump-fence-main

Conversation

@wolfy-j

@wolfy-j wolfy-j commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Defect

local v = t[key] or nil (any … or nil local initializer) inside a generic for body panics the VM with a Go nil pointer dereference whenever the left operand is truthy. or false, numeric for, while, and the same statement outside a loop are unaffected.

Root cause

codeStore.AddLoadNil merges a new LOADNIL into the previous instruction whenever the register spans are adjacent, with no jump-target fence. The nil arm of or nil ends in a LOADNIL that the short-circuit path jumps over; folding the next statement's nil-register init into it moves that init onto the skipped path, so the comparison reads an uninitialized stack slot:

[015] JMP      0, 1          ; truthy lhs skips the nil arm
[016] LOADNIL  7, 8          ; merged: R8 only nil'd on the falsy path
[017] EQ       1, 7, 8       ; reads R8 -> empty LValue on the truthy path

PUC Lua guards exactly this with fs->lasttarget in luaK_nil.

Fix

codeStore records the highest label pc (MarkLabelPc, fed by funcContext.SetLabelPc); AddLoadNil merges only when the previous instruction lies strictly after every label position. Adjacent nil locals with no label between them still fold.

Tests

TestLoadNilMergeStopsAtJumpTarget covers ipairs/pairs hit+miss, scalar or nil, guarded chains, and the still-folding case. Full package suite passes.

https://claude.ai/code/session_01CGwbFUspyUu7dbwdWsXL7J

The AddLoadNil peephole folds a new LOADNIL into the previous instruction
whenever the register spans are adjacent. When the previous LOADNIL ends a
conditionally-skipped path — the nil arm of an 'or nil' expression — the fold
moves the next statement's nil-register init onto that skippable path. The
short-circuit path then reads an uninitialized stack slot and the VM
dereferences an empty LValue: 'local v = t[key] or nil' inside a generic for
loop panics with a Go nil pointer whenever t[key] is truthy.

codeStore now records the highest label pc (MarkLabelPc, fed by SetLabelPc)
and AddLoadNil merges only when the previous instruction lies strictly after
every label position, mirroring PUC Lua's fs->lasttarget fence in luaK_nil.

Claude-Session: https://claude.ai/code/session_0134uPSdqJwq5Sp8qyUseaq5
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