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

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