Admin message

Due to a large amount of spam we do not allow new users to create repositories, they are "external" users. If you are a new user and want to create a repository, for example for forking GHC, open a new issue on ghc/ghc using the "get-verified" issue template

Specialiser drops quantification of type variables free only in a SpecDict argument, giving out-of-scope Core
<!-- ============================================================ Metadata for Simon (not part of the ticket body) Suggested title: Specialiser drops quantification of type variables free only in a SpecDict argument, giving out-of-scope Core (regression from 641ec3f019) Suggested labels: ~bug, ~Specialisation, ~"compiler crash" (Lint error) Suggested milestone: %9.14.2 (the culprit commit is being backported there via 55f0de7cebf5; alternatively %10.0.1 / whatever HEAD targets) Related issues: #26682 (the bug 641ec3f019 fixed; this is a regression from that fix), #22459 (origin of Note [Weird special case for SpecDict]), #23559 (re-enabling -fpolymorphic-specialisation by default would widen exposure) CC candidates: @simonpj (author of 641ec3f019), @sheaf (minimised #26682) GlaHiCo provenance: finding f1 of run ~/glahico/runs/local-work-fb77202..55f0de7/20260805T205127 (sweep ghc-9.14.2-rc1, group g133), published review: https://gitlab.haskell.org/sjakobi/GlaHiCo/-/snippets/6128 Reproducer files: tickets/f1/ (Repro.hs, ReproPlugin.hs, run.sh), verified 2026-08-06 against in-tree stage2 at d415f38a75 (10.1.20260710, contains 641ec3f019) and against ghcup ghc-9.14.1 (pre-patch control). ============================================================ --> ## Summary Commit 641ec3f01974dff9dfd756f3f0499796c8df1a8b ("Fix scoping errors in specialisation", fixing #26682; cherry-picked to `ghc-9.14` as 55f0de7cebf5da569f56e1a2d39fe3cc2c787cde for 9.14.2) refactored `specHeader`/`spec_call` in `GHC.Core.Opt.Specialise` so that the extra type variables to quantify in a specialisation are computed in `specHeader` alone. The new code collects them only in the `SpecType` case (`free_tvs`, from `tyCoVarsOfTypeList ty`). The old `spec_call` code computed `poly_qvars` from the free variables of *all* `SpecArg`s, including dictionary *expressions* (`specArgsFVs` has `get (SpecDict dx) = exprFVs dx`). So a type variable that is free in a `SpecDict` argument's *expression* but not in its *type* (and not in any `SpecType` argument) is no longer quantified anywhere: the specialised RHS (or a floated `dx_bind`) mentions it out of scope. This is exactly the shape documented in Note [Weird special case for SpecDict] (from #22459), and it violates (MP2) of Note [Specialising polymorphic dictionaries]. It needs `-fpolymorphic-specialisation`: with that flag `ci_fvs` deliberately omits type variables (see (MP1) and `singleCall`), so such calls float past their `/\a` binders by design. Note [Weird special case for SpecDict] claims the case is prevented by a guard in the `SpecDict` case of `specHeader`: ``` allVarSet (`elemInScopeSet` in_scope) (exprFreeVars d) ``` but that guard does not exist: it was deleted in 695fc5d2b2, precisely when `poly_qvars` was introduced to subsume it — and 641ec3f019 now deletes `poly_qvars` without restoring the guard. (Two stale references to `poly_qvars` also survive in comments in `spec_call`.) Both `master` and `ghc-9.14` are affected. ## Reproducer The triggering dictionary shape (`mkD @a :: C T` with `a` free only in the expression) cannot be written in source Haskell — the known producer (`abstractFloats` over-abstraction, #22459) was fixed — so the reproducer uses a small Core plugin to inject the Note's example before the Core pipeline runs. The injected Core is scope-correct, ordinary Core; the specialiser is expected to handle it. `Repro.hs` (compiled with `-O -fpolymorphic-specialisation -dcore-lint`) defines a recursive overloaded `split :: C b => [b] -> [b]` and `poly :: forall (x :: Type). T -> [T]`; the plugin adds `mkD :: forall a. T -> C T` (NOINLINE, `mkD = /\a. \_. $fCT`) and rewrites `poly` to ``` poly = /\x. \t. split @T (mkD @x MkT) [t] ``` With a compiler containing 641ec3f019 (tested: in-tree stage2 at d415f38a75, 10.1.20260710), specialising `split` produces: ``` *** Core Lint errors : in result of Specialise *** <no location info>: warning: The type variable ‘x_auL’ is out of scope In the RHS of $dC_swk :: C T ... $dC_swk :: C T $dC_swk = mkD_sw1 @x_auL MkT Rec { $ssplit_swj :: [T] -> [T] $ssplit_swj = \ (ds_DvU :: [T]) -> case ds_DvU of { [] -> [] @T; : x_aqt xs_aqu -> : @T (meth @T $dC_swk x_aqt) (split @T $dC_swk xs_aqu) } ``` The floated dictionary binding `$dC_swk = mkD @x MkT` has escaped its `/\x` binder. Without `-dcore-lint` this small example compiles silently, i.e. the scope-broken Core goes unnoticed. Control: GHC 9.14.1 (pre-patch) on the same input correctly builds `$ssplit = /\x. let $dC = mkD @x MkT in ...` via `poly_qvars` — but Lint instead reports `Rule "SPEC split @T": unbound [x_aG6]`, the rule template variable that is not bound on the rule LHS. That is the #26682 bug which 641ec3f019 fixed. So the old quantification was also not a complete answer for this call shape; the patch fixed the rule but made the specialised code itself scope-broken, which seems worse. Presumably the right treatment for a call whose dictionary argument has out-of-scope free variables is to decline to specialise it — i.e. restore the guard that Note [Weird special case for SpecDict] still describes (or quantify the dict expression's tyvars in `specHeader`'s `SpecDict` case and keep them off the RULE). If instead the case is deemed impossible nowadays, the Note should be updated to say why, and the stale `poly_qvars` comments removed. `run.sh` (set `GHC=` to a plugin-capable build containing 641ec3f019; defaults to an in-tree stage2): <details> <summary>run.sh</summary> ```sh #!/bin/sh # Reproduces the SpecDict quantification bug introduced by 641ec3f019 # ("Fix scoping errors in specialisation"). # # GHC must contain 641ec3f019 and be plugin-capable (a stage-2 in-tree # build works). Expected result: Core Lint "out of scope" errors after # the Specialiser pass. set -e GHC=${GHC:-$HOME/ghc/work/_build/stage1/bin/ghc} cd "$(dirname "$0")" rm -f ./*.hi ./*.o "$GHC" --make -package ghc -dynamic-too -c ReproPlugin.hs "$GHC" -c -O -fpolymorphic-specialisation -dcore-lint Repro.hs ``` </details> <details> <summary>Repro.hs</summary> ```haskell {-# LANGUAGE AllowAmbiguousTypes #-} {-# OPTIONS_GHC -fplugin=ReproPlugin #-} -- The plugin rewrites 'poly' to the example of -- Note [Weird special case for SpecDict]: -- -- poly = /\x. \t. split @T (mkD @x MkT) [t] -- -- where mkD :: forall a. T -> C T is a new top-level binding -- (mkD = /\a. \_. $fCT), so the tyvar x is free in the dictionary -- *expression* but not in its *type* (C T). module Repro (poly, split, T (..), C (..)) where import Data.Kind (Type) data T = MkT class C b where meth :: b -> b instance C T where meth x = x split :: C b => [b] -> [b] split [] = [] split (x : xs) = meth x : split xs poly :: forall (x :: Type). T -> [T] poly t = [t] ``` </details> <details> <summary>ReproPlugin.hs</summary> ```haskell -- Injects the example of Note [Weird special case for SpecDict] in -- GHC.Core.Opt.Specialise, which cannot be written in source Haskell: -- a call whose dictionary argument mentions a type variable that is -- free in the dictionary expression but not in its type. -- -- Runs before the main Core pipeline (in particular before the -- Specialiser). It adds -- -- mkD :: forall a. T -> C T {-# NOINLINE mkD #-} -- mkD = /\a. \_. $fCT -- -- and rewrites poly = /\x. \t. [t] -- to poly = /\x. \t. split @T (mkD @x MkT) [t] {-# LANGUAGE CPP #-} module ReproPlugin (plugin) where import GHC.Core.Make (mkCoreApps, mkListExpr) import GHC.Core.Predicate (getClassPredTys_maybe) import GHC.Plugins #if __GLASGOW_HASKELL__ >= 1000 import GHC.Types.InlinePragma (neverInlinePragma) #else import GHC.Types.Basic (neverInlinePragma) #endif plugin :: Plugin plugin = defaultPlugin { installCoreToDos = \_ todos -> return (CoreDoPluginPass "inject-weird-specdict" pass : todos) , pluginRecompile = purePlugin } pass :: ModGuts -> CoreM ModGuts pass guts = do let binds = mg_binds guts dfun = case [b | b <- bindersOfBinds binds, isDFunId b] of (b : _) -> b [] -> panic "ReproPlugin: no dfun binding in module" dictTy = idType dfun -- C T tyT = case getClassPredTys_maybe dictTy of Just (_cls, [t]) -> t _ -> panic "ReproPlugin: dfun type is not C T" mkTCon = case tyConDataCons (tyConAppTyCon tyT) of (dc : _) -> dataConWorkId dc [] -> panic "ReproPlugin: T has no data constructors" findTopId nm = case [b | b <- bindersOfBinds binds, occNameString (getOccName b) == nm] of (b : _) -> b [] -> panic ("ReproPlugin: no top-level binding " ++ nm) splitId = findTopId "split" polyId = findTopId "poly" uMkD <- getUniqueM uTv <- getUniqueM uWild <- getUniqueM let aTv = mkTyVar (mkSystemName uTv (mkTyVarOcc "a")) liftedTypeKind mkDTy = mkSpecForAllTys [aTv] (mkFunctionType ManyTy tyT dictTy) mkDId = setInlinePragma (mkExportedVanillaId (mkSystemName uMkD (mkVarOcc "mkD")) mkDTy) neverInlinePragma wild = mkSysLocal (fsLit "eta") uWild ManyTy tyT mkDBind = NonRec mkDId (mkLams [aTv, wild] (Var dfun)) rewritePoly rhs | (bndrs, _body) <- collectBinders rhs , (x : _) <- reverse (filter isTyVar bndrs) , (t : _) <- filter isId bndrs = mkLams bndrs $ mkCoreApps (Var splitId) [ Type tyT , mkCoreApps (Var mkDId) [Type (mkTyVarTy x), Var mkTCon] , mkListExpr tyT [Var t] ] | otherwise = pprPanic "ReproPlugin: unexpected shape of poly" (ppr rhs) go (b, rhs) | b == polyId = (b, rewritePoly rhs) | otherwise = (b, rhs) -- One top-level Rec, so bind order is irrelevant. binds' = [Rec (map go (flattenBinds binds) ++ flattenBinds [mkDBind])] putMsgS "ReproPlugin: injected weird SpecDict call into poly" return guts {mg_binds = binds'} ``` </details> ## Impact assessment Narrow in practice today: it needs `-fpolymorphic-specialisation` (off by default; #23559 tracks re-enabling it) *and* a dictionary expression whose type variables are hidden from its type, whose one known in-tree producer was fixed. But the triggering Core is valid input that any pass or plugin may produce, the specialiser is expected to be robust to it, and the compiled result is scope-broken Core that only `-dcore-lint` notices. At minimum the code and Note [Weird special case for SpecDict] now contradict each other and one of them should change. ## Provenance This issue was found by GlaHiCo, an LLM-generated review of 641ec3f019 / 55f0de7cebf5 (review published at https://gitlab.haskell.org/sjakobi/GlaHiCo/-/snippets/6128), and was verified and written up with LLM assistance. A human reviewed the finding before filing.
issue