Skip to content

sdk: reserve an 8 MiB main-thread shadow stack for linked programs#842

Open
brandonpayton wants to merge 1 commit into
mainfrom
sdk/main-thread-stack-8mib
Open

sdk: reserve an 8 MiB main-thread shadow stack for linked programs#842
brandonpayton wants to merge 1 commit into
mainfrom
sdk/main-thread-stack-8mib

Conversation

@brandonpayton

@brandonpayton brandonpayton commented Jul 4, 2026

Copy link
Copy Markdown
Member

Problem

wasm-ld's default main-thread shadow stack is about 64 KiB, and WebAssembly has no stack guard page. An overflow therefore writes below __data_end into allocator and pthread/TLS globals before failing later in an unrelated function.

This has two concrete package-level reproductions:

  • GTK's gdk_pixbuf_new_from_file call chain corrupts TLS under the default stack.
  • ncurses 6.5 nests two 32,816-byte terminfo reader frames. The visible call chain needs at least 70,016 bytes, so setupterm() deterministically crosses the 64 KiB boundary and corrupts __malloc_context before strdup() traps.

These are SDK memory-layout failures, not GTK or ncurses compatibility defects.

Fix

Reserve an 8 MiB main-thread shadow stack in both SDK compiler drivers:

  • sdk/src/lib/flags.ts for the TypeScript host SDK
  • sdk/kandelo/bin/wasm32posix-cc for the in-Kandelo SDK

The SDK treats 8 MiB as a floor. Smaller direct decimal -Wl,-z,stack-size=<bytes> requests are raised to 8 MiB, while larger requirements remain intact. Directly referenced linker response files are inspected for the equivalent lld option. In particular, SpiderMonkey's explicit 16 MiB request still produces a 16 MiB stack.

The native driver parses response files with ordinary file redirection and pure Bash matching; it does not depend on process substitution or FIFO support.

POSIX leaves the default stack size implementation-defined. 8 MiB matches the common Linux/glibc main-thread default. This affects only the initial thread; pthread stacks continue to use musl's separate default.

ABI

No ABI change. The host reads each module's exported __heap_base and __stack_pointer dynamically. Existing 64 KiB binaries and newly linked 8 MiB binaries remain compatible with the same kernel, and the ABI snapshot is unchanged.

Validation

Local validation on commit 2dc2115a6:

  • npm --prefix sdk test: 61 tests passed, including a new test that executes the packaged Bash driver with fake LLVM tools.
  • TypeScript and packaged Bash SDK real LLVM links both produced 8 MiB for default/1 MiB requests and retained 16 MiB for direct, zero-padded, and response-file requests.
  • ncurses source, libraries, and terminfo data relinked with the new SDK default: Kandelo read the on-disk screen-256color entry and returned colors=256.
  • bash scripts/check-abi-version.sh: snapshot and generated bindings remained in sync.
  • ShellCheck passed with the file's pre-existing SC2054 warning excluded; Bash 3 and Bash 5 syntax checks passed.
  • A final independent devil's-advocate review found no remaining blocker.

The previous head completed the full 68-package staging matrix and all test suites. The current rebased head intentionally triggers CI again for current package-build evidence.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

Phase B-1 matrix build status — pr-842-staging

ABI v16. 68 built, 0 failed, 68 total.

Package Arch Status Sha
libcurl wasm32 built 154948cf
libcxx wasm32 built b7260c81
libcxx wasm64 built 9af5a9bd
libpng wasm32 built 1a170103
libxml2 wasm32 built ffe2c666
libxml2 wasm64 built 7a8e8bfe
openssl wasm32 built ff4739c9
openssl wasm64 built 0fdc9f3d
sqlite wasm32 built 2c18c826
sqlite wasm64 built 6f78fe50
zlib wasm32 built ba5d2d85
zlib wasm64 built 273f9a3e
bc wasm32 built d3c11758
bzip2 wasm32 built b602bb54
coreutils wasm32 built f4e7516c
curl wasm32 built f1933aef
dash wasm32 built 21c59835
diffutils wasm32 built a526e448
dinit wasm32 built 9a9333b2
fbdoom wasm32 built 7d715c01
file wasm32 built 4ad59319
findutils wasm32 built f14d56e3
gawk wasm32 built 43167c9c
git wasm32 built 5c60cc86
grep wasm32 built 8e8ff483
gzip wasm32 built 7cac4250
hello wasm32 built 40e207ba
kandelo-sdk wasm32 built fc71b9ad
kernel wasm32 built f84a5360
less wasm32 built 39fdd424
lsof wasm32 built 40f59d6d
m4 wasm32 built 2b187906
make wasm32 built 85da34e9
mariadb wasm32 built 055dcb38
mariadb wasm64 built f0db5a84
modeset wasm32 built 0f57ba86
msmtpd wasm32 built 0d664123
nano wasm32 built 8b11aad8
ncurses wasm32 built 5eadfd4f
netcat wasm32 built f51e98ef
nginx wasm32 built c7fdf80a
php wasm32 built ed4b6e00
posix-utils-lite wasm32 built d4bb5dce
sed wasm32 built 0de52b10
spidermonkey wasm32 built 2bc880bf
tar wasm32 built 0131f136
tcl wasm32 built b6069068
unzip wasm32 built 3c3e994b
userspace wasm32 built 74cfbf92
vim wasm32 built a30fa39c
wget wasm32 built d1cfb3a9
xz wasm32 built 3fc3a70a
zip wasm32 built efc3b71e
zstd wasm32 built 07e6d0d4
bash wasm32 built 64270ef7
mariadb-test wasm32 built 8951e082
mariadb-vfs wasm32 built 76192e32
mariadb-vfs wasm64 built 07c2f83a
nethack wasm32 built 2ccbaf57
node wasm32 built fe3afc28
spidermonkey-node wasm32 built dbcbaf9a
vim-browser-bundle wasm32 built a66884fc
nethack-browser-bundle wasm32 built d598cdef
rootfs wasm32 built 90036c7c
shell wasm32 built 5388ab5f
lamp wasm32 built f930e23e
node-vfs wasm32 built 3fdb3c1a
wordpress wasm32 built 33fed5e9

Auto-generated; replaced on each push. Raw data in the publish-status workflow artifact.

@brandonpayton brandonpayton force-pushed the sdk/main-thread-stack-8mib branch from 977371e to cdff91f Compare July 11, 2026 00:17
wasm-ld's default shadow stack is only ~64 KiB, and WebAssembly has no stack
guard page, so a program that overflows it does not fault at the overflow:
__stack_pointer silently decrements past __data_end and overwrites the top of
.bss. On this platform that region holds the pthread/TLS globals
(__wasm_tp_storage -- the main thread's struct pthread -- and __pthread_tsd_main),
so an overflow corrupts thread-local storage and later surfaces as a spurious
"memory access out of bounds" in an unrelated function (e.g. __pthread_getspecific
or pthread_mutex_lock), far from the real fault. Deep call chains in ordinary
libraries hit this easily; GTK's gdk_pixbuf_new_from_file -> GObject type
registration -> glib chain is a confirmed case.

Reserve 8 MiB instead. POSIX leaves the default stack size implementation-defined
(RLIMIT_STACK governs the main thread), but 8 MiB is the de-facto Linux/glibc
default that mainstream C software is written and tested against, so matching it
maximizes portability. Because a wasm shadow stack cannot grow at runtime, the
reservation is fixed at link time. This sizes the main thread only (pthreads use
musl __default_stacksize, 128 KiB) and costs ~8 MiB of initial linear memory per
process (it raises __heap_base 1:1).

Added to both SDK cc implementations (bash wasm32posix-cc exe_link_flags and the
TS linkFlags()), kept in sync, plus a LINK_FLAGS test and a docs/sdk-guide.md
rationale. No ABI change: the host reads __heap_base/__stack_pointer dynamically
per module, so existing 64 KiB binaries and new 8 MiB binaries run on the same
kernel unchanged; the change touches only user-program link flags, not the
kernel, crates/shared, syscall numbers, or struct layouts.

Verified: linking the exact crashing gdk_pixbuf_new_from_file case with the new
default decodes the PNG (PASS 32x32) where the 64 KiB default traps; 1/8/16 MiB
all pass while 64 KiB fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant