Compare commits

...

7 Commits

Author SHA1 Message Date
Jarred Sumner
df3b61d104 Merge branch 'main' into claude/fix-messageport-segfault-23194 2025-10-12 14:57:55 -07:00
Claude Bot
0c95717d6f Fix test to use readFileSync for loading vendored comlink
- Linter was removing import attributes
- Use readFileSync with __dirname to reliably load comlink.js
- Include comlink source directly in tempDir files
- Test now passes reliably on all platforms
2025-10-12 18:54:44 +00:00
Claude Bot
a8662c315a Vendor comlink instead of installing it as dependency
- Removes bun install step from test, making it faster
- Copies comlink.js (13KB) to test directory
- Test now runs in ~6.5s instead of requiring npm install
2025-10-11 18:26:10 +00:00
Claude Bot
20f1a56040 Remove redundant crash checks and explicit timeout per coding guidelines 2025-10-11 18:21:48 +00:00
Claude Bot
4918acc5b3 Use tempDir from harness for automatic cleanup 2025-10-11 18:19:13 +00:00
Claude Bot
0e5873809e Optimize test timeout to reduce test duration 2025-10-11 18:11:42 +00:00
Claude Bot
c7af3289d5 Fix MessagePort segfault when context is destroyed (#23194)
Fixes a null pointer dereference in MessagePort::postMessage when the
ScriptExecutionContext is destroyed during high-frequency message
passing between workers using Comlink.

The issue occurred when postMessage() was called on a MessagePort after
its associated ScriptExecutionContext had been destroyed. The code was
directly dereferencing protectedScriptExecutionContext() without checking
if it returned null, leading to a segfault at the address stored in the
RefPtr (typically 0x18-0x48).

This fix checks if the context is valid before dereferencing it, and
returns early if it's null, preventing the crash. This is consistent
with other parts of the codebase that check for context validity before
use.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 17:48:40 +00:00
3 changed files with 431 additions and 1 deletions

View File

@@ -183,7 +183,11 @@ ExceptionOr<void> MessagePort::postMessage(JSC::JSGlobalObject& state, JSC::JSVa
MessageWithMessagePorts message { messageData.releaseReturnValue(), WTFMove(transferredPorts) };
MessagePortChannelProvider::fromContext(*protectedScriptExecutionContext()).postMessageToRemote(WTFMove(message), m_remoteIdentifier);
auto protectedContext = protectedScriptExecutionContext();
if (!protectedContext)
return {};
MessagePortChannelProvider::fromContext(*protectedContext).postMessageToRemote(WTFMove(message), m_remoteIdentifier);
return {};
}

View File

@@ -0,0 +1,68 @@
// https://github.com/oven-sh/bun/issues/23194
// Test that MessagePort doesn't crash when postMessage is called
// after the script execution context is destroyed
import { expect, test } from "bun:test";
import { readFileSync } from "fs";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
test("comlink worker communication doesn't segfault", async () => {
const comlinkSource = readFileSync(join(__dirname, "23194", "comlink.js"), "utf-8");
using testDir = tempDir("comlink-test", {
"comlink.js": comlinkSource,
"worker.js": `
import * as Comlink from './comlink.js';
Comlink.expose({
async start(start, main) {
let i = 0;
const interval = setInterval(
() => main.callback(i++, Date.now() - start),
1,
);
setTimeout(() => {
clearInterval(interval);
main.callback(i, Date.now(), true);
}, 1000);
},
});
`,
"main.js": `
import * as Comlink from './comlink.js';
let mainloop = true;
const
worker = new Worker("./worker.js", {type: "module"}),
api = Comlink.wrap(worker),
main = {
async callback(index, ts, final) {
if(final) mainloop = false;
},
};
(async () => {
await api.start(Date.now(), Comlink.proxy(main));
while (mainloop) {
await Bun.sleep(0);
Bun.sleepSync(16);
}
worker.terminate();
console.log("SUCCESS");
})();
`,
});
// Run the test
await using proc = Bun.spawn({
cmd: [bunExe(), "main.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stdout).toContain("SUCCESS");
});

View File

@@ -0,0 +1,358 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const proxyMarker = Symbol("Comlink.proxy");
const createEndpoint = Symbol("Comlink.endpoint");
const releaseProxy = Symbol("Comlink.releaseProxy");
const finalizer = Symbol("Comlink.finalizer");
const throwMarker = Symbol("Comlink.thrown");
const isObject = (val) => (typeof val === "object" && val !== null) || typeof val === "function";
/**
* Internal transfer handle to handle objects marked to proxy.
*/
const proxyTransferHandler = {
canHandle: (val) => isObject(val) && val[proxyMarker],
serialize(obj) {
const { port1, port2 } = new MessageChannel();
expose(obj, port1);
return [port2, [port2]];
},
deserialize(port) {
port.start();
return wrap(port);
},
};
/**
* Internal transfer handler to handle thrown exceptions.
*/
const throwTransferHandler = {
canHandle: (value) => isObject(value) && throwMarker in value,
serialize({ value }) {
let serialized;
if (value instanceof Error) {
serialized = {
isError: true,
value: {
message: value.message,
name: value.name,
stack: value.stack,
},
};
}
else {
serialized = { isError: false, value };
}
return [serialized, []];
},
deserialize(serialized) {
if (serialized.isError) {
throw Object.assign(new Error(serialized.value.message), serialized.value);
}
throw serialized.value;
},
};
/**
* Allows customizing the serialization of certain values.
*/
const transferHandlers = new Map([
["proxy", proxyTransferHandler],
["throw", throwTransferHandler],
]);
function isAllowedOrigin(allowedOrigins, origin) {
for (const allowedOrigin of allowedOrigins) {
if (origin === allowedOrigin || allowedOrigin === "*") {
return true;
}
if (allowedOrigin instanceof RegExp && allowedOrigin.test(origin)) {
return true;
}
}
return false;
}
function expose(obj, ep = globalThis, allowedOrigins = ["*"]) {
ep.addEventListener("message", function callback(ev) {
if (!ev || !ev.data) {
return;
}
if (!isAllowedOrigin(allowedOrigins, ev.origin)) {
console.warn(`Invalid origin '${ev.origin}' for comlink proxy`);
return;
}
const { id, type, path } = Object.assign({ path: [] }, ev.data);
const argumentList = (ev.data.argumentList || []).map(fromWireValue);
let returnValue;
try {
const parent = path.slice(0, -1).reduce((obj, prop) => obj[prop], obj);
const rawValue = path.reduce((obj, prop) => obj[prop], obj);
switch (type) {
case "GET" /* MessageType.GET */:
{
returnValue = rawValue;
}
break;
case "SET" /* MessageType.SET */:
{
parent[path.slice(-1)[0]] = fromWireValue(ev.data.value);
returnValue = true;
}
break;
case "APPLY" /* MessageType.APPLY */:
{
returnValue = rawValue.apply(parent, argumentList);
}
break;
case "CONSTRUCT" /* MessageType.CONSTRUCT */:
{
const value = new rawValue(...argumentList);
returnValue = proxy(value);
}
break;
case "ENDPOINT" /* MessageType.ENDPOINT */:
{
const { port1, port2 } = new MessageChannel();
expose(obj, port2);
returnValue = transfer(port1, [port1]);
}
break;
case "RELEASE" /* MessageType.RELEASE */:
{
returnValue = undefined;
}
break;
default:
return;
}
}
catch (value) {
returnValue = { value, [throwMarker]: 0 };
}
Promise.resolve(returnValue)
.catch((value) => {
return { value, [throwMarker]: 0 };
})
.then((returnValue) => {
const [wireValue, transferables] = toWireValue(returnValue);
ep.postMessage(Object.assign(Object.assign({}, wireValue), { id }), transferables);
if (type === "RELEASE" /* MessageType.RELEASE */) {
// detach and deactive after sending release response above.
ep.removeEventListener("message", callback);
closeEndPoint(ep);
if (finalizer in obj && typeof obj[finalizer] === "function") {
obj[finalizer]();
}
}
})
.catch((error) => {
// Send Serialization Error To Caller
const [wireValue, transferables] = toWireValue({
value: new TypeError("Unserializable return value"),
[throwMarker]: 0,
});
ep.postMessage(Object.assign(Object.assign({}, wireValue), { id }), transferables);
});
});
if (ep.start) {
ep.start();
}
}
function isMessagePort(endpoint) {
return endpoint.constructor.name === "MessagePort";
}
function closeEndPoint(endpoint) {
if (isMessagePort(endpoint))
endpoint.close();
}
function wrap(ep, target) {
const pendingListeners = new Map();
ep.addEventListener("message", function handleMessage(ev) {
const { data } = ev;
if (!data || !data.id) {
return;
}
const resolver = pendingListeners.get(data.id);
if (!resolver) {
return;
}
try {
resolver(data);
}
finally {
pendingListeners.delete(data.id);
}
});
return createProxy(ep, pendingListeners, [], target);
}
function throwIfProxyReleased(isReleased) {
if (isReleased) {
throw new Error("Proxy has been released and is not useable");
}
}
function releaseEndpoint(ep) {
return requestResponseMessage(ep, new Map(), {
type: "RELEASE" /* MessageType.RELEASE */,
}).then(() => {
closeEndPoint(ep);
});
}
const proxyCounter = new WeakMap();
const proxyFinalizers = "FinalizationRegistry" in globalThis &&
new FinalizationRegistry((ep) => {
const newCount = (proxyCounter.get(ep) || 0) - 1;
proxyCounter.set(ep, newCount);
if (newCount === 0) {
releaseEndpoint(ep);
}
});
function registerProxy(proxy, ep) {
const newCount = (proxyCounter.get(ep) || 0) + 1;
proxyCounter.set(ep, newCount);
if (proxyFinalizers) {
proxyFinalizers.register(proxy, ep, proxy);
}
}
function unregisterProxy(proxy) {
if (proxyFinalizers) {
proxyFinalizers.unregister(proxy);
}
}
function createProxy(ep, pendingListeners, path = [], target = function () { }) {
let isProxyReleased = false;
const proxy = new Proxy(target, {
get(_target, prop) {
throwIfProxyReleased(isProxyReleased);
if (prop === releaseProxy) {
return () => {
unregisterProxy(proxy);
releaseEndpoint(ep);
pendingListeners.clear();
isProxyReleased = true;
};
}
if (prop === "then") {
if (path.length === 0) {
return { then: () => proxy };
}
const r = requestResponseMessage(ep, pendingListeners, {
type: "GET" /* MessageType.GET */,
path: path.map((p) => p.toString()),
}).then(fromWireValue);
return r.then.bind(r);
}
return createProxy(ep, pendingListeners, [...path, prop]);
},
set(_target, prop, rawValue) {
throwIfProxyReleased(isProxyReleased);
// FIXME: ES6 Proxy Handler `set` methods are supposed to return a
// boolean. To show good will, we return true asynchronously ¯\_(ツ)_/¯
const [value, transferables] = toWireValue(rawValue);
return requestResponseMessage(ep, pendingListeners, {
type: "SET" /* MessageType.SET */,
path: [...path, prop].map((p) => p.toString()),
value,
}, transferables).then(fromWireValue);
},
apply(_target, _thisArg, rawArgumentList) {
throwIfProxyReleased(isProxyReleased);
const last = path[path.length - 1];
if (last === createEndpoint) {
return requestResponseMessage(ep, pendingListeners, {
type: "ENDPOINT" /* MessageType.ENDPOINT */,
}).then(fromWireValue);
}
// We just pretend that `bind()` didnt happen.
if (last === "bind") {
return createProxy(ep, pendingListeners, path.slice(0, -1));
}
const [argumentList, transferables] = processArguments(rawArgumentList);
return requestResponseMessage(ep, pendingListeners, {
type: "APPLY" /* MessageType.APPLY */,
path: path.map((p) => p.toString()),
argumentList,
}, transferables).then(fromWireValue);
},
construct(_target, rawArgumentList) {
throwIfProxyReleased(isProxyReleased);
const [argumentList, transferables] = processArguments(rawArgumentList);
return requestResponseMessage(ep, pendingListeners, {
type: "CONSTRUCT" /* MessageType.CONSTRUCT */,
path: path.map((p) => p.toString()),
argumentList,
}, transferables).then(fromWireValue);
},
});
registerProxy(proxy, ep);
return proxy;
}
function myFlat(arr) {
return Array.prototype.concat.apply([], arr);
}
function processArguments(argumentList) {
const processed = argumentList.map(toWireValue);
return [processed.map((v) => v[0]), myFlat(processed.map((v) => v[1]))];
}
const transferCache = new WeakMap();
function transfer(obj, transfers) {
transferCache.set(obj, transfers);
return obj;
}
function proxy(obj) {
return Object.assign(obj, { [proxyMarker]: true });
}
function windowEndpoint(w, context = globalThis, targetOrigin = "*") {
return {
postMessage: (msg, transferables) => w.postMessage(msg, targetOrigin, transferables),
addEventListener: context.addEventListener.bind(context),
removeEventListener: context.removeEventListener.bind(context),
};
}
function toWireValue(value) {
for (const [name, handler] of transferHandlers) {
if (handler.canHandle(value)) {
const [serializedValue, transferables] = handler.serialize(value);
return [
{
type: "HANDLER" /* WireValueType.HANDLER */,
name,
value: serializedValue,
},
transferables,
];
}
}
return [
{
type: "RAW" /* WireValueType.RAW */,
value,
},
transferCache.get(value) || [],
];
}
function fromWireValue(value) {
switch (value.type) {
case "HANDLER" /* WireValueType.HANDLER */:
return transferHandlers.get(value.name).deserialize(value.value);
case "RAW" /* WireValueType.RAW */:
return value.value;
}
}
function requestResponseMessage(ep, pendingListeners, msg, transfers) {
return new Promise((resolve) => {
const id = generateUUID();
pendingListeners.set(id, resolve);
if (ep.start) {
ep.start();
}
ep.postMessage(Object.assign({ id }, msg), transfers);
});
}
function generateUUID() {
return new Array(4)
.fill(0)
.map(() => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(16))
.join("-");
}
export { createEndpoint, expose, finalizer, proxy, proxyMarker, releaseProxy, transfer, transferHandlers, windowEndpoint, wrap };
//# sourceMappingURL=comlink.js.map