Skip to content

Instantly share code, notes, and snippets.

@adamziel
Last active January 23, 2026 22:57
Show Gist options
  • Select an option

  • Save adamziel/ddc75b5affdc6367329df4c6e2c92186 to your computer and use it in GitHub Desktop.

Select an option

Save adamziel/ddc75b5affdc6367329df4c6e2c92186 to your computer and use it in GitHub Desktop.
memoryleak.md

Bun vs Node.js WebAssembly Memory Issue Test Results

Test Environment:

  • Bun version: 1.2.13 (JavaScriptCore)
  • Node.js version: v22.20.0 (V8)
  • Platform: Linux (NixOS)
  • Date: 2026-01-23

Executive Summary

Testing reveals significant memory management differences between Bun (JSC) and Node.js (V8):

Metric Bun (JSC) Node.js (V8) Difference
SharedArrayBuffer leak 6.4 KB/iter 0 KB/iter V8 has no leak
Table leak 84 KB/iter 6 KB/iter 14x worse in JSC
Memory growth test (shared) +38.76 MB -56 KB JSC leaks, V8 reclaims
Memory growth test (non-shared) +14.50 MB -1.82 MB JSC leaks, V8 reclaims

Conclusion: V8 (Node.js) properly reclaims WASM memory while JSC (Bun/Safari) shows persistent memory accumulation, confirming the Safari WebAssembly memory issues exist in the JSC engine itself.


Test 1: SharedArrayBuffer Memory Creation

Results Comparison

Runtime All Tests Passed RSS Change Heap Change
Bun 1.2.13 ✓ Yes +17.18 MB +112 MB
Node.js v22 ✓ Yes +0.55 MB -1.22 MB

Both runtimes successfully allocate all SharedArrayBuffer sizes including 2GB maximum. However, Node.js shows minimal memory impact while Bun retains significant memory after tests complete.

Allocation Performance

Test Case Bun Create Node Create Bun Grow Node Grow
Small shared (16MB/256MB) 0.06ms 0.07ms 10.23ms 0.01ms
Very large shared (~200MB/2GB) 0.97ms 0.03ms 9.20ms 0.01ms

Key Finding: Node.js memory growth operations are ~1000x faster for shared memory.


Test 2: Memory Growth Stress Test

Non-Shared Memory (50 iterations)

Runtime Initial RSS Final RSS Total Change Per Iteration Leak Status
Bun 1.2.13 33.48 MB 47.98 MB +14.50 MB 297 KB ⚠ LEAKING
Node.js v22 109.32 MB 107.50 MB -1.82 MB -36 KB ✓ OK

Shared Memory (50 iterations)

Runtime Initial RSS Final RSS Total Change Per Iteration Leak Status
Bun 1.2.13 48.73 MB 87.48 MB +38.76 MB 794 KB ⚠ LEAKING
Node.js v22 107.50 MB 107.45 MB -56 KB -1 KB ✓ OK

Key Finding: V8 properly garbage collects WASM memory (RSS actually decreases), while JSC accumulates memory with each cycle. This directly correlates with Safari's page-reload memory issues.


Test 4: Memory Leak Detection (100 iterations)

WASM with Table (setTable leak test)

Runtime Total Growth Leak/Iteration Trend Slope Status
Bun 1.2.13 8.20 MB 84 KB 52.59 KB/iter ⚠ LEAKING
Node.js v22 600 KB 6 KB 708 bytes/iter ⚠ Minor leak

Ratio: JSC leaks 14x more memory per iteration than V8 for table operations.

WASM with Non-Shared Memory

Runtime Total Growth Leak/Iteration Trend Slope Status
Bun 1.2.13 1.34 MB 13.76 KB 5.03 KB/iter ⚠ LEAKING
Node.js v22 256 KB 2.56 KB 1.03 KB/iter ⚠ Minor leak

Ratio: JSC leaks 5x more memory than V8.

WASM with Shared Memory

Runtime Total Growth Leak/Iteration Trend Slope Status
Bun 1.2.13 640 KB 6.40 KB 8.45 KB/iter ⚠ LEAKING
Node.js v22 0 bytes 0 bytes 0 bytes/iter ✓ OK

Key Finding: V8 shows zero memory leak for shared memory WASM, while JSC continues to accumulate. This is the most significant difference and explains why Safari has SharedArrayBuffer memory issues.


Analysis: Why JSC Leaks

Based on the test results and WebKit bug reports:

1. setTable Reference Leak (WebKit Bug #285766)

  • JSC's JSWebAssemblyInstance::setTable stores Wasm::Table references but doesn't properly release them
  • Test Result: 14x worse in JSC vs V8
  • Fixed in Safari 18.3 but not backported

2. SharedArrayBuffer Memory Mode

  • JSC uses MemoryMode::Signaling which allocates full maximum capacity upfront as protected pages
  • Memory cleanup relies on reference counting that appears to fail in certain scenarios
  • Test Result: V8 shows zero leak, JSC shows consistent accumulation

3. GC Behavior Differences

  • V8's garbage collector aggressively reclaims WASM memory after instances go out of scope
  • JSC's GC appears to retain references longer, especially for SharedArrayBuffer-backed memory
  • Test Result: V8 RSS decreased during stress tests, JSC RSS increased

Test 3: Worker Visibility (Bun Issue #25677)

Result: NOT REPRODUCED in either runtime

Both WASM and JS writes to SharedArrayBuffer are visible to workers in both Bun 1.2.13 and Node.js v22.20.0. This suggests the issue was either:

  • Fixed in recent versions
  • Platform-specific
  • Timing-dependent

Practical Implications

For Bun/Safari Users:

  1. Avoid repeated WASM instantiation - memory will accumulate
  2. Prefer non-shared memory when possible (lower leak rate)
  3. Minimize table usage in WASM modules (highest leak rate)
  4. Upgrade to Safari 18.3+ for the setTable fix
  5. Consider 256MB memory limits on iOS Safari

For Node.js Users:

  1. V8 handles WASM memory correctly - no special precautions needed
  2. Even shared memory with large allocations is properly reclaimed

For Cross-Platform Apps:

  1. Test WASM memory behavior on both engines
  2. Consider implementing memory limits for Safari/JSC environments
  3. Use non-shared memory fallback when SharedArrayBuffer causes issues

Reproduction Commands

# Run with Bun (JSC)
bun run test-shared-memory.ts
bun run test-memory-growth.ts
bun run test-memory-leak.ts

# Run with Node.js (V8) - requires --expose-gc for accurate results
npx tsx --expose-gc test-shared-memory.ts
npx tsx --expose-gc test-memory-growth.ts
npx tsx --expose-gc test-memory-leak.ts

Related Bug Reports


Raw Data Summary

Test 1: SharedArrayBuffer Allocation

Bun:    RSS +17.18 MB, Heap +112.10 MB
Node:   RSS +0.55 MB,  Heap -1.22 MB

Test 2: Memory Growth (50 cycles)

Bun Non-shared:  +14.50 MB (297 KB/iter) - LEAKING
Bun Shared:      +38.76 MB (794 KB/iter) - LEAKING
Node Non-shared: -1.82 MB  (-36 KB/iter) - OK
Node Shared:     -56 KB    (-1 KB/iter)  - OK

Test 4: Instance Leak (100 cycles)

Bun Table:       +8.20 MB  (84 KB/iter)   - LEAKING
Bun Non-shared:  +1.34 MB  (13.76 KB/iter) - LEAKING
Bun Shared:      +640 KB   (6.40 KB/iter)  - LEAKING
Node Table:      +600 KB   (6 KB/iter)     - Minor leak
Node Non-shared: +256 KB   (2.56 KB/iter)  - Minor leak
Node Shared:     0 bytes   (0 KB/iter)     - OK
/**
* Runtime compatibility layer for Bun/Node.js
*/
// Detect runtime
export const isBun = typeof Bun !== "undefined";
export const isNode = !isBun && typeof process !== "undefined";
// Force garbage collection
export function forceGC(): void {
if (isBun) {
(globalThis as any).Bun.gc(true);
} else if (typeof (globalThis as any).gc === "function") {
(globalThis as any).gc();
}
// If gc() not available, do nothing
}
// Get runtime name and version
export function getRuntimeInfo(): string {
if (isBun) {
return `Bun ${(globalThis as any).Bun.version}`;
} else if (isNode) {
return `Node.js ${process.version}`;
}
return "Unknown runtime";
}
// Format bytes helper
export function formatBytes(bytes: number): string {
if (bytes < 0) {
return `-${formatBytes(-bytes)}`;
}
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
} else if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
} else if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
}
return `${bytes} bytes`;
}
/**
* Test 2: Memory Growth Stress Test
*
* Creates and grows WebAssembly.Memory repeatedly to test for leaks.
* Monitors heap statistics to detect memory accumulation.
*
* Related bugs:
* - WebKit Bug #222097: Memory not freed on reload
* - WebKit Bug #285766: JSWebAssemblyInstance::setTable leak
*/
import { forceGC, getRuntimeInfo, formatBytes } from "./runtime-compat";
console.log("=".repeat(60));
console.log("Test 2: Memory Growth Stress Test");
console.log(`Runtime: ${getRuntimeInfo()}`);
console.log("=".repeat(60));
interface GrowthTestResult {
iteration: number;
rss: number;
heapUsed: number;
success: boolean;
error?: string;
}
async function testGrowthCycle(
iteration: number,
shared: boolean
): Promise<GrowthTestResult> {
try {
// Create memory with moderate initial size
const memory = new WebAssembly.Memory({
initial: 64, // 4MB
maximum: 1024, // 64MB
shared: shared,
});
// Grow in steps
for (let i = 0; i < 10; i++) {
memory.grow(64); // Grow by 4MB each time
}
// Write some data to ensure memory is actually used
const view = new Int32Array(memory.buffer);
for (let i = 0; i < 1000; i++) {
view[i * 1000] = i;
}
// Verify data
let sum = 0;
for (let i = 0; i < 1000; i++) {
sum += view[i * 1000];
}
if (sum !== 499500) {
throw new Error(`Data verification failed: expected 499500, got ${sum}`);
}
// Memory should go out of scope here
} catch (error: any) {
const memAfter = process.memoryUsage();
return {
iteration,
rss: memAfter.rss,
heapUsed: memAfter.heapUsed,
success: false,
error: `${error.name}: ${error.message}`,
};
}
// Force GC
forceGC();
const memAfter = process.memoryUsage();
return {
iteration,
rss: memAfter.rss,
heapUsed: memAfter.heapUsed,
success: true,
};
}
async function runGrowthStressTest(shared: boolean, iterations: number) {
console.log(`\n--- Testing ${shared ? "Shared" : "Non-shared"} Memory (${iterations} iterations) ---`);
const results: GrowthTestResult[] = [];
const initialMem = process.memoryUsage();
console.log(`Initial RSS: ${formatBytes(initialMem.rss)}, heap: ${formatBytes(initialMem.heapUsed)}`);
for (let i = 0; i < iterations; i++) {
const result = await testGrowthCycle(i + 1, shared);
results.push(result);
if (!result.success) {
console.log(` Iteration ${i + 1}: FAILED - ${result.error}`);
break;
}
// Log every 10 iterations or if there's significant growth
if ((i + 1) % 10 === 0 || i === 0) {
const rssGrowth = result.rss - initialMem.rss;
console.log(
` Iteration ${i + 1}: RSS=${formatBytes(result.rss)} ` +
`(+${formatBytes(rssGrowth)} from start)`
);
}
}
// Analyze results
const finalResult = results[results.length - 1];
const rssGrowth = finalResult.rss - initialMem.rss;
const successCount = results.filter(r => r.success).length;
console.log(`\nResults for ${shared ? "Shared" : "Non-shared"}:`);
console.log(` Successful iterations: ${successCount}/${iterations}`);
console.log(` Initial RSS: ${formatBytes(initialMem.rss)}`);
console.log(` Final RSS: ${formatBytes(finalResult.rss)}`);
console.log(` Total growth: ${formatBytes(rssGrowth)}`);
// Detect potential leak
const actualGrowthPerIteration = rssGrowth / successCount;
if (actualGrowthPerIteration > 100 * 1024) { // More than 100KB per iteration
console.log(` ⚠ WARNING: Potential memory leak detected!`);
console.log(` Average growth per iteration: ${formatBytes(actualGrowthPerIteration)}`);
} else {
console.log(` ✓ No significant memory leak detected`);
}
return results;
}
async function main() {
const iterations = 50;
console.log(`Running ${iterations} create/grow/destroy cycles for each mode...\n`);
// Test non-shared first (baseline)
await runGrowthStressTest(false, iterations);
// Force GC between tests
forceGC();
await new Promise(resolve => setTimeout(resolve, 100));
// Test shared memory
await runGrowthStressTest(true, iterations);
// Final cleanup and comparison
forceGC();
const finalMem = process.memoryUsage();
console.log("\n" + "=".repeat(60));
console.log("Final State");
console.log("=".repeat(60));
console.log(`Final RSS: ${formatBytes(finalMem.rss)}`);
console.log(`Final heap used: ${formatBytes(finalMem.heapUsed)}`);
}
main().catch(console.error);
/**
* Test 4: Memory Leak Detection
*
* Creates and destroys WASM instances in a loop, monitoring memory
* via process.memoryUsage() to detect accumulating leaks.
*
* This test simulates what happens in Safari when a page with WASM
* is repeatedly reloaded - the JSWebAssemblyInstance::setTable leak.
*
* Related bugs:
* - WebKit Bug #285766: JSWebAssemblyInstance::setTable leak
* - WebKit Bug #250569: Terminated worker memory leak
*/
import { forceGC, getRuntimeInfo, formatBytes } from "./runtime-compat";
console.log("=".repeat(60));
console.log("Test 4: Memory Leak Detection");
console.log(`Runtime: ${getRuntimeInfo()}`);
console.log("=".repeat(60));
interface MemorySnapshot {
iteration: number;
rss: number;
heapUsed: number;
}
// Minimal WASM module with a table (to trigger setTable leak)
// Compiled from test-with-table.wat
const WASM_WITH_TABLE = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60,
0x00, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x04, 0x05, 0x01, 0x70, 0x01,
0x01, 0x0a, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x0f, 0x02, 0x03, 0x66,
0x6f, 0x6f, 0x00, 0x00, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x01, 0x00,
0x09, 0x07, 0x01, 0x00, 0x41, 0x00, 0x0b, 0x01, 0x00, 0x0a, 0x06, 0x01,
0x04, 0x00, 0x41, 0x2a, 0x0b
]);
async function createWasmInstanceWithTable(): Promise<WebAssembly.Instance> {
const module = await WebAssembly.compile(WASM_WITH_TABLE);
return await WebAssembly.instantiate(module);
}
// Non-shared memory WASM module (compiled from test-import-mem.wat)
const WASM_IMPORT_MEM = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60,
0x00, 0x01, 0x7f, 0x02, 0x0c, 0x01, 0x03, 0x65, 0x6e, 0x76, 0x03, 0x6d,
0x65, 0x6d, 0x02, 0x00, 0x01, 0x03, 0x02, 0x01, 0x00, 0x07, 0x08, 0x01,
0x04, 0x6c, 0x6f, 0x61, 0x64, 0x00, 0x00, 0x0a, 0x09, 0x01, 0x07, 0x00,
0x41, 0x00, 0x28, 0x02, 0x00, 0x0b
]);
// Shared memory WASM module (compiled from test-import-shared-mem.wat)
const WASM_IMPORT_SHARED_MEM = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60,
0x00, 0x01, 0x7f, 0x02, 0x0e, 0x01, 0x03, 0x65, 0x6e, 0x76, 0x03, 0x6d,
0x65, 0x6d, 0x02, 0x03, 0x01, 0x80, 0x02, 0x03, 0x02, 0x01, 0x00, 0x07,
0x08, 0x01, 0x04, 0x6c, 0x6f, 0x61, 0x64, 0x00, 0x00, 0x0a, 0x0a, 0x01,
0x08, 0x00, 0x41, 0x00, 0xfe, 0x10, 0x02, 0x00, 0x0b
]);
async function createWasmInstanceWithMemory(shared: boolean): Promise<{
instance: WebAssembly.Instance;
memory: WebAssembly.Memory;
}> {
const memory = new WebAssembly.Memory({
initial: 64, // 4MB
maximum: 256, // 16MB
shared: shared,
});
const wasmBytes = shared ? WASM_IMPORT_SHARED_MEM : WASM_IMPORT_MEM;
const module = await WebAssembly.compile(wasmBytes);
const instance = await WebAssembly.instantiate(module, {
env: { mem: memory }
});
return { instance, memory };
}
async function runLeakTest(
testName: string,
iterations: number,
createInstance: () => Promise<any>
): Promise<MemorySnapshot[]> {
console.log(`\n--- ${testName} ---`);
const snapshots: MemorySnapshot[] = [];
// Initial GC and baseline
forceGC();
await new Promise(r => setTimeout(r, 50));
const baseline = process.memoryUsage();
console.log(`Baseline RSS: ${formatBytes(baseline.rss)}, heap: ${formatBytes(baseline.heapUsed)}`);
for (let i = 0; i < iterations; i++) {
// Create instance (should be GC'd after going out of scope)
const result = await createInstance();
// Use the instance briefly to ensure it's not optimized away
if (result?.instance?.exports?.foo) {
(result.instance.exports.foo as Function)();
} else if (result?.exports?.foo) {
(result.exports.foo as Function)();
}
// Explicit GC
forceGC();
// Small delay to allow cleanup
if (i % 10 === 0) {
await new Promise(r => setTimeout(r, 10));
}
// Snapshot memory
const mem = process.memoryUsage();
snapshots.push({
iteration: i + 1,
rss: mem.rss,
heapUsed: mem.heapUsed,
});
// Log progress
if ((i + 1) % 20 === 0 || i === 0 || i === iterations - 1) {
const growth = mem.rss - baseline.rss;
console.log(
` Iteration ${i + 1}: RSS=${formatBytes(mem.rss)} ` +
`(+${formatBytes(growth)} from baseline)`
);
}
}
// Final GC
forceGC();
await new Promise(r => setTimeout(r, 100));
const finalMem = process.memoryUsage();
const totalGrowth = finalMem.rss - baseline.rss;
const avgGrowth = totalGrowth / iterations;
console.log(`\n Summary:`);
console.log(` Final RSS: ${formatBytes(finalMem.rss)}`);
console.log(` Total growth: ${formatBytes(totalGrowth)}`);
console.log(` Avg per iter: ${formatBytes(avgGrowth)}`);
// Detect leak
if (avgGrowth > 1024) { // More than 1KB per iteration
console.log(` ⚠ POTENTIAL LEAK: ~${formatBytes(avgGrowth)} per instance`);
} else {
console.log(` ✓ No significant leak detected`);
}
return snapshots;
}
async function main() {
const iterations = 100;
console.log(`Running ${iterations} create/destroy cycles for each test...\n`);
// Test 1: WASM with Table (triggers setTable)
const tableResults = await runLeakTest(
"WASM with Table (setTable leak test)",
iterations,
createWasmInstanceWithTable
);
// Wait between tests
forceGC();
await new Promise(r => setTimeout(r, 200));
// Test 2: WASM with non-shared memory
const nonSharedResults = await runLeakTest(
"WASM with Non-shared Memory",
iterations,
() => createWasmInstanceWithMemory(false)
);
// Wait between tests
forceGC();
await new Promise(r => setTimeout(r, 200));
// Test 3: WASM with shared memory
const sharedResults = await runLeakTest(
"WASM with Shared Memory",
iterations,
() => createWasmInstanceWithMemory(true)
);
// Final analysis
console.log("\n" + "=".repeat(60));
console.log("Final Analysis");
console.log("=".repeat(60));
const analyzeResults = (name: string, results: MemorySnapshot[]) => {
const first = results[0];
const last = results[results.length - 1];
const growth = last.rss - first.rss;
const leakPerIter = growth / results.length;
// Calculate trend (linear regression)
const n = results.length;
const sumX = (n * (n - 1)) / 2;
const sumY = results.reduce((acc, r) => acc + r.rss, 0);
const sumXY = results.reduce((acc, r, i) => acc + i * r.rss, 0);
const sumXX = (n * (n - 1) * (2 * n - 1)) / 6;
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
console.log(`\n${name}:`);
console.log(` Total growth: ${formatBytes(growth)}`);
console.log(` Leak/iter: ${formatBytes(leakPerIter)}`);
console.log(` Trend slope: ${formatBytes(slope)}/iter`);
if (slope > 500) {
console.log(` Status: ⚠ LEAKING`);
} else {
console.log(` Status: ✓ OK`);
}
};
analyzeResults("Table test", tableResults);
analyzeResults("Non-shared memory", nonSharedResults);
analyzeResults("Shared memory", sharedResults);
// Final memory state
forceGC();
const finalMem = process.memoryUsage();
console.log(`\nFinal RSS: ${formatBytes(finalMem.rss)}`);
}
main().catch(error => {
console.error("Test failed:", error);
process.exit(1);
});
/**
* Test 1: Basic SharedArrayBuffer Memory Creation
*
* Tests whether SharedArrayBuffer-backed WebAssembly.Memory can be created
* with various sizes, including large maximum values.
*
* Related bugs:
* - WebKit Bug #255103: WASM OOM with shared=true (iOS 16.4+)
* - WebKit Bug #222097: Memory not freed on reload
*/
import { forceGC, getRuntimeInfo } from "./runtime-compat";
console.log("=".repeat(60));
console.log("Test 1: SharedArrayBuffer Memory Creation");
console.log(`Runtime: ${getRuntimeInfo()}`);
console.log("=".repeat(60));
interface TestCase {
name: string;
initial: number; // in pages (64KB each)
maximum: number;
shared: boolean;
}
const testCases: TestCase[] = [
// Small shared memory (should work everywhere)
{ name: "Small shared (16MB/256MB)", initial: 256, maximum: 4096, shared: true },
// Medium shared memory
{ name: "Medium shared (16MB/512MB)", initial: 256, maximum: 8192, shared: true },
// Large shared memory (problematic on iOS Safari)
{ name: "Large shared (16MB/1GB)", initial: 256, maximum: 16384, shared: true },
// Very large shared memory (mirrors iOS Safari issue #255103)
{ name: "Very large shared (~200MB/2GB)", initial: 3134, maximum: 32768, shared: true },
// Non-shared equivalents for comparison
{ name: "Small non-shared (16MB/256MB)", initial: 256, maximum: 4096, shared: false },
{ name: "Large non-shared (16MB/2GB)", initial: 256, maximum: 32768, shared: false },
];
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
} else if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
} else if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
}
return `${bytes} bytes`;
}
async function runTest(test: TestCase): Promise<void> {
const initialBytes = test.initial * 65536;
const maxBytes = test.maximum * 65536;
console.log(`\n[TEST] ${test.name}`);
console.log(` Initial: ${test.initial} pages (${formatBytes(initialBytes)})`);
console.log(` Maximum: ${test.maximum} pages (${formatBytes(maxBytes)})`);
console.log(` Shared: ${test.shared}`);
try {
const startTime = performance.now();
const memory = new WebAssembly.Memory({
initial: test.initial,
maximum: test.maximum,
shared: test.shared,
});
const createTime = performance.now() - startTime;
console.log(` ✓ Created in ${createTime.toFixed(2)}ms`);
console.log(` Buffer type: ${memory.buffer.constructor.name}`);
console.log(` Buffer size: ${formatBytes(memory.buffer.byteLength)}`);
// Verify it's actually a SharedArrayBuffer when shared=true
if (test.shared) {
const isShared = memory.buffer instanceof SharedArrayBuffer;
console.log(` Is SharedArrayBuffer: ${isShared}`);
if (!isShared) {
console.log(" ⚠ WARNING: Expected SharedArrayBuffer but got ArrayBuffer");
}
}
// Test basic read/write
const view = new Int32Array(memory.buffer);
view[0] = 12345;
const readBack = view[0];
console.log(` Basic read/write: ${readBack === 12345 ? "✓ Pass" : "✗ Fail"}`);
// Test memory growth
const growPages = Math.min(256, test.maximum - test.initial);
if (growPages > 0) {
const growStart = performance.now();
const oldSize = memory.grow(growPages);
const growTime = performance.now() - growStart;
console.log(` Growth: ${oldSize} -> ${oldSize + growPages} pages (${growTime.toFixed(2)}ms)`);
console.log(` New buffer size: ${formatBytes(memory.buffer.byteLength)}`);
}
console.log(" [RESULT] ✓ SUCCESS");
} catch (error: any) {
console.log(` [RESULT] ✗ FAILED: ${error.name}: ${error.message}`);
// Check for specific OOM errors
if (error.name === "RangeError" && error.message.includes("memory")) {
console.log(" [NOTE] This matches Safari's SharedArrayBuffer OOM behavior");
}
}
}
async function main() {
// Get memory stats before tests
const memBefore = process.memoryUsage();
console.log(`\nInitial RSS: ${formatBytes(memBefore.rss)}`);
console.log(`Initial heap used: ${formatBytes(memBefore.heapUsed)}`);
for (const test of testCases) {
await runTest(test);
}
// Force GC and check memory after
forceGC();
const memAfter = process.memoryUsage();
console.log("\n" + "=".repeat(60));
console.log("Summary");
console.log("=".repeat(60));
console.log(`RSS before: ${formatBytes(memBefore.rss)}`);
console.log(`RSS after: ${formatBytes(memAfter.rss)}`);
console.log(`Heap used before: ${formatBytes(memBefore.heapUsed)}`);
console.log(`Heap used after: ${formatBytes(memAfter.heapUsed)}`);
}
main().catch(console.error);
/**
* Test 3: Worker Visibility Test
*
* Reproduces Bun issue #25677 - WASM writes to SharedArrayBuffer
* not being visible to workers, while JS writes ARE visible.
*
* The test:
* 1. Creates SharedArrayBuffer-backed WebAssembly.Memory
* 2. WASM writes a value to the buffer
* 3. JS writes a different value to a different offset
* 4. Worker reads both values
* 5. Compares: JS write should be visible, WASM write might not be
*
* Related bugs:
* - Bun Issue #25677: WASM writes not visible to workers
*/
import { readFileSync } from "fs";
console.log("=".repeat(60));
console.log("Test 3: Worker Visibility Test (Bun Issue #25677)");
console.log("=".repeat(60));
const WASM_OFFSET = 0; // Where WASM writes
const JS_OFFSET = 1024; // Where JS writes
const WASM_VALUE = 0xDEAD; // Value WASM writes
const JS_VALUE = 0xBEEF; // Value JS writes
interface WorkerResponse {
type: "result";
offset: number;
actualValue: number;
expectedValue?: number;
matches: boolean;
source: "worker";
}
async function loadWasmModule(): Promise<WebAssembly.Module> {
// Try to load pre-compiled WASM, fall back to inline module
try {
const wasmBytes = readFileSync("./test.wasm");
return await WebAssembly.compile(wasmBytes);
} catch {
// Create a minimal inline WASM module for testing
// This module just imports memory and provides atomic store/load
console.log("Note: Using inline WASM module (test.wasm not found)");
const wasmBytes = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // WASM magic + version
0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x00, // Type section: (i32, i32) -> void
0x02, 0x0d, 0x01, 0x03, 0x65, 0x6e, 0x76, 0x06, // Import section: env.memory
0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x03, // shared memory import
0x01, 0x00, 0x10, // limits: shared, min=1, max=16
0x03, 0x02, 0x01, 0x00, // Function section: 1 function of type 0
0x07, 0x0f, 0x01, 0x0b, 0x77, 0x72, 0x69, 0x74, // Export section: "write_value"
0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x00,
0x00,
0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, // Code section: i32.store
0x01, 0x36, 0x02, 0x00, 0x0b,
]);
return await WebAssembly.compile(wasmBytes);
}
}
async function runTest() {
console.log("\n1. Creating shared memory...");
const memory = new WebAssembly.Memory({
initial: 256,
maximum: 1024,
shared: true,
});
console.log(` Memory created: ${memory.buffer.byteLength} bytes`);
console.log(` Is SharedArrayBuffer: ${memory.buffer instanceof SharedArrayBuffer}`);
if (!(memory.buffer instanceof SharedArrayBuffer)) {
console.log(" ✗ FAILED: Memory.buffer is not a SharedArrayBuffer");
console.log(" This may indicate SharedArrayBuffer is disabled in this environment");
return;
}
// Get views for both WASM and JS operations
const view = new Int32Array(memory.buffer);
console.log("\n2. Loading WASM module...");
let wasmExports: any;
try {
const module = await loadWasmModule();
const instance = await WebAssembly.instantiate(module, {
env: { memory }
});
wasmExports = instance.exports;
console.log(" Module loaded successfully");
} catch (error: any) {
console.log(` ✗ FAILED to load WASM: ${error.message}`);
// Continue with JS-only test
wasmExports = null;
}
console.log("\n3. Writing values...");
// JS write (should always be visible)
Atomics.store(view, JS_OFFSET / 4, JS_VALUE);
console.log(` JS wrote ${JS_VALUE.toString(16)} at offset ${JS_OFFSET}`);
// WASM write (potentially not visible due to bug)
if (wasmExports?.write_value) {
wasmExports.write_value(WASM_OFFSET, WASM_VALUE);
console.log(` WASM wrote ${WASM_VALUE.toString(16)} at offset ${WASM_OFFSET}`);
} else if (wasmExports?.atomic_write) {
wasmExports.atomic_write(WASM_OFFSET, WASM_VALUE);
console.log(` WASM (atomic) wrote ${WASM_VALUE.toString(16)} at offset ${WASM_OFFSET}`);
} else {
// Fallback: use JS to simulate WASM write
view[WASM_OFFSET / 4] = WASM_VALUE;
console.log(` [Simulated] JS wrote ${WASM_VALUE.toString(16)} at offset ${WASM_OFFSET} (WASM functions not available)`);
}
// Verify from main thread
const mainWasmRead = Atomics.load(view, WASM_OFFSET / 4);
const mainJsRead = Atomics.load(view, JS_OFFSET / 4);
console.log(`\n Main thread verification:`);
console.log(` WASM offset: ${mainWasmRead.toString(16)} (expected ${WASM_VALUE.toString(16)})`);
console.log(` JS offset: ${mainJsRead.toString(16)} (expected ${JS_VALUE.toString(16)})`);
console.log("\n4. Spawning worker...");
// Add small delay - this is part of the workaround for the bug
// await new Promise(resolve => setTimeout(resolve, 10));
const worker = new Worker(new URL("./worker.ts", import.meta.url).href);
const results: WorkerResponse[] = [];
await new Promise<void>((resolve, reject) => {
let responseCount = 0;
const timeout = setTimeout(() => {
reject(new Error("Worker timeout"));
}, 5000);
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
results.push(event.data);
responseCount++;
if (responseCount === 2) {
clearTimeout(timeout);
resolve();
}
};
worker.onerror = (error) => {
clearTimeout(timeout);
reject(error);
};
// Ask worker to check both offsets
worker.postMessage({
type: "check",
buffer: memory.buffer,
offset: WASM_OFFSET,
expectedValue: WASM_VALUE,
});
worker.postMessage({
type: "check",
buffer: memory.buffer,
offset: JS_OFFSET,
expectedValue: JS_VALUE,
});
});
worker.terminate();
console.log("\n5. Results from worker:");
let wasmVisibility = false;
let jsVisibility = false;
for (const result of results) {
const isWasmOffset = result.offset === WASM_OFFSET;
const label = isWasmOffset ? "WASM write" : "JS write";
const expected = isWasmOffset ? WASM_VALUE : JS_VALUE;
console.log(` ${label} at offset ${result.offset}:`);
console.log(` Expected: 0x${expected.toString(16)}`);
console.log(` Actual: 0x${result.actualValue.toString(16)}`);
console.log(` Visible: ${result.matches ? "✓ YES" : "✗ NO"}`);
if (isWasmOffset) {
wasmVisibility = result.matches;
} else {
jsVisibility = result.matches;
}
}
console.log("\n" + "=".repeat(60));
console.log("Test Summary");
console.log("=".repeat(60));
if (jsVisibility && wasmVisibility) {
console.log("✓ Both JS and WASM writes visible to worker");
console.log(" This environment does NOT exhibit Bun issue #25677");
} else if (jsVisibility && !wasmVisibility) {
console.log("✗ WASM writes NOT visible to worker, but JS writes ARE");
console.log(" This REPRODUCES Bun issue #25677!");
console.log(" Workaround: Add a small delay before spawning worker");
} else if (!jsVisibility && !wasmVisibility) {
console.log("✗ Neither JS nor WASM writes visible to worker");
console.log(" This may indicate a SharedArrayBuffer configuration issue");
} else {
console.log("? Unexpected result: WASM visible but JS not");
}
}
runTest().catch(error => {
console.error("Test failed:", error);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment