You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A module that instantiates discard with two different Bind dictionaries — Effect or ST plus any second monad (the repro uses ST + Effect; Effect + Maybe triggers the same shape) — miscompiles: the generated Lua reads the Effect/ST bind methods off module-table fields that are never assigned, and crashes on first use with attempt to call field 'Control_Monad_ST_Internal_bind_' (a nil value).
Reproduction (crashes at runtime):
moduleGolden.MixedDiscardFloat.TestwhereimportPreludeimportControl.Monad.STasSTimportControl.Monad.ST.RefasSTRefimportEffect (Effect)
importEffect.Console (log, logShow)
stCount::Int
stCount = ST.run do
r <-STRef.new 1
void (STRef.write 2 r)
STRef.read r
main::EffectUnit
main = do
log "st:"
logShow stCount
Generated Lua (abridged) — M is declared empty and these fields are never assigned:
When one module uses discard with two different Bind dictionaries, the PureScript compiler's own CSE (its CoreFn common-subexpression pass over compiler-synthesized dictionary applications) floats the dictionary application in two stages — the shared partial application gets its own top-level binding (verbatim in the repro's corefn.json):
Neither canonicalization tier of #182 matches the two-stage shape. Tier 1 (CoreFn translation) sees discard1 = App (Ref Test.discard) (Ref bindST), whose head is the module-local float, not Control.Bind.discard. Tier 2 (canonicalizeEffectHead) keeps seeing the same two-node shape at every pre-magic-do pass boundary, because the float alias is only dissolved by call-site inlining, which is enabled post-magic-do (#180). Two consequences, in increasing severity:
Magic-do starvation.isCanonicalHead resolves one top-level hop; this chain head needs two (discard1 → Test.discard → Control.Bind.discard discardUnit). The Effect/ST chains stay nested, so the missed flattening re-opens the Long do blocks generate Lua that exceeds the parser nesting limit #46 nesting-limit exposure for long chains. Traced at the magic-do boundary: discard1 = AppN (Ref (Imported Test "discard")) (Ref (Imported "Control.Monad.ST.Internal" "bindST")).
The crash. In the pre-magic-do optimize+dce fixpoints nothing references the canonical foreign accessors (Control.Monad.ST.Internal.bind_, Effect.bindE) — every route goes through the dictionaries — so their accessor bindings are dissolved into use sites and the standalone bindings DCE'd. Post-magic-do, call-site inlining dissolves Test.discard into discard1/discard2, the dictionary application finally becomes canonical, and canonicalizeEffectHead rewrites it to Ref (Imported Control.Monad.ST.Internal bind_) — manufacturing a reference to a binding that no longer exists. No pass or lint validates Imported references, and codegen renders the dangling one as a read of a never-assigned module-table field.
The final IR shows the dangling end state: bind_ occurs in the module's ForeignImport carried-name list (so the FFI table keeps the field), and as the manufactured Ref — but no Control.Monad.ST.Internal.bind_ binding exists.
Approach
Two independent fixes:
Normalize the spine through top-level aliases instead of counting hops. The canonical rows apply only to Effect/ST, whose dictionaries are nullary constants, so the matched spines are structurally bounded (bind/pure: two nodes, discard: three) — but purs CSE may cut any node of that bounded spine into a top-level float. Tier 2 should resolve each head position through the top-level binding map (visited-bounded) before matching the table; that is closed under any CSE split, not just the observed two-stage one. It also fixes the starvation: tier 2 runs in the pre-magic-do fixpoints, so magic-do sees canonical heads again.
No rewrite may manufacture a Ref to a binding that does not exist. The dangling-import class outlives any particular recognizer: add a pass-boundary lint (checked runner) that every Imported reference outside Prim resolves to a live top-level binding, so the golden suite pins any future resurrect-after-DCE to the offending pass instead of miscompiling silently.
Found
While implementing #233: the ST golden module mixes ST loop discards with an Effect main and crashed at baseline, before any lowering landed.
Problem
A module that instantiates
discardwith two differentBinddictionaries — Effect or ST plus any second monad (the repro uses ST + Effect; Effect + Maybe triggers the same shape) — miscompiles: the generated Lua reads the Effect/ST bind methods off module-table fields that are never assigned, and crashes on first use withattempt to call field 'Control_Monad_ST_Internal_bind_' (a nil value).Reproduction (crashes at runtime):
Generated Lua (abridged) —
Mis declared empty and these fields are never assigned:Mechanism
When one module uses
discardwith two differentBinddictionaries, the PureScript compiler's own CSE (its CoreFn common-subexpression pass over compiler-synthesized dictionary applications) floats the dictionary application in two stages — the shared partial application gets its own top-level binding (verbatim in the repro'scorefn.json):Neither canonicalization tier of #182 matches the two-stage shape. Tier 1 (CoreFn translation) sees
discard1 = App (Ref Test.discard) (Ref bindST), whose head is the module-local float, notControl.Bind.discard. Tier 2 (canonicalizeEffectHead) keeps seeing the same two-node shape at every pre-magic-do pass boundary, because the float alias is only dissolved by call-site inlining, which is enabled post-magic-do (#180). Two consequences, in increasing severity:Magic-do starvation.
isCanonicalHeadresolves one top-level hop; this chain head needs two (discard1→Test.discard→Control.Bind.discard discardUnit). The Effect/ST chains stay nested, so the missed flattening re-opens the Long do blocks generate Lua that exceeds the parser nesting limit #46 nesting-limit exposure for long chains. Traced at the magic-do boundary:discard1 = AppN (Ref (Imported Test "discard")) (Ref (Imported "Control.Monad.ST.Internal" "bindST")).The crash. In the pre-magic-do optimize+dce fixpoints nothing references the canonical foreign accessors (
Control.Monad.ST.Internal.bind_,Effect.bindE) — every route goes through the dictionaries — so their accessor bindings are dissolved into use sites and the standalone bindings DCE'd. Post-magic-do, call-site inlining dissolvesTest.discardintodiscard1/discard2, the dictionary application finally becomes canonical, andcanonicalizeEffectHeadrewrites it toRef (Imported Control.Monad.ST.Internal bind_)— manufacturing a reference to a binding that no longer exists. No pass or lint validatesImportedreferences, and codegen renders the dangling one as a read of a never-assigned module-table field.The final IR shows the dangling end state:
bind_occurs in the module'sForeignImportcarried-name list (so the FFI table keeps the field), and as the manufacturedRef— but noControl.Monad.ST.Internal.bind_binding exists.Approach
Two independent fixes:
bind/pure: two nodes,discard: three) — but purs CSE may cut any node of that bounded spine into a top-level float. Tier 2 should resolve each head position through the top-level binding map (visited-bounded) before matching the table; that is closed under any CSE split, not just the observed two-stage one. It also fixes the starvation: tier 2 runs in the pre-magic-do fixpoints, so magic-do sees canonical heads again.Refto a binding that does not exist. The dangling-import class outlives any particular recognizer: add a pass-boundary lint (checked runner) that everyImportedreference outsidePrimresolves to a live top-level binding, so the golden suite pins any future resurrect-after-DCE to the offending pass instead of miscompiling silently.Found
While implementing #233: the ST golden module mixes ST loop discards with an Effect
mainand crashed at baseline, before any lowering landed.