Summary
pslua aborts during Lua code generation with
Unexpected bound reference: Ref Nothing (Local (Name "b")) 1 in module Data.Array
when compiling Data.Array. The crash is raised by the Lua backend (Lua.UnexpectedRefBound, see lib/Language/PureScript/Backend/Lua.hs:214) and is reported to the user via exe/Main.hs:105. This blocks compiling the purescript-lua-arrays port entirely (spago build succeeds, but pslua --entry Data.Array fails).
This is a pslua code-generator bug, not a fork bug: the offending function is stock upstream purescript-arrays code, and the bad reference is produced inside pslua's own IR optimizer, not present in the CoreFn that purs emits.
Triggering construct
The single declaration Data.Array.foldRecM is sufficient to trigger the crash. Verified by reducing the module's CoreFn to that one declaration: pslua still aborts with the identical error.
foldRecM :: forall m a b. MonadRec m => (b -> a -> m b) -> b -> Array a -> m b
foldRecM f b array = tailRecM2 go b 0
where
go res i
| i >= length array = pure (Done res)
| otherwise = do
res' <- f res (unsafePartial (unsafeIndex array i))
pure (Loop { a: res', b: i + 1 })
The shape that matters:
- A top-level
Abs binder named b (the second parameter of foldRecM).
- A typeclass-dictionary
Let group that purs floats around the lambdas (the MonadRec/Monad/Bind/Applicative accessors — tailRecM2, bind1, pure1, Monad0), which pslua's inlineLocalBindings later inlines because each is used once.
- A single reference to that
b binder, in the body tailRecM2 go b 0.
There is exactly one binder named b and exactly one reference to it in the whole declaration. The record literal { a: res', b: i + 1 } uses b only as a record label, not as a binder, so there is no source-level shadowing of b at all. Therefore the only correct De Bruijn index for that reference is 0.
Root cause
By the time the Lua backend runs, the reference to b carries index 1 instead of 0. The Lua backend (fromIR, Ref case) only accepts local references at index 0 — see Note [Locals are uniquely named after renameShadowedNames] in lib/Language/PureScript/Backend/IR/Optimizer.hs. The renaming pass renameShadowedNames only rewrites a local reference when its scope map holds a rename entry at the matching index (lib/Language/PureScript/Backend/IR/Optimizer.hs:136-142); for b there is only one binder, so the scope has no entry at index 1, the reference is left untouched, and the backend then rejects it.
The index 1 is introduced during IR optimization, not by purs. The likely mechanism is the inlining of the single-use dictionary Let bindings (inlineLocalBindings -> inlineLocalBinding -> substitute, lib/Language/PureScript/Backend/IR/Optimizer.hs:381-396). substitute descends through the enclosing Abs b and, on the way, applies shift 1 b 0 to the replacement / threads the index through binders (lib/Language/PureScript/Backend/IR/Types.hs:668-704). shift increments the index of every Local "b" reference at or above minIndex (lib/Language/PureScript/Backend/IR/Types.hs:752-757). When this bookkeeping runs through the dictionary-Let + nested-Abs structure that purs produces for foldRecM, the one legitimate b reference (which should stay at index 0, bound by the enclosing Abs b) gets bumped to index 1, so it ends up pointing past its only binder. This is the same class of bug as #37 (a sibling/over-eager index leaving a local reference unbound after optimization), reached here through the dictionary-binding inlining path rather than DCE.
I have intentionally not pinned the exact faulty line; the symptom (index inflated from 0 to 1 with only one binder in scope) is reproduced and isolated, and the fix should restore the invariant that renameShadowedNames relies on.
Impact
- Severity: high for the array port.
Data.Array cannot be compiled to Lua at all, which blocks purescript-lua-arrays and any downstream package depending on Data.Array.
foldRecM is ordinary upstream code; any module that combines a constrained polymorphic function (typeclass dictionary args, which purs lowers to floated Let bindings) with a where/do body that references one of the outer parameters can hit the same index-inflation path. So this is not specific to Data.Array.
Reproduction
Toolchain: purs 0.15.15, pslua at Unisay/purescript-lua@6fdc70c (current master).
In a checkout of purescript-lua-arrays (the master branch) after spago build -u '-g corefn':
pslua --foreign-path . --ps-output output --entry Data.Array --lua-output-file dist/Data.Array.lua
# => Unexpected bound reference: Ref Nothing (Local (Name "b")) 1 in module Data.Array
Minimal isolation (reduce output/Data.Array/corefn.json to only the foldRecM declaration, keep everything else identical) still reproduces the exact same error, confirming foldRecM is the sole trigger.
How it was found
Triaging a failing nix develop -c ./scripts/build in the purescript-lua-arrays fork. Bisected at the CoreFn declaration level: of the three top-level declarations in Data.Array that reference a local named b (foldRecM, foldM, filterA), removing only foldRecM from the CoreFn makes the error disappear; keeping foldRecM as the lone declaration reproduces it.
Summary
psluaaborts during Lua code generation withwhen compiling
Data.Array. The crash is raised by the Lua backend (Lua.UnexpectedRefBound, seelib/Language/PureScript/Backend/Lua.hs:214) and is reported to the user viaexe/Main.hs:105. This blocks compiling thepurescript-lua-arraysport entirely (spago buildsucceeds, butpslua --entry Data.Arrayfails).This is a
psluacode-generator bug, not a fork bug: the offending function is stock upstreampurescript-arrayscode, and the bad reference is produced insidepslua's own IR optimizer, not present in the CoreFn thatpursemits.Triggering construct
The single declaration
Data.Array.foldRecMis sufficient to trigger the crash. Verified by reducing the module's CoreFn to that one declaration:psluastill aborts with the identical error.The shape that matters:
Absbinder namedb(the second parameter offoldRecM).Letgroup thatpursfloats around the lambdas (theMonadRec/Monad/Bind/Applicativeaccessors —tailRecM2,bind1,pure1,Monad0), whichpslua'sinlineLocalBindingslater inlines because each is used once.bbinder, in the bodytailRecM2 go b 0.There is exactly one binder named
band exactly one reference to it in the whole declaration. The record literal{ a: res', b: i + 1 }usesbonly as a record label, not as a binder, so there is no source-level shadowing ofbat all. Therefore the only correct De Bruijn index for that reference is0.Root cause
By the time the Lua backend runs, the reference to
bcarries index1instead of0. The Lua backend (fromIR,Refcase) only accepts local references at index0— seeNote [Locals are uniquely named after renameShadowedNames]inlib/Language/PureScript/Backend/IR/Optimizer.hs. The renaming passrenameShadowedNamesonly rewrites a local reference when its scope map holds a rename entry at the matching index (lib/Language/PureScript/Backend/IR/Optimizer.hs:136-142); forbthere is only one binder, so the scope has no entry at index1, the reference is left untouched, and the backend then rejects it.The index
1is introduced during IR optimization, not bypurs. The likely mechanism is the inlining of the single-use dictionaryLetbindings (inlineLocalBindings->inlineLocalBinding->substitute,lib/Language/PureScript/Backend/IR/Optimizer.hs:381-396).substitutedescends through the enclosingAbs band, on the way, appliesshift 1 b 0to the replacement / threads the index through binders (lib/Language/PureScript/Backend/IR/Types.hs:668-704).shiftincrements the index of everyLocal "b"reference at or aboveminIndex(lib/Language/PureScript/Backend/IR/Types.hs:752-757). When this bookkeeping runs through the dictionary-Let+ nested-Absstructure thatpursproduces forfoldRecM, the one legitimatebreference (which should stay at index0, bound by the enclosingAbs b) gets bumped to index1, so it ends up pointing past its only binder. This is the same class of bug as #37 (a sibling/over-eager index leaving a local reference unbound after optimization), reached here through the dictionary-binding inlining path rather than DCE.I have intentionally not pinned the exact faulty line; the symptom (index inflated from 0 to 1 with only one binder in scope) is reproduced and isolated, and the fix should restore the invariant that
renameShadowedNamesrelies on.Impact
Data.Arraycannot be compiled to Lua at all, which blockspurescript-lua-arraysand any downstream package depending onData.Array.foldRecMis ordinary upstream code; any module that combines a constrained polymorphic function (typeclass dictionary args, whichpurslowers to floatedLetbindings) with awhere/dobody that references one of the outer parameters can hit the same index-inflation path. So this is not specific toData.Array.Reproduction
Toolchain:
purs0.15.15,psluaatUnisay/purescript-lua@6fdc70c(currentmaster).In a checkout of
purescript-lua-arrays(themasterbranch) afterspago build -u '-g corefn':Minimal isolation (reduce
output/Data.Array/corefn.jsonto only thefoldRecMdeclaration, keep everything else identical) still reproduces the exact same error, confirmingfoldRecMis the sole trigger.How it was found
Triaging a failing
nix develop -c ./scripts/buildin thepurescript-lua-arraysfork. Bisected at the CoreFn declaration level: of the three top-level declarations inData.Arraythat reference a local namedb(foldRecM,foldM,filterA), removing onlyfoldRecMfrom the CoreFn makes the error disappear; keepingfoldRecMas the lone declaration reproduces it.