From ebc86bf5031577e9462076cb7726f530ba3830d5 Mon Sep 17 00:00:00 2001
From: Claude <claude@anthropic.com>
Date: Thu, 23 Jul 2026 04:25:44 +0000
Subject: [PATCH] fix: give each worker thread its own shadow stack to stop
 cross-thread local corruption

The three web workers share one WebAssembly.Memory, but each wasm instance's
__stack_pointer global initialises to the same address, so all threads placed
their shadow stacks at the same location. In the debug build the loop counter
`n` in worker/sim.rs is spilled to that shared shadow stack, so the threads
clobbered each other's locals and numbers were skipped.

Export __stack_pointer (and the TLS symbols) from the wasm and, in sim.mjs,
point each worker at its own non-overlapping stack + TLS block before running.
Enlarge the shared memory to 64 pages to hold the per-thread stacks and add a
Node worker_threads regression test (worker/test_repro.mjs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---
 main.mjs                  |  14 ++++---
 worker/.cargo/config.toml |   2 +-
 worker/sim.mjs            |  25 +++++++++--
 worker/sim.wasm           | Bin 2371703 -> 2346448 bytes
 worker/test_repro.mjs     |  86 ++++++++++++++++++++++++++++++++++++++
 5 files changed, 118 insertions(+), 9 deletions(-)
 create mode 100644 worker/test_repro.mjs

diff --git a/main.mjs b/main.mjs
index 881222b..24a1e3a 100644
--- a/main.mjs
+++ b/main.mjs
@@ -1,9 +1,11 @@
 //Let's count to 300. We'll have three web workers, each taking ⅓rd of the task. 0-100, 100-200, 200-300...
 
 //First, allocate some shared memory. (The original task wants to share some values around.)
+//64 pages (4 MiB) leaves room for the module's static data/heap plus a separate
+//shadow-stack + TLS block per worker thread (see worker/sim.mjs for the layout).
 const memory = new WebAssembly.Memory({
-	initial: 23,
-	maximum: 23,
+	initial: 64,
+	maximum: 64,
 	shared: true,
 })
 
@@ -20,9 +22,11 @@ const startAWorkerCore = coreIndex => {
 }
 
 //Now, let's start some worker threads! They will work on different memory locations, so they don't conflict.
-startAWorkerCore(0) //works fine
-startAWorkerCore(1) //breaks counting - COMMENT THIS OUT TO FIX COUNTING
-startAWorkerCore(2) //breaks counting - COMMENT THIS OUT TO FIX COUNTING
+//Each worker now gets its own shadow stack + TLS block (see worker/sim.mjs), so all
+//three count their own range concurrently without clobbering each other's locals.
+startAWorkerCore(0)
+startAWorkerCore(1)
+startAWorkerCore(2)
 
 
 //Run the simulation thrice. Each thread should print a hundred numbers in order, thrice.
diff --git a/worker/.cargo/config.toml b/worker/.cargo/config.toml
index de9fb5d..389b073 100644
--- a/worker/.cargo/config.toml
+++ b/worker/.cargo/config.toml
@@ -1,6 +1,6 @@
 [target.wasm32-unknown-unknown]
 rustflags = [
     "-C", "target-feature=+atomics,+mutable-globals,+bulk-memory",
-    "-C", "link-args=--no-entry --shared-memory --import-memory --max-memory=2130706432",
+    "-C", "link-args=--no-entry --shared-memory --import-memory --max-memory=2130706432 --export=__stack_pointer --export=__wasm_init_tls --export=__tls_size --export=__tls_base --export=__tls_align",
 ]
 # RUSTFLAGS='--codegen target-feature=+atomics,+mutable-globals,+bulk-memory --codegen link-args=--no-entry --enable-threads --shared-memory --import-memory --max-memory=2130706432'
\ No newline at end of file
diff --git a/worker/sim.mjs b/worker/sim.mjs
index c9363be..3ca4169 100644
--- a/worker/sim.mjs
+++ b/worker/sim.mjs
@@ -20,14 +20,33 @@ self.start = async (workerID, worldBackingBuffer, world) => {
 		},
 	})
 	
-	//Initialise thread-local storage, so we get separate stacks for our local variables.
-	wasm.instance.exports.__wasm_init_tls(workerID-1)
+	//Each worker runs on its own thread but shares ONE linear memory. The wasm module's
+	//shadow stack (where debug locals like the loop counter `n` get spilled) and its
+	//thread-local storage both live IN that shared memory, addressed by the __stack_pointer
+	//and __tls_base globals. Those globals initialise to the SAME value in every instance,
+	//so without the setup below all three threads pile their stacks on top of each other and
+	//clobber each other's locals. Give each worker its own non-overlapping stack + TLS block.
+	const exports = wasm.instance.exports
+	const workerIndex = workerID - 1
+
+	const REGION_BASE = 2 * 1024 * 1024 //Above the module's static data, heap base, and world.globalTick.
+	const STACK_SIZE  = 512 * 1024      //Plenty for this program; grows downward from the top of the block.
+	const align = n => (n + 15) & ~15
+	const tlsSize = align(exports.__tls_size.value) //0 here (no #[thread_local]s), but honour it for correctness.
+	const blockSize = tlsSize + STACK_SIZE
+	const blockBase = REGION_BASE + workerIndex * blockSize
+
+	//TLS sits at the bottom of the block; the shadow stack occupies the rest and grows down from the top.
+	//Set the private shadow stack FIRST, before any wasm call (including TLS init), so the
+	//very first function entry runs on this worker's own stack rather than the shared one.
+	exports.__stack_pointer.value = blockBase + blockSize
+	exports.__wasm_init_tls(blockBase)
 	
 	//Loop, running the Rust logging loop when the "tick" advances.
 	let lastProcessedTick = 0
 	while (1) {
 		Atomics.wait(world.globalTick, 0, lastProcessedTick)
 		lastProcessedTick = world.globalTick[0]
-		wasm.instance.exports.run(workerID)
+		exports.run(workerID)
 	}
 }
\ No newline at end of file
diff --git a/worker/sim.wasm b/worker/sim.wasm
index fae8200b40fa90edb7963836a62bda0d2e498e2b..28f942d5c704d44b48bdd0f817e688566ba18621 100755
GIT binary patch
diff --git a/worker/test_repro.mjs b/worker/test_repro.mjs
new file mode 100644
index 0000000..93c5d37
--- /dev/null
+++ b/worker/test_repro.mjs
@@ -0,0 +1,86 @@
+// Headless regression test for the shared-shadow-stack multithreading bug.
+//
+// Mirrors the browser reproduction (main.mjs + sim.mjs) using Node worker_threads:
+// three threads share ONE WebAssembly.Memory and each runs the wasm `run()` loop,
+// which logs the numbers in its 100-wide chunk. Every number in 0..299 must be
+// reported exactly `TICKS` times. When the threads share a shadow stack the debug
+// build's loop counter `n` gets clobbered across threads and numbers go missing.
+//
+//   MODE=fixed  (default) applies the per-thread stack + TLS setup  -> must pass
+//   MODE=broken replicates the old buggy setup                      -> expected to fail
+//
+// Usage: node worker/test_repro.mjs   (run from repo root or worker/)
+
+import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+const WORKERS = 3
+const RANGE = 300
+const TICKS = 500                 // repeat the loop many times to widen the race window
+const MODE = process.env.MODE || 'fixed'
+const wasmPath = fileURLToPath(new URL('./sim.wasm', import.meta.url))
+
+if (isMainThread) {
+	// 64 pages, matching main.mjs. Shared so all threads see the same linear memory.
+	const memory = new WebAssembly.Memory({ initial: 64, maximum: 64, shared: true })
+	// Independent shared histogram: hist[n] counts how many times `n` was logged.
+	const hist = new Int32Array(new SharedArrayBuffer(RANGE * 4))
+
+	const results = await Promise.all(
+		Array.from({ length: WORKERS }, (_, i) =>
+			new Promise((resolve, reject) => {
+				const w = new Worker(fileURLToPath(import.meta.url), {
+					workerData: { workerID: i + 1, memory, hist, wasmPath, mode: MODE, ticks: TICKS },
+				})
+				w.once('message', resolve)
+				w.once('error', reject)
+			})
+		)
+	)
+
+	let missing = 0, wrong = 0
+	for (let n = 0; n < RANGE; n++) {
+		if (hist[n] === 0) missing++
+		else if (hist[n] !== TICKS) wrong++
+	}
+
+	console.log(`MODE=${MODE} workers=${WORKERS} ticks=${TICKS}`)
+	console.log(`numbers never logged: ${missing} / ${RANGE}`)
+	console.log(`numbers logged the wrong number of times: ${wrong} / ${RANGE}`)
+	const ok = missing === 0 && wrong === 0
+	console.log(ok ? 'PASS: every number 0..299 was logged exactly TICKS times.'
+	              : 'FAIL: shadow-stack corruption skipped or duplicated numbers.')
+	process.exit(ok ? 0 : 1)
+} else {
+	const { workerID, memory, hist, wasmPath, mode, ticks } = workerData
+	const bytes = readFileSync(wasmPath)
+	const wasm = await WebAssembly.instantiate(bytes, {
+		env: { memory },
+		imports: {
+			abort: () => { throw new Error(`abort in thread ${workerID}`) },
+			_log_num: num => { Atomics.add(hist, num, 1) },
+		},
+	})
+	const exports = wasm.instance.exports
+
+	if (mode === 'broken') {
+		// Old behaviour: bogus TLS init, no per-thread stack -> all threads share one stack.
+		exports.__wasm_init_tls(workerID - 1)
+	} else {
+		// The fix (see sim.mjs): give each thread its own non-overlapping stack + TLS block.
+		// Set the private stack FIRST, before any wasm call (incl. TLS init).
+		const workerIndex = workerID - 1
+		const REGION_BASE = 2 * 1024 * 1024
+		const STACK_SIZE = 512 * 1024
+		const align = n => (n + 15) & ~15
+		const tlsSize = align(exports.__tls_size.value)
+		const blockSize = tlsSize + STACK_SIZE
+		const blockBase = REGION_BASE + workerIndex * blockSize
+		exports.__stack_pointer.value = blockBase + blockSize
+		exports.__wasm_init_tls(blockBase)
+	}
+
+	for (let t = 0; t < ticks; t++) exports.run(workerID)
+	parentPort.postMessage({ workerID, done: true })
+}
-- 
2.53.0

