From 2f63103a5cf2dc702ae90ee51e92fb8977313f20 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 25 Oct 2026 09:59:03 +0100 Subject: [PATCH] fix(builtin): raise Go runtime panics for channel and nil map faults A negative channel size, a send on or close of a closed channel, and an assignment to a nil map threw plain JavaScript Errors, which recover does not see as Go panics. They now panic with the runtime.Error values and messages of Go: makechan: size out of range, send on closed channel, close of closed channel, and assignment to entry in nil map. Signed-off-by: Randall C. O'Reilly --- gs/builtin/channel.ts | 23 ++++++++++++++------ gs/builtin/map.ts | 3 ++- gs/builtin/panic.test.ts | 46 +++++++++++++++++++++++++++++++++++++++ gs/net/http/index.test.ts | 2 +- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/gs/builtin/channel.ts b/gs/builtin/channel.ts index 4bfbdc800..6eec70f40 100644 --- a/gs/builtin/channel.ts +++ b/gs/builtin/channel.ts @@ -1,3 +1,4 @@ +import { GoPanic, RuntimeError, runtimePanic } from './panic.js' import { queueTask } from './scheduler.js' /** @@ -371,6 +372,12 @@ export async function chanRecvWithOk( * @param direction Optional direction for the channel. Default is 'both' (bidirectional). * @returns A new channel instance or channel reference. */ +// closedSendPanic returns the Go runtime panic of a send on a closed channel, +// to throw or to reject a waiting send with. +function closedSendPanic(): GoPanic { + return new GoPanic(new RuntimeError('send on closed channel')) +} + export function makeChannel( bufferSize: number, zeroValue: T, @@ -391,6 +398,10 @@ export function makeChannel( zeroValue: T, direction: 'send' | 'receive' | 'both' = 'both', ): Channel | ChannelRef { + // A negative size panics as in Go, so recover sees a runtime.Error. + if (bufferSize < 0) { + runtimePanic('makechan: size out of range') + } const channel = new BufferedChannel(bufferSize, zeroValue) if (direction === 'send') { @@ -439,7 +450,7 @@ class BufferedChannel implements Channel { async send(value: T): Promise { if (this.closed) { - throw new Error('send on closed channel') + throw closedSendPanic() } // Attempt to hand off to a waiting receiver (rendezvous) @@ -627,7 +638,7 @@ class BufferedChannel implements Channel { trySelectSend(value: T, id: number): SelectResult | undefined { if (this.closed) { - throw new Error('send on closed channel') + throw closedSendPanic() } // Sends must hand off to already waiting receivers before using spare // buffer capacity, including through the select fast path. @@ -662,7 +673,7 @@ class BufferedChannel implements Channel { onCommit?.() // A select case sending on a closed channel panics in Go. // This will cause Promise.race in selectStatement to reject. - throw new Error('send on closed channel') + throw closedSendPanic() } if (this.receivers.length > 0) { @@ -736,16 +747,14 @@ class BufferedChannel implements Channel { close(): void { if (this.closed) { - throw new Error('close of closed channel') + runtimePanic('close of closed channel') } this.closed = true const sendersToNotify = [...this.senders] // Shallow copy for iteration this.senders = [] for (const senderTask of sendersToNotify) { - scheduleClosedChannelWake(() => - senderTask.rejectSend(new Error('send on closed channel')), - ) + scheduleClosedChannelWake(() => senderTask.rejectSend(closedSendPanic())) } const receiversToNotify = [...this.receivers] diff --git a/gs/builtin/map.ts b/gs/builtin/map.ts index 4c2165089..f0d25de1d 100644 --- a/gs/builtin/map.ts +++ b/gs/builtin/map.ts @@ -1,4 +1,5 @@ import { comparableEqual } from './builtin.js' +import { runtimePanic } from './panic.js' import { canonicalPointerIdentity, isStructObject, @@ -230,7 +231,7 @@ export function mapGet( // mapSet preserves the existing equal key when replacing its value. export const mapSet = (map: Map | null, key: K, value: V): void => { if (!map) { - throw new Error('assign to nil map') + runtimePanic('assignment to entry in nil map') } const canonical = goMapStructKey(map, key) if (canonical !== undefined) { diff --git a/gs/builtin/panic.test.ts b/gs/builtin/panic.test.ts index 18c3cc15b..4db67d7bc 100644 --- a/gs/builtin/panic.test.ts +++ b/gs/builtin/panic.test.ts @@ -12,6 +12,8 @@ import { recovered, runtimePanic, } from './panic.js' +import { makeChannel } from './channel.js' +import { mapSet } from './map.js' import { goSlice, index, makeSlice } from './slice.js' import { DisposableStack } from './defer.js' @@ -92,6 +94,50 @@ describe('runtime panics route through GoPanic', () => { ) }) + it('makechan with negative size panics with the Go message', () => { + const caught = captureThrow(() => makeChannel(-1, 0, 'both')) + + expect(caught).toBeInstanceOf(GoPanic) + expect(((caught as GoPanic).value as RuntimeError).Error()).toBe( + 'makechan: size out of range', + ) + }) + + it('assignment to a nil map panics with the Go message', () => { + const caught = captureThrow(() => mapSet(null, 'k', 1)) + + expect(caught).toBeInstanceOf(GoPanic) + expect(((caught as GoPanic).value as RuntimeError).Error()).toBe( + 'assignment to entry in nil map', + ) + }) + + it('close of a closed channel panics with the Go message', () => { + const ch = makeChannel(0, 0, 'both') + ch.close() + const caught = captureThrow(() => ch.close()) + + expect(caught).toBeInstanceOf(GoPanic) + expect(((caught as GoPanic).value as RuntimeError).Error()).toBe( + 'close of closed channel', + ) + }) + + it('send on a closed channel panics with the Go message', async () => { + const ch = makeChannel(1, 0, 'both') + ch.close() + const caught = await ch.send(1).then( + () => undefined, + (e: unknown) => e, + ) + + expect(caught).toBeInstanceOf(GoPanic) + expect(((caught as GoPanic).value as RuntimeError).Error()).toBe( + 'send on closed channel', + ) + recovered(caught as GoPanic) + }) + it('panic carries an arbitrary value that panicValue unwraps', () => { const caught = captureThrow(() => panic('boom')) diff --git a/gs/net/http/index.test.ts b/gs/net/http/index.test.ts index df5460ac1..02cc2db8c 100644 --- a/gs/net/http/index.test.ts +++ b/gs/net/http/index.test.ts @@ -480,7 +480,7 @@ describe('net/http override', () => { expect(written.Len()).toBe(0) Header_Del(null, 'Content-Type') expect(() => Header_Set(null, 'Content-Type', 'text/plain')).toThrow( - 'assign to nil map', + 'assignment to entry in nil map', ) })