Stack corruption in GHCi large unboxed tuple returns (stale ctoi_tuple_spill_words)
<!-- ══════════════════════════════════════════════════════════════════
Draft metadata (for Simon — strip before filing)
Covers GlaHiCo findings f1–f3 of snippet 6133 as ONE issue: all three
concern the tso->ctoi_tuple_spill_words bookkeeping introduced by the
arbitrary-size-unboxed-tuples-in-bytecode work (#26946). No existing
GitLab issue tracks any of them (searched: ctoi_tuple_spill_words,
maxTupleReturnNativeStackSize, "unboxed tuples in bytecode", control0,
stg_ctoi_t, "tuple too big for the bytecode").
Suggested labels: ~"T::bug" ~"GHCi" ~"bytecode" ~"runtime system"
~"P::high" (bugs 1–2 are silent stack corruption /
segfaults; bug 3 additionally ~"cross-compilation")
Suggested milestone: %9.15.1 (bugs are on master); also flag on the
9.14.2 release status issue — backported in !16163,
first shipped in 9.14.2-rc1, not in 9.14.1, and the
reproducers are a *regression* vs 9.14.1 (24-field
tuples were within the old 62-word limit).
Related: #26946 (feature ticket), !14979 (implementation MR),
!16163 (9.14.2 backport MR), #27620 (separate bug in
the same feature area), #26486 (old "tuple too big"
panic, closed)
Reproducers: tickets/f1-f3/ (run.sh drives both; verified
2026-08-06 on master build 10.1.20260710 =
d415f38a75, x86_64-linux: both segfault, exit 139;
same programs print 300/300 on ghc-9.14.1).
Bug 3 (f3) has NO runnable reproducer — it needs a
32-bit-host GHC targeting a 64-bit platform; the
code-level trigger is given instead.
═══════════════════════════════════════════════════════════════════ -->
## Summary
Commits a85bd503a480 ("Support arbitrary size unboxed tuples in
bytecode", #26946) and e22090317347 ("Add specialized frames for small
tuples") — backported to `ghc-9.14` as 7f0a32b8df1c and 8e8e3d32d70d in
!16163 and first shipped in 9.14.2-rc1 — introduce a TSO field
`ctoi_tuple_spill_words` that records how many words of an unboxed
tuple return are spilled to the stack. The generic return frame
`stg_ctoi_t` (used whenever the spill exceeds `MAX_SMALL_TUPLE_CTOI`
= 8 words) reads this field on return from compiled code and adjusts
`Sp` by it, relying on the invariant documented in
Note [GHCi unboxed tuples stack spills] (`rts/StgMiscClosures.cmm`):
> `tso->ctoi_tuple_spill_words == (frame[CTOI_TUPLE_INFO_OFFSET] >> 24)`
> where frame is the topmost `stg_ctoi_t` frame on the tso's stack.
Three ways this goes wrong, all present on both `master` (checked at
d415f38a75) and `ghc-9.14` (i.e. 9.14.2-rc1):
1. **A failed `control0#` capture clobbers the field.**
`captureContinuationAndAbort` updates the field during Phase 1, the
prompt-frame *search* loop (`rts/Continuation.c`, the
`if (info_ptr == &stg_ctoi_t_info)` hunk at ~line 460). If the
search bails out (`return NULL` on STOP/UPDATE/ATOMICALLY/
CATCH_RETRY/CATCH_STM frames) after walking past a `stg_ctoi_t`
frame, the stack is left untouched — `stg_control0zh` merely raises
the no-matching-prompt exception — but the TSO field has already
been overwritten with the walked frame's `old_spill`. All other
Phase-1 bookkeeping lives in locals discarded on bail-out; this TSO
write is the only durable side effect. The subsequent unwind does
not repair it either: `raiseExceptionHelper` restores the field when
it *pops* a `stg_ctoi_t` frame, but stops at the first CATCH_FRAME,
whereas Phase 1 walks *past* CATCH_FRAMEs — so a `catch` installed
above the `stg_ctoi_t` frame keeps the frame live and the field
stale. Verified: segfault (reproducer 1 below).
2. **STM `retry#`/abort unwinding never restores the field.** The
patch added `old_spill` restoration to three unwinding sites —
`raiseExceptionHelper` (`rts/Schedule.c`), `raiseAsync`
(`rts/RaiseAsync.c`), and continuation capture — but not to
`findRetryFrameHelper` or `findAtomicallyFrameHelper`
(`rts/Schedule.c`, the `default:` arms). Both helpers genuinely
discard every frame above the enclosing
CATCH_RETRY_FRAME/ATOMICALLY_FRAME (their callers set `Sp = frame`,
`rts/PrimOps.cmm`), so a `stg_ctoi_t` frame discarded by `retry#`
leaves the TSO field holding the discarded frame's spill count
instead of the enclosing frame's. Verified: segfault
(reproducer 2 below).
3. **`maxTupleReturnNativeStackSize` overflows to −1 on 32-bit
hosts.** `compiler/GHC/ByteCode/Asm.hs` returns the literal
`1099511627775` (2⁴⁰−1) at type `WordOff` for `PW8` targets, but
`WordOff` is a newtype over host-sized `Int`
(`compiler/GHC/ByteCode/Types.hs`). On a 32-bit host the literal
silently wraps to `-1` (`-Woverflowed-literals` does not fire for
newtypes), so the guard
`nativeCallStackSpillSize > maxTupleReturnNativeStackSize platform`
in `mkNativeCallInfoSig` holds for *every* tuple return and the
compiler panics with "tuple too big for the bytecode compiler" for
all unboxed-tuple returns targeting a 64-bit platform (32-bit-host
GHC with e.g. a 64-bit external interpreter — a cross-wordsize
configuration the `HalfWord` comment in `ByteCode/Types.hs`
explicitly supports). Before the patch the constant was 62, which
fits any `Int`. Not run (no 32-bit-host GHC at hand); the analysis
is code-level only.
Bugs 1 and 2 are silent stack corruption: `stg_ctoi_t` does
`Sp = Sp + WDS(tuple_spill)` with the stale count and then reads the
frame's `tuple_info`/`tuple_BCO`/`old_spill` words from the wrong
stack slots. The reproducers segfault; less fortunate layouts could
compute wrong results instead.
Note these are regressions relative to 9.14.1, not just bugs in newly
supported sizes: the reproducers use 24-field `Int#` tuples, well
within the old 62-word limit, and both print `300` on 9.14.1.
## Steps to reproduce
With any GHC containing a85bd503a480 (or 9.14.2-rc1), in a directory
containing the three files below:
```
$ GHC=/path/to/ghc sh run.sh
== Repro 1: failed control0# capture clobbers the TSO field:
Segmentation fault (core dumped)
Main1 exit: 139
== Repro 2: STM retry unwinding does not restore the TSO field:
Segmentation fault (core dumped)
Main2 exit: 139
```
Expected output (and actual output on 9.14.1): `300` twice.
Both reproducers follow the same pattern: interpreted code cases on a
large unboxed tuple (pushing a generic `stg_ctoi_t` frame, since the
spill exceeds 8 words), the scrutinee takes a detour that corrupts
the TSO field while the frame is still live, and then tail-calls a
*compiled* function `mkT` whose tuple return enters the frame's native
entry code, which trusts the stale field.
- Reproducer 1: the detour is a `control0#` on a fresh `PromptTag#`
with no matching prompt; the resulting exception is caught by a
`catch` installed *above* the `stg_ctoi_t` frame, so the unwind
stops before reaching the frame and the Phase-1 clobber survives.
- Reproducer 2: the detour is `atomically (stmBody `orElse` return ())`
where `stmBody` cases on a 16-field tuple (different spill count)
and retries; `findRetryFrameHelper` discards the inner `stg_ctoi_t`
frame without restoring its `old_spill`.
Deleting the `control0#` call (repro 1) or the `retry#` call (repro 2)
makes each program print `300`.
<details><summary>run.sh</summary>
```sh
#!/bin/sh
# Reproduces stale tso->ctoi_tuple_spill_words stack corruption in GHCi.
# Usage: GHC=/path/to/ghc sh run.sh
# Needs a GHC containing a85bd503a4 (master) / 7f0a32b8df (ghc-9.14, 9.14.2-rc1).
# Expected output on a good compiler: 300 twice. Buggy compilers segfault.
set -e
: "${GHC:=ghc}"
rm -f Obj.o Obj.hi
echo "== Repro 1: failed control0# capture clobbers the TSO field:"
$GHC -e main Main1.hs || echo "Main1 exit: $?"
echo "== Repro 2: STM retry unwinding does not restore the TSO field:"
$GHC -e main Main2.hs || echo "Main2 exit: $?"
```
</details>
<details><summary>Obj.hs</summary>
```haskell
{-# OPTIONS_GHC -fobject-code #-}
{-# LANGUAGE UnboxedTuples, MagicHash #-}
module Obj where
import GHC.Exts
-- Compiled to object code; returns a large unboxed tuple, so the return
-- into an interpreted caller goes through the native stg_ctoi_t entry code,
-- which reads tso->ctoi_tuple_spill_words.
mkT :: State# RealWorld -> (# State# RealWorld, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int# #)
mkT s = (# s, 1#, 2#, 3#, 4#, 5#, 6#, 7#, 8#, 9#, 10#, 11#, 12#, 13#, 14#, 15#, 16#, 17#, 18#, 19#, 20#, 21#, 22#, 23#, 24# #)
{-# NOINLINE mkT #-}
```
</details>
<details><summary>Main1.hs</summary>
```haskell
{-# LANGUAGE UnboxedTuples, MagicHash #-}
module Main where
import Control.Exception
import GHC.Exts
import GHC.IO (IO(..))
import Obj
unIO :: IO a -> State# RealWorld -> (# State# RealWorld, a #)
unIO (IO f) = f
-- control0# with a tag that has no matching prompt anywhere on the stack;
-- the resulting exception is caught right here, above the stg_ctoi_t frame.
failing :: IO ()
failing = IO (\s0 -> case newPromptTag# s0 of
(# s1, tag #) -> control0# tag (\_ s -> (# s, () #)) s1)
`catch` \(SomeException _) -> return ()
scrut :: State# RealWorld -> (# State# RealWorld, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int# #)
scrut s0 = case unIO failing s0 of (# s1, _ #) -> mkT s1
{-# NOINLINE scrut #-}
main :: IO ()
main = IO (\s0 -> case scrut s0 of
(# s1, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13, x14, x15, x16, x17, x18, x19, x20, x21, x22, x23, x24 #) ->
unIO (print (I# (x1 +# x2 +# x3 +# x4 +# x5 +# x6 +# x7 +# x8 +# x9 +# x10 +# x11 +# x12 +# x13 +# x14 +# x15 +# x16 +# x17 +# x18 +# x19 +# x20 +# x21 +# x22 +# x23 +# x24))) s1)
```
</details>
<details><summary>Main2.hs</summary>
```haskell
{-# LANGUAGE UnboxedTuples, MagicHash #-}
module Main where
import GHC.Conc (STM(..), atomically, orElse)
import GHC.Exts
import GHC.IO (IO(..))
import Obj
unIO :: IO a -> State# RealWorld -> (# State# RealWorld, a #)
unIO (IO f) = f
-- Inside the transaction: case on a 16-field tuple (different spill count
-- than the outer 24-field one), then retry. The generic stg_ctoi_t frame
-- pushed for this case is discarded by findRetryFrameHelper.
innerScrut :: State# RealWorld -> (# State# RealWorld, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int# #)
innerScrut s0 = case retry# s0 of (# s1, () #) -> (# s1, 1#, 2#, 3#, 4#, 5#, 6#, 7#, 8#, 9#, 10#, 11#, 12#, 13#, 14#, 15#, 16# #)
{-# NOINLINE innerScrut #-}
stmBody :: STM ()
stmBody = STM (\s0 -> case innerScrut s0 of
(# s1, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16 #) -> (# s1, () #))
outerScrut :: State# RealWorld -> (# State# RealWorld, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int#, Int# #)
outerScrut s0 =
case unIO (atomically (stmBody `orElse` return ())) s0 of
(# s1, _ #) -> mkT s1
{-# NOINLINE outerScrut #-}
main :: IO ()
main = IO (\s0 -> case outerScrut s0 of
(# s1, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13, x14, x15, x16, x17, x18, x19, x20, x21, x22, x23, x24 #) ->
unIO (print (I# (x1 +# x2 +# x3 +# x4 +# x5 +# x6 +# x7 +# x8 +# x9 +# x10 +# x11 +# x12 +# x13 +# x14 +# x15 +# x16 +# x17 +# x18 +# x19 +# x20 +# x21 +# x22 +# x23 +# x24))) s1)
```
</details>
(The tuples are 24 and 16 `Int#` fields respectively, giving ~18 and
~10 spilled words on x86_64 — both over the `MAX_SMALL_TUPLE_CTOI = 8`
threshold for the generic frame, and different from each other, which
is what repro 2 needs.)
## Details
### Bug 1: `captureContinuationAndAbort` (f1)
The field update sits in Phase 1, after the bail-out check
(`rts/Continuation.c`, `captureContinuationAndAbort`):
```c
// see Note [GHCi unboxed tuples stack spills]
if (info_ptr == &stg_ctoi_t_info) {
tso->ctoi_tuple_spill_words = frame[CTOI_OLD_TUPLE_SPILL_WORDS_OFFSET];
}
```
The write is needed on the success path (once the walked frames are
captured away, the topmost surviving frame's value is the right one),
but on the failure path it fires for frames that remain live. Fix
sketch: accumulate the value in a Phase-1 local and store it to the
TSO only in Phase 2, once the capture is committed.
### Bug 2: `findRetryFrameHelper` / `findAtomicallyFrameHelper` (f2)
Their `default:` arms just do `p = next; continue;`. Fix sketch:
mirror the `raiseExceptionHelper` change in both:
```c
// see Note [GHCi unboxed tuples stack spills] in StgMiscClosures.cmm
if (*p == (StgWord)&stg_ctoi_t_info) {
tso->ctoi_tuple_spill_words = p[CTOI_OLD_TUPLE_SPILL_WORDS_OFFSET];
}
```
The reproducer above exercises `findRetryFrameHelper` via `orElse`;
`findAtomicallyFrameHelper` (reached via `stg_abort` when both
`orElse` branches retry) has the identical omission. The plain
"blocking `retry` woken by another thread" path also goes through
`findRetryFrameHelper` and is affected the same way.
### Bug 3: `maxTupleReturnNativeStackSize` on 32-bit hosts (f3)
```haskell
maxTupleReturnNativeStackSize :: Platform -> WordOff
maxTupleReturnNativeStackSize platform = case platformWordSize platform of
PW4 -> 255
PW8 -> 1099511627775
```
with `newtype WordOff = WordOff Int` (`compiler/GHC/ByteCode/Types.hs`).
On a 32-bit host `1099511627775 :: WordOff` is `-1`, so
`nativeCallStackSpillSize > maxTupleReturnNativeStackSize platform` in
`mkNativeCallInfoSig` (`compiler/GHC/ByteCode/Asm.hs`) is true for
every spill size ≥ 0 and every unboxed-tuple return targeting a PW8
platform panics. (The second use, `assertPpr (cont_offset <=
fromIntegral (maxTupleReturnNativeStackSize platform))`, becomes a
vacuous `<= 0xFFFFFFFFFFFFFFFF :: Word64`, but the first guard panics
before that matters.) Fix sketch: compute the limit at a
host-independent type, e.g. `Word64` (`bit 40 - 1`), and convert
`nativeCallStackSpillSize` for the comparison.
Untested on a real 32-bit host — this part is from code inspection
only.
## Versions affected
- `master`: bugs verified by running reproducers on build
10.1.20260710 (d415f38a75); introduced by a85bd503a480.
- `ghc-9.14`: same code present (7f0a32b8df1c + 8e8e3d32d70d, via
!16163); shipped in 9.14.2-rc1. Not in 9.14.1 (reproducers print
`300` there).
## Provenance
Found by [GlaHiCo](https://gitlab.haskell.org/sjakobi/GlaHiCo), an
LLM-driven review pipeline, while reviewing the 9.14.2-rc1 backports
7f0a32b8df1c and 8e8e3d32d70d; the published review is at
https://gitlab.haskell.org/sjakobi/GlaHiCo/-/snippets/6133 (findings
f1–f3 correspond to this issue). This draft was prepared by an agent —
which also built and ran the reproducers — and reviewed by a human
before filing.
issue