Skip to content

Mixed ST/Effect discard chains miscompile: canonicalization resurrects a DCE'd foreign accessor (runtime nil call) #297

Description

@Unisay

Problem

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):

module Golden.MixedDiscardFloat.Test where

import Prelude

import Control.Monad.ST as ST
import Control.Monad.ST.Ref as STRef
import Effect (Effect)
import Effect.Console (log, logShow)

stCount :: Int
stCount = ST.run do
  r <- STRef.new 1
  void (STRef.write 2 r)
  STRef.read r

main :: Effect Unit
main = do
  log "st:"
  logShow stCount

Generated Lua (abridged) — M is declared empty and these fields are never assigned:

local M = {}
...
  return M.Control_Monad_ST_Internal_bind_(...)(...)()  -- nil call
...
return M.Effect_bindE(Effect_Console_log("st:"))(...)()  -- nil call

Mechanism

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):

discard  = Control.Bind.discard discardUnit
discard1 = discard bindST
discard2 = discard bindEffect

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:

  1. Magic-do starvation. isCanonicalHead resolves one top-level hop; this chain head needs two (discard1Test.discardControl.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")).

  2. 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.

Metadata

Metadata

Assignees

Labels

area: irIR / optimizer / DCE / inlinerbugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions