denoland/deno · error · Error
ERR_CLOSED_MESSAGE_PORT
ERR_CLOSED_MESSAGE_PORT
Error message
Cannot send data on closed MessagePort
What it means
moveMessagePortToContext looks up the port's internal id; a null id means the port is detached — already close()d, or already moved (moving clears the id so the original becomes unusable). The check intentionally runs before the vm.Context check to give the clearer ERR_CLOSED_MESSAGE_PORT ('Cannot send data on closed MessagePort') error, mirroring Node's ordering.
Source
Thrown at ext/node/polyfills/worker_threads.ts:1753
// underlying port are deserialized without the global host-object
// deserializers, mirroring Node's behavior where the target context lacks
// the JS classes registered in the source realm: any host object (e.g. a
// crypto KeyObject) triggers `messageerror` with the
// `ERR_MESSAGE_TARGET_CONTEXT_UNAVAILABLE` code, while plain transferable
// data is delivered as `message`.
function moveMessagePortToContext(
port: MessagePort,
context: object,
): object {
if (!(ObjectPrototypeIsPrototypeOf(MessagePortPrototype, port))) {
throw new ERR_INVALID_ARG_TYPE("port", "MessagePort", port);
}
// Node checks closed-port state before vm.Context to give a clearer
// error when the port is detached -- order matters for tests in the
// node_compat suite that pass an empty {} as the context.
const portId = getMessagePortId(port);
if (portId === null) {
throw new ERR_CLOSED_MESSAGE_PORT();
}
const vm = lazyVm();
if (!vm.isContext(context)) {
throw new ERR_INVALID_ARG_TYPE("context", "vm.Context", context);
}
// Take ownership of the port: clear the id on the original so it can no
// longer be used from this context.
setMessagePortId(port, null);
// Allocate the wrapper inside the target context so its prototype chain
// is the target realm's (i.e., `wrapper instanceof Object` in the caller
// realm is false, matching Node).
const wrapper = vm.runInContext("({})", context);
wrapper.onmessage = null;
wrapper.onmessageerror = null;
let enabled = false;
let closed = false;View on GitHub (pinned to 9ad36f7a2c)
Solutions
- Use a fresh port from a new MessageChannel() for each move; ports are single-use for this API.
- Order operations so close() only runs after the move completes; guard with a moved/closed flag.
- Catch ERR_CLOSED_MESSAGE_PORT and rebuild the channel rather than retrying the same port.
Example fix
// before
port.close();
moveMessagePortToContext(port, ctx); // ERR_CLOSED_MESSAGE_PORT
// after
import { MessageChannel, moveMessagePortToContext } from 'node:worker_threads';
const { port1 } = new MessageChannel();
moveMessagePortToContext(port1, ctx); // move a live port exactly once Defensive patterns
Strategy: try-catch
Validate before calling
const movedOrClosed = new WeakSet<object>();
function closePort(p: MessagePort) { p.close(); movedOrClosed.add(p); }
function isUsable(p: MessagePort) { return !movedOrClosed.has(p); }
if (isUsable(port)) moveMessagePortToContext(port, ctx); Try / catch
try {
moveMessagePortToContext(port, ctx);
} catch (e) {
if (e?.code === 'ERR_CLOSED_MESSAGE_PORT') {
// rebuild the channel and retry with a fresh port
const { port1 } = new MessageChannel();
moveMessagePortToContext(port1, ctx);
} else throw e;
} Prevention
- Treat ports as consumed after move/close; never reuse them.
- Centralize close() in one cleanup path.
- Track port lifecycle state explicitly in the owning module.
When it happens
Trigger: port.close() followed by moveMessagePortToContext(port, ctx); or calling moveMessagePortToContext twice with the same port — the second call sees the id cleared by the first.
Common situations: Retry logic reusing a port after a failed setup whose cleanup closed it; double registration during init; error handlers closing ports that a pending move still needs.
Related errors
- Destroy hook of "${id}" errored
- Already closed
- Request closed
- Queue already closed
- ERR_HTTP_HEADERS_SENT
AI-assisted analysis of denoland/deno@9ad36f7a2c (2026-08-20).
Data as JSON: /api/errors/173162734a5d9021.
Report an issue: GitHub.