On this project's patched Embedded Swift toolchain (target mipsel-none-none-elf, MIPS I /
R3000A, -mno-abicalls -fno-pic -relocation-model=static), ordinary Array operations —
growing past an existing capacity, and even removeAll(keepingCapacity: true) on an empty,
already-reserved array — intermittently hang forever with no crash, no exception, no log.
Dictionary (which shares Array's tail-allocated buffer-class storage shape) is suspected of
the same class of bug but not yet independently isolated.
This was discovered while building a game (junkbot-swift's ports/PS1, an Embedded Swift
game engine port) on top of this toolchain, and is reproduced here in a minimal, self-contained
example: make arraytests / make arraytests-llc (see Sources/ArrayTests/). Both .psexes
run the same sequence of increasingly complex Array/Dictionary operations, printing
"N RUNNING" then "N PASS" for each; a test that hangs leaves its "RUNNING" line frozen
on screen forever, and no later test's line ever appears. That frozen line is the failure
signal — there is no other indication anything went wrong (see "Why this is hard to detect,
below).
This is not a small, isolable set of bad call sites. Every mitigation attempted so far (described below) has narrowed the reproduction slightly but not converged on a fix, and the failure has turned out to be sensitive to seemingly-irrelevant details of the surrounding code (see "Update: reproducibility is context-sensitive" below) — consistent with a stale/uninitialized register being reused across a live range in the compiled code, rather than one specific wrong formula.
- Toolchain:
/Volumes/Crucial-2TB/Developer/build/Ninja-ReleaseAssert(built from source per this repo's own README patch set) swiftc --version:Swift version 6.5-dev (LLVM 9597da2255fb6d2, Swift 0cc6e978b39043d),Target: arm64-apple-macosx26.0,Build config: +assertions- Target triple:
mipsel-none-none-elf - Reproduced via this repo's
Sources/ArrayTests/(new), run in DuckStation 0.1-11515.
make check-sdk # confirms psn00bsdk/ is set up (see README's "Build" section)
make arraytests # plain `swiftc -c` (this repo's normal build path for every other example)
make arraytests-llc # same Main.swift, routed through `swift-frontend -emit-ir` + a separate
# `llc` invocation instead (same toolchain, same LLVM MIPS backend either
# way — this compares invocation *paths*, not different codegen)Load either .psexe in an emulator (DuckStation recommended, per this repo's main README) and
watch which test's "N RUNNING" line is the last one to ever appear.
| # | What it does | Result |
|---|---|---|
| 1 | UnsafeMutableRawPointer.allocate(byteCount: 32, alignment: 8) |
Always passes. |
| 2 | var a: [Int32] = []; a.append(1) — first-ever allocation for this array |
Always passes. |
| 3a | var a: [Int32] = []; a.append(10); a.append(20) — array literal starts empty, two separate .append() calls, second one grows past capacity 1 |
Hangs, both builds (see "Update" below — this passed under arraytests-llc in an earlier, more complex repro context; it does not reproduce that success here). |
| 3b | var a: [Int32] = [10]; a.append(20) — same growth, but the initial element comes from an array literal instead of .append() |
Hangs, both builds. |
| 4 | var a: [Int32] = []; a.reserveCapacity(8); for i in 0..<8 { a.append(...) } — growth avoided entirely via reserveCapacity |
(not reached — 3a/3b already hang first; expected to pass based on junkbot-swift's findings, not yet independently confirmed in this example) |
| 5 | var a: [Int32] = []; a.reserveCapacity(8); a.removeAll(keepingCapacity: true) |
(not reached in this example's current run order — confirmed hanging independently in junkbot-swift's ports/PS1, see below) |
| 6 | var d: [Int32: Int32] = [:]; d[1] = 100; d[2] = 200 |
(not reached) |
There is no exception handler installed on this bare-metal target. When a miscompiled code
path produces a bad value that the Swift runtime would normally turn into a trap/precondition
failure, the CPU has nowhere to go — it silently spins forever at the reset/exception vector.
From the outside, this is indistinguishable from a plain infinite loop: the emulator's own UI
keeps running fine, but the "Game" FPS counter (if the emulator reports one) drops to 0 and the
framebuffer freezes on whatever was last presented. Nothing in any log indicates a fault
occurred. The "print RUNNING, do the thing, print PASS, flip to both video buffers after each
line" structure in Sources/ArrayTests/Main.swift exists specifically to make this diagnosable
without a debugger or exception handler.
- Not a naive
posix_memalignignoringalignment.Sources/ArrayTests/shim.cuses a real bump allocator that honors the requested alignment (unlikeHelloPS1/Balls/RGB24/Tiles'sposix_memalign -> malloc(size)passthrough, which happens to work for those examples only because none of them exerciseArraygrowth). A misaligned pointer causing a hard fault on the R3000A's no-fixup unaligned access was the first hypothesis; fixing the allocator's alignment handling did not change the outcome. - Not heap bounds/out-of-memory. Plenty of headroom confirmed at every failure point in
junkbot-swift's more detailed bisection (see that repo'sports/PS1/KNOWN_ISSUES.md). - Not a general C/Swift calling-convention bug.
UnsafeMutableRawPointer.allocate(test 1) and a fresh array's first allocation (test 2) both pass reliably — the exact sameposix_memalignC entry point, called with different (correct) arguments each time. - Not
reserveCapacityalone. It avoids growth-past-capacity in some contexts (confirmed injunkbot-swift) but does not fixremoveAll(keepingCapacity: true)on an already-empty array with sufficient reserved capacity — that hangs regardless (seejunkbot-swift'sports/PS1/KNOWN_ISSUES.md"Update 2" for the isolated repro:var a: [Int32] = []; a.reserveCapacity(8); a.removeAll(keepingCapacity: true)hangs on its own, with nothing else preceding it).
An earlier, more complex repro (building a real game's entities array, inside
junkbot-swift's ports/PS1) found that routing the SAME kind of two-.append()-calls growth
through -emit-ir + llc (rather than plain swiftc -c) avoided the corruption — the byte
count arriving at posix_memalign was correct both times, where it had been corrupted
(garbage in the upper 16 bits of an otherwise-plausible value, e.g. 0x5104006C instead of
0x0000006C) under plain swiftc -c.
This example's test 3a is the same shape, deliberately isolated down to nothing but two
.append() calls with no other code around them — and it hangs under both make arraytests and make arraytests-llc. So the -emit-ir+llc route is not a reliable
workaround; it happened to avoid the corruption in one particular surrounding-code context and
does not in this simpler one. This is the strongest evidence yet that whatever's wrong is
sensitive to register allocation / live-range reuse around the growth call site (which
differs between the two repro contexts), not a single deterministically-wrong formula that a
given compilation path either has or doesn't have.
Practical implication for anyone debugging this upstream: don't trust "it passed under
-emit-ir+llc" as a fix without testing the exact surrounding code shape you care about —
as this example demonstrates, the same operation can pass in one context and hang in another
under the identical compilation pipeline.
- Diff the LLVM IR (
build/arraytests-llc/main.swift.llaftermake arraytests-llc) for test 3a against test 3b — same growth (capacity 1 → 2), only difference is whether the first element came from.append()or an array literal. If the IR is identical or near-identical but codegen differs, the bug is in instruction selection/register allocation, not the frontend's IR generation. - Add
-mllvm -print-after-allto thellcinvocation in theMakefile'sbuild/arraytests-llc/main.swift.orule and inspect what changes across passes for the growth-copy/removeAllcode paths specifically. - Compare against a target where the same
Sources/ArrayTests/Main.swift(ported to that target's bridging header) runs correctly —junkbot-swift'sports/N64(targetmips-none-none-elf, MIPS III/32r2, official Embedded Swift MIPS slice, no source patches needed) andports/3DS(targetarmv6-none-none-eabi) both runArray-heavy code reliably. The-march=mips1(true MIPS I) instruction subset is far less common/tested in LLVM than either of those — that's the most likely place a narrow codegen bug would hide undetected. - Try
-Ononeinstead of-Osizefor the affected compilation to check whether this is optimization-dependent (consistent with "stale register reused across an optimized live range" rather than a fundamentally wrong lowering).
InlineArray<count, Element> (SE-0453, available on this toolchain's Swift 6.5-dev) is a
fixed-size, inline-storage collection — its element count is part of the type, like a C
array, with no heap allocation, no COW, no growth, and therefore none of the dynamic
buffer-class machinery bugs #1/#2 live in. Test 7 in Sources/ArrayTests/Main.swift confirms
this works correctly on this target: literal initialization, subscript writes, subscript
reads, and iteration all produce correct results (sum=280 from 0+10+...+70, verified in
DuckStation).
The tradeoff is ergonomics: InlineArray has no .append()/.removeAll()/dynamic resizing —
using it as an Array replacement in existing code (e.g. JunkbotCore's entities: [Entity])
means wrapping it in a small "fixed capacity + separate count" type that manually implements
just the subset of Array's API actually used (append-if-room, iterate 0..<count,
swap-remove, etc.) via direct subscript access into a compile-time-sized InlineArray. This is
real porting work — every Array/Dictionary use site needs a concrete maximum size chosen
up front — but it's a proven-working path that doesn't require an upstream compiler fix.
Any Embedded Swift program on this target that uses Array/Dictionary beyond the narrowest
proven-safe patterns (a single allocation, populate, read — no further growth, no
removeAll) risks hitting this. This blocks porting any non-trivial existing Swift codebase
(e.g. junkbot-swift's shared JunkbotCore game engine, which is Array-heavy throughout)
without either an upstream fix, or rewriting the affected code against InlineArray-backed
fixed-capacity containers instead of Array/Dictionary (see above — confirmed viable, not
just theoretical).