fix(cli,core): stop dev workers spinning at 100% CPU after parent CLI disconnect (#3491)
Orphaned `trigger-dev-run-worker` processes were pinning CPU at 100% after the dev CLI exited — stuck in an uncaughtException feedback loop where a closed IPC channel kept throwing `ERR_IPC_CHANNEL_CLOSED` back into a handler that itself called `process.send`. Fix: - `ZodIpcConnection` no-ops sends when the channel is disconnected. - Dev workers exit on `process.disconnect` instead of being re-parented to init. - All worker `uncaughtException` handlers route through a `safeSend` guard so the handler can never re-enter itself. Verified end-to-end: `kill -9` of the dev CLI now cleans up all child workers within ~2s.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix dev workers spinning at 100% CPU after the parent CLI disconnects. Orphaned `trigger-dev-run-worker` (and indexer) processes were caught in an `uncaughtException` feedback loop: a periodic IPC send via `process.send` would throw `ERR_IPC_CHANNEL_CLOSED` once the parent closed the channel, which re-entered the same handler that itself called `process.send`, scheduled via `setImmediate` and amplified by source-map-support's `prepareStackTrace`. Fixed by (1) silently dropping packets in `ZodIpcConnection` when the channel is disconnected, (2) adding a `process.on("disconnect", ...)` handler in dev workers so they exit cleanly when the CLI closes the IPC channel, and (3) wrapping all `uncaughtException`-path `process.send` calls in a `safeSend` guard that checks `process.connected` and swallows synchronous throws.
|
||||
@@ -27,30 +27,45 @@ sourceMapSupport.install({
|
||||
hookRequire: false,
|
||||
});
|
||||
|
||||
// If the parent CLI closes the IPC channel, exit cleanly instead of being
|
||||
// re-parented to init and busy-looping on `process.send` against a dead channel.
|
||||
process.on("disconnect", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
function safeSend(message: unknown) {
|
||||
if (!process.connected || !process.send) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.send(message);
|
||||
} catch {
|
||||
// swallow: a throw here would re-enter this handler and busy-loop the worker
|
||||
}
|
||||
}
|
||||
|
||||
process.on("uncaughtException", function (error, origin) {
|
||||
if (error instanceof Error) {
|
||||
process.send &&
|
||||
process.send({
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
origin,
|
||||
},
|
||||
version: "v1",
|
||||
});
|
||||
safeSend({
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
origin,
|
||||
},
|
||||
version: "v1",
|
||||
});
|
||||
} else {
|
||||
process.send &&
|
||||
process.send({
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: {
|
||||
name: "Error",
|
||||
message: typeof error === "string" ? error : JSON.stringify(error),
|
||||
},
|
||||
origin,
|
||||
safeSend({
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: {
|
||||
name: "Error",
|
||||
message: typeof error === "string" ? error : JSON.stringify(error),
|
||||
},
|
||||
version: "v1",
|
||||
});
|
||||
origin,
|
||||
},
|
||||
version: "v1",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -183,7 +198,7 @@ await sendMessageInCatalog(
|
||||
importErrors,
|
||||
},
|
||||
async (msg) => {
|
||||
process.send?.(msg);
|
||||
safeSend(msg);
|
||||
}
|
||||
).catch((err) => {
|
||||
if (err instanceof ZodSchemaParsedError) {
|
||||
|
||||
@@ -77,37 +77,53 @@ sourceMapSupport.install({
|
||||
hookRequire: false,
|
||||
});
|
||||
|
||||
// If the parent CLI closes the IPC channel (process restart, crash, lost
|
||||
// handle), exit cleanly instead of being re-parented to init and busy-looping
|
||||
// on `process.send` that throws against a dead channel.
|
||||
process.on("disconnect", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
function safeSend(message: unknown) {
|
||||
if (!process.connected || !process.send) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.send(message);
|
||||
} catch {
|
||||
// swallow: a throw here would re-enter this handler and busy-loop the worker
|
||||
}
|
||||
}
|
||||
|
||||
process.on("uncaughtException", function (error, origin) {
|
||||
logError("Uncaught exception", { error, origin });
|
||||
if (error instanceof Error) {
|
||||
process.send &&
|
||||
process.send({
|
||||
type: "EVENT",
|
||||
message: {
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
origin,
|
||||
},
|
||||
version: "v1",
|
||||
safeSend({
|
||||
type: "EVENT",
|
||||
message: {
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
origin,
|
||||
},
|
||||
});
|
||||
version: "v1",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
process.send &&
|
||||
process.send({
|
||||
type: "EVENT",
|
||||
message: {
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: {
|
||||
name: "Error",
|
||||
message: typeof error === "string" ? error : JSON.stringify(error),
|
||||
},
|
||||
origin,
|
||||
safeSend({
|
||||
type: "EVENT",
|
||||
message: {
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: {
|
||||
name: "Error",
|
||||
message: typeof error === "string" ? error : JSON.stringify(error),
|
||||
},
|
||||
version: "v1",
|
||||
origin,
|
||||
},
|
||||
});
|
||||
version: "v1",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,30 +27,39 @@ sourceMapSupport.install({
|
||||
hookRequire: false,
|
||||
});
|
||||
|
||||
function safeSend(message: unknown) {
|
||||
if (!process.connected || !process.send) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.send(message);
|
||||
} catch {
|
||||
// swallow: a throw here would re-enter this handler and busy-loop the worker
|
||||
}
|
||||
}
|
||||
|
||||
process.on("uncaughtException", function (error, origin) {
|
||||
if (error instanceof Error) {
|
||||
process.send &&
|
||||
process.send({
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
origin,
|
||||
},
|
||||
version: "v1",
|
||||
});
|
||||
safeSend({
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
origin,
|
||||
},
|
||||
version: "v1",
|
||||
});
|
||||
} else {
|
||||
process.send &&
|
||||
process.send({
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: {
|
||||
name: "Error",
|
||||
message: typeof error === "string" ? error : JSON.stringify(error),
|
||||
},
|
||||
origin,
|
||||
safeSend({
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: {
|
||||
name: "Error",
|
||||
message: typeof error === "string" ? error : JSON.stringify(error),
|
||||
},
|
||||
version: "v1",
|
||||
});
|
||||
origin,
|
||||
},
|
||||
version: "v1",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -191,7 +200,7 @@ await sendMessageInCatalog(
|
||||
importErrors,
|
||||
},
|
||||
async (msg) => {
|
||||
process.send?.(msg);
|
||||
safeSend(msg);
|
||||
}
|
||||
).catch((err) => {
|
||||
if (err instanceof ZodSchemaParsedError) {
|
||||
@@ -200,7 +209,7 @@ await sendMessageInCatalog(
|
||||
"TASKS_FAILED_TO_PARSE",
|
||||
{ zodIssues: err.error.issues, tasks },
|
||||
async (msg) => {
|
||||
process.send?.(msg);
|
||||
safeSend(msg);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -77,37 +77,46 @@ sourceMapSupport.install({
|
||||
hookRequire: false,
|
||||
});
|
||||
|
||||
function safeSend(message: unknown) {
|
||||
if (!process.connected || !process.send) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.send(message);
|
||||
} catch {
|
||||
// swallow: a throw here would re-enter this handler and busy-loop the worker
|
||||
}
|
||||
}
|
||||
|
||||
process.on("uncaughtException", function (error, origin) {
|
||||
console.error("Uncaught exception", { error, origin });
|
||||
if (error instanceof Error) {
|
||||
process.send &&
|
||||
process.send({
|
||||
type: "EVENT",
|
||||
message: {
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
origin,
|
||||
},
|
||||
version: "v1",
|
||||
safeSend({
|
||||
type: "EVENT",
|
||||
message: {
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
origin,
|
||||
},
|
||||
});
|
||||
version: "v1",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
process.send &&
|
||||
process.send({
|
||||
type: "EVENT",
|
||||
message: {
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: {
|
||||
name: "Error",
|
||||
message: typeof error === "string" ? error : JSON.stringify(error),
|
||||
},
|
||||
origin,
|
||||
safeSend({
|
||||
type: "EVENT",
|
||||
message: {
|
||||
type: "UNCAUGHT_EXCEPTION",
|
||||
payload: {
|
||||
error: {
|
||||
name: "Error",
|
||||
message: typeof error === "string" ? error : JSON.stringify(error),
|
||||
},
|
||||
version: "v1",
|
||||
origin,
|
||||
},
|
||||
});
|
||||
version: "v1",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ interface ZodIpcConnectionOptions<
|
||||
process: {
|
||||
send?: (message: any) => any;
|
||||
on?: (event: "message", listener: (message: any) => void) => void;
|
||||
connected?: boolean;
|
||||
};
|
||||
handlers?: ZodIpcMessageHandlers<TListenCatalog, TEmitCatalog>;
|
||||
}
|
||||
@@ -257,7 +258,19 @@ export class ZodIpcConnection<
|
||||
}
|
||||
|
||||
async #sendPacket(packet: Packet) {
|
||||
await this.opts.process.send?.(packet);
|
||||
// When the IPC channel is closed (e.g. parent process exited), there is no
|
||||
// recipient — drop the packet rather than letting `process.send` throw
|
||||
// ERR_IPC_CHANNEL_CLOSED, which would otherwise propagate as an
|
||||
// uncaughtException and re-enter any handler that itself calls `process.send`.
|
||||
if (this.opts.process.connected === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.opts.process.send?.(packet);
|
||||
} catch {
|
||||
// swallow: channel raced from open to closed between the check and the send
|
||||
}
|
||||
}
|
||||
|
||||
async send<K extends GetSocketMessagesWithoutCallback<TEmitCatalog>>(
|
||||
|
||||
Reference in New Issue
Block a user