From 799dd367e2b863198809650338906de92d619b13 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 12 Aug 2026 00:07:59 -0700 Subject: [PATCH] fix(runtime): support disposable symbols without native definitions WebKit lacks native Symbol.dispose and Symbol.asyncDispose symbols, while Bun lowered using declarations look up the registry fallbacks from Symbol.for. The runtime methods were registered only under missing native keys, so generated disposal could not reach them. Resolve the native or registry key once for each symbol and use those keys for both disposable stacks. The browser fixture builds Bun lowered using code and covers Chromium and WebKit, LIFO cleanup, async cleanup, and deferred error propagation. Native Chromium behavior and the public runtime API remain unchanged. Signed-off-by: Christian Stewart --- gs/builtin/defer-browser.test.ts | 56 ++++++++++++++++ gs/builtin/defer.ts | 12 +++- .../testdata/disposable-stack-browser.ts | 64 +++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 gs/builtin/defer-browser.test.ts create mode 100644 gs/builtin/testdata/disposable-stack-browser.ts diff --git a/gs/builtin/defer-browser.test.ts b/gs/builtin/defer-browser.test.ts new file mode 100644 index 00000000..873fba29 --- /dev/null +++ b/gs/builtin/defer-browser.test.ts @@ -0,0 +1,56 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { chromium, webkit } from 'playwright' +import { afterAll, describe, expect, it } from 'vitest' + +const outputDir = mkdtempSync(join(tmpdir(), 'goscript-disposable-stack-')) +const outputPath = join(outputDir, 'bundle.js') +const entryPath = fileURLToPath( + new URL('./testdata/disposable-stack-browser.ts', import.meta.url), +) + +execFileSync('bun', [ + 'build', + entryPath, + '--target=browser', + '--format=iife', + `--outfile=${outputPath}`, +]) +const bundle = readFileSync(outputPath, 'utf8') + +afterAll(() => rmSync(outputDir, { recursive: true, force: true })) + +describe.each([ + ['Chromium', chromium], + ['WebKit', webkit], +])('DisposableStack in %s', (_name, browserType) => { + it('supports generated using declarations', async () => { + const browser = await browserType.launch({ headless: true }) + const page = await browser.newPage() + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + + try { + await page.addScriptTag({ content: bundle }) + expect(errors).toEqual([]) + expect( + await page.evaluate( + async () => await globalThis.__goscriptDisposableStackResult, + ), + ).toEqual({ + disposeSymbolPresent: _name === 'Chromium', + asyncDisposeSymbolPresent: _name === 'Chromium', + usesDisposeSymbol: true, + usesAsyncDisposeSymbol: true, + order: ['second', 'first', 'async'], + disposeError: 'deferred failure', + }) + } finally { + await browser.close() + } + }) +}) diff --git a/gs/builtin/defer.ts b/gs/builtin/defer.ts index 6abd94f2..12b00390 100644 --- a/gs/builtin/defer.ts +++ b/gs/builtin/defer.ts @@ -1,5 +1,11 @@ import { withRecoveringPanic } from './panic.js' +const disposeSymbol: typeof Symbol.dispose = + Symbol.dispose ?? (Symbol.for('Symbol.dispose') as typeof Symbol.dispose) +const asyncDisposeSymbol: typeof Symbol.asyncDispose = + Symbol.asyncDispose ?? + (Symbol.for('Symbol.asyncDispose') as typeof Symbol.asyncDispose) + /** * DisposableStack manages synchronous disposable resources, mimicking Go's defer behavior. * Functions added via `defer` are executed in LIFO order when the stack is disposed. @@ -30,7 +36,7 @@ export class DisposableStack implements Disposable { /** * Disposes during ordinary scope exit. */ - [Symbol.dispose](): void { + [disposeSymbol](): void { this.dispose() } } @@ -68,11 +74,11 @@ export class AsyncDisposableStack implements AsyncDisposable { } } - async [Symbol.asyncDispose](): Promise { + async [asyncDisposeSymbol](): Promise { await this.dispose() } - [Symbol.dispose](): void { + [disposeSymbol](): void { while (this.stack.length) { const fn = this.stack.pop()! const result = fn() diff --git a/gs/builtin/testdata/disposable-stack-browser.ts b/gs/builtin/testdata/disposable-stack-browser.ts new file mode 100644 index 00000000..bb8ea003 --- /dev/null +++ b/gs/builtin/testdata/disposable-stack-browser.ts @@ -0,0 +1,64 @@ +import { AsyncDisposableStack, DisposableStack } from '../defer.js' + +type DisposableStackResult = { + disposeSymbolPresent: boolean + asyncDisposeSymbolPresent: boolean + usesDisposeSymbol: boolean + usesAsyncDisposeSymbol: boolean + order: string[] + disposeError: string | undefined +} + +declare global { + var __goscriptDisposableStackResult: + | Promise + | undefined +} + +globalThis.__goscriptDisposableStackResult = (async () => { + const disposeSymbol = Symbol.dispose ?? Symbol.for('Symbol.dispose') + const asyncDisposeSymbol = + Symbol.asyncDispose ?? Symbol.for('Symbol.asyncDispose') + const result: DisposableStackResult = { + disposeSymbolPresent: typeof Symbol.dispose === 'symbol', + asyncDisposeSymbolPresent: typeof Symbol.asyncDispose === 'symbol', + usesDisposeSymbol: + typeof Object.getOwnPropertyDescriptor( + DisposableStack.prototype, + disposeSymbol, + )?.value === 'function', + usesAsyncDisposeSymbol: + typeof Object.getOwnPropertyDescriptor( + AsyncDisposableStack.prototype, + asyncDisposeSymbol, + )?.value === 'function', + order: [], + disposeError: undefined, + } + + { + using stack = new DisposableStack() + stack.defer(() => result.order.push('first')) + stack.defer(() => result.order.push('second')) + } + + await (async () => { + await using stack = new AsyncDisposableStack() + stack.defer(async () => { + result.order.push('async') + }) + })() + + try { + { + using stack = new DisposableStack() + stack.defer(() => { + throw new Error('deferred failure') + }) + } + } catch (error) { + result.disposeError = error instanceof Error ? error.message : String(error) + } + + return result +})()