feat(cli): auto-cancel dev runs on CLI exit via detached watchdog (#3191)
When the dev CLI exits (e.g. ctrl+c via pnpm), runs that were mid-execution previously stayed stuck in EXECUTING status for up to 5 minutes until the heartbeat timeout fired. Now they are cancelled within seconds. The dev CLI spawns a lightweight detached watchdog process at startup. The watchdog monitors the CLI process ID and, when it detects the CLI has exited, calls a new POST /engine/v1/dev/disconnect endpoint to cancel all in-flight runs immediately (skipping PENDING_CANCEL since the worker is known to be dead). Watchdog design: - Fully detached (detached: true, stdio: ignore, unref()) so it survives even when pnpm sends SIGKILL to the process tree - Active run IDs maintained via atomic file write (.trigger/active-runs.json) - Single-instance guarantee via PID file (.trigger/watchdog.pid) - Safety timeout: exits after 24 hours to prevent zombie processes - On clean shutdown, the watchdog is killed (no disconnect needed) Disconnect endpoint: - Rate-limited: 5 calls/min per environment - Capped at 500 runs per call - Small counts (<= 25): cancelled inline with pMap concurrency 10 - Large counts: delegated to the bulk action system - Uses finalizeRun: true to skip PENDING_CANCEL and go straight to FINISHED Run engine change: - cancelRun() now respects finalizeRun when the run is in EXECUTING status, skipping the PENDING_CANCEL waiting state and going directly to FINISHED
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Auto-cancel in-flight dev runs when the CLI exits, using a detached watchdog process that survives pnpm SIGKILL
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Added `/engine/v1/dev/disconnect` endpoint to auto-cancel runs when the CLI disconnects. Maximum of 500 runs can be cancelled. Uses the bulk action system when there are more than 25 runs to cancel.
|
||||
@@ -0,0 +1,180 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { DevDisconnectRequestBody } from "@trigger.dev/core/v3";
|
||||
import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { BulkActionNotificationType, BulkActionType } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { RateLimiter } from "~/services/rateLimiter.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
|
||||
import { commonWorker } from "~/v3/commonWorker.server";
|
||||
import pMap from "p-map";
|
||||
|
||||
const CANCEL_REASON = "Dev session ended (CLI exited)";
|
||||
|
||||
// Below this threshold, cancel runs inline with pMap.
|
||||
// Above it, create a bulk action and process asynchronously.
|
||||
const BULK_ACTION_THRESHOLD = 25;
|
||||
|
||||
// Maximum number of runs that can be cancelled in a single disconnect call.
|
||||
const MAX_RUNS = 500;
|
||||
|
||||
// Rate limit: 5 calls per minute per environment
|
||||
const disconnectRateLimiter = new RateLimiter({
|
||||
keyPrefix: "dev-disconnect",
|
||||
limiter: Ratelimit.fixedWindow(5, "1 m"),
|
||||
logFailure: true,
|
||||
});
|
||||
|
||||
const { action } = createActionApiRoute(
|
||||
{
|
||||
body: DevDisconnectRequestBody,
|
||||
maxContentLength: 1024 * 256, // 256KB
|
||||
method: "POST",
|
||||
},
|
||||
async ({ authentication, body }) => {
|
||||
// Only allow dev environments — this endpoint uses finalizeRun which
|
||||
// skips PENDING_CANCEL and immediately finalizes executing runs.
|
||||
if (authentication.environment.type !== "DEVELOPMENT") {
|
||||
return json({ error: "This endpoint is only available for dev environments" }, { status: 403 });
|
||||
}
|
||||
|
||||
const environmentId = authentication.environment.id;
|
||||
|
||||
// Rate limit per environment
|
||||
const rateLimitResult = await disconnectRateLimiter.limit(environmentId);
|
||||
if (!rateLimitResult.success) {
|
||||
return json(
|
||||
{ error: "Rate limit exceeded", retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000) },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
if (body.runFriendlyIds.length > MAX_RUNS) {
|
||||
return json(
|
||||
{ error: `A maximum of ${MAX_RUNS} runs can be cancelled per request` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { runFriendlyIds } = body;
|
||||
|
||||
if (runFriendlyIds.length === 0) {
|
||||
return json({ cancelled: 0 }, { status: 200 });
|
||||
}
|
||||
|
||||
logger.info("Dev disconnect: cancelling runs", {
|
||||
environmentId,
|
||||
runCount: runFriendlyIds.length,
|
||||
});
|
||||
|
||||
// For small numbers of runs, cancel inline
|
||||
if (runFriendlyIds.length <= BULK_ACTION_THRESHOLD) {
|
||||
const cancelled = await cancelRunsInline(runFriendlyIds, environmentId);
|
||||
return json({ cancelled }, { status: 200 });
|
||||
}
|
||||
|
||||
// For large numbers, create a bulk action to process asynchronously
|
||||
const bulkActionId = await createBulkCancelAction(
|
||||
runFriendlyIds,
|
||||
authentication.environment.project.id,
|
||||
environmentId
|
||||
);
|
||||
|
||||
logger.info("Dev disconnect: created bulk action for large run set", {
|
||||
environmentId,
|
||||
bulkActionId,
|
||||
runCount: runFriendlyIds.length,
|
||||
});
|
||||
|
||||
return json({ cancelled: 0, bulkActionId }, { status: 200 });
|
||||
}
|
||||
);
|
||||
|
||||
async function cancelRunsInline(
|
||||
runFriendlyIds: string[],
|
||||
environmentId: string
|
||||
): Promise<number> {
|
||||
const runIds = runFriendlyIds.map((fid) => RunId.toId(fid));
|
||||
|
||||
const runs = await prisma.taskRun.findMany({
|
||||
where: {
|
||||
id: { in: runIds },
|
||||
runtimeEnvironmentId: environmentId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
engine: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
taskEventStore: true,
|
||||
},
|
||||
});
|
||||
|
||||
let cancelled = 0;
|
||||
const cancelService = new CancelTaskRunService(prisma);
|
||||
|
||||
await pMap(
|
||||
runs,
|
||||
async (run) => {
|
||||
const [error, result] = await tryCatch(
|
||||
cancelService.call(run, { reason: CANCEL_REASON, finalizeRun: true })
|
||||
);
|
||||
|
||||
if (error) {
|
||||
logger.error("Dev disconnect: failed to cancel run", {
|
||||
runId: run.id,
|
||||
error,
|
||||
});
|
||||
} else if (result && !result.alreadyFinished) {
|
||||
cancelled++;
|
||||
}
|
||||
},
|
||||
{ concurrency: 10 }
|
||||
);
|
||||
|
||||
logger.info("Dev disconnect: completed inline cancellation", {
|
||||
environmentId,
|
||||
cancelled,
|
||||
total: runFriendlyIds.length,
|
||||
});
|
||||
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
async function createBulkCancelAction(
|
||||
runFriendlyIds: string[],
|
||||
projectId: string,
|
||||
environmentId: string
|
||||
): Promise<string> {
|
||||
const { id, friendlyId } = BulkActionId.generate();
|
||||
|
||||
await prisma.bulkActionGroup.create({
|
||||
data: {
|
||||
id,
|
||||
friendlyId,
|
||||
projectId,
|
||||
environmentId,
|
||||
name: "Dev session disconnect",
|
||||
type: BulkActionType.CANCEL,
|
||||
params: { runId: runFriendlyIds, finalizeRun: true },
|
||||
queryName: "bulk_action_v1",
|
||||
totalCount: runFriendlyIds.length,
|
||||
completionNotification: BulkActionNotificationType.NONE,
|
||||
},
|
||||
});
|
||||
|
||||
await commonWorker.enqueue({
|
||||
id: `processBulkAction-${id}`,
|
||||
job: "processBulkAction",
|
||||
payload: { bulkActionId: id },
|
||||
});
|
||||
|
||||
return friendlyId;
|
||||
}
|
||||
|
||||
export { action };
|
||||
@@ -138,11 +138,13 @@ export class BulkActionService extends BaseService {
|
||||
}
|
||||
|
||||
// 2. Parse the params
|
||||
const rawParams = group.params && typeof group.params === "object" ? group.params : {};
|
||||
const finalizeRun = "finalizeRun" in rawParams && (rawParams as any).finalizeRun === true;
|
||||
const filters = parseRunListInputOptions({
|
||||
organizationId: group.project.organizationId,
|
||||
projectId: group.projectId,
|
||||
environmentId: group.environmentId,
|
||||
...(group.params && typeof group.params === "object" ? group.params : {}),
|
||||
...rawParams,
|
||||
});
|
||||
|
||||
const runsRepository = new RunsRepository({
|
||||
@@ -199,6 +201,7 @@ export class BulkActionService extends BaseService {
|
||||
cancelService.call(run, {
|
||||
reason: `Bulk action ${group.friendlyId} cancelled run`,
|
||||
bulkActionId: bulkActionId,
|
||||
finalizeRun,
|
||||
})
|
||||
);
|
||||
if (error) {
|
||||
|
||||
@@ -8,6 +8,8 @@ export type CancelTaskRunServiceOptions = {
|
||||
cancelAttempts?: boolean;
|
||||
cancelledAt?: Date;
|
||||
bulkActionId?: string;
|
||||
/** Skip PENDING_CANCEL and finalize immediately (use when the worker is known to be dead). */
|
||||
finalizeRun?: boolean;
|
||||
};
|
||||
|
||||
type CancelTaskRunServiceResult = {
|
||||
@@ -57,6 +59,7 @@ export class CancelTaskRunService extends BaseService {
|
||||
runId: taskRun.id,
|
||||
completedAt: options?.cancelledAt,
|
||||
reason: options?.reason,
|
||||
finalizeRun: options?.finalizeRun,
|
||||
bulkActionId: options?.bulkActionId,
|
||||
tx: this._prisma,
|
||||
});
|
||||
|
||||
@@ -1436,35 +1436,39 @@ export class RunAttemptSystem {
|
||||
});
|
||||
|
||||
//if executing, we need to message the worker to cancel the run and put it into `PENDING_CANCEL` status
|
||||
//unless finalizeRun is true (worker is known to be dead), in which case skip straight to FINISHED
|
||||
if (
|
||||
isExecuting(latestSnapshot.executionStatus) ||
|
||||
isPendingExecuting(latestSnapshot.executionStatus)
|
||||
) {
|
||||
const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, {
|
||||
run,
|
||||
snapshot: {
|
||||
executionStatus: "PENDING_CANCEL",
|
||||
description: "Run was cancelled",
|
||||
},
|
||||
previousSnapshotId: latestSnapshot.id,
|
||||
environmentId: latestSnapshot.environmentId,
|
||||
environmentType: latestSnapshot.environmentType,
|
||||
projectId: latestSnapshot.projectId,
|
||||
organizationId: latestSnapshot.organizationId,
|
||||
workerId,
|
||||
runnerId,
|
||||
});
|
||||
if (!finalizeRun) {
|
||||
const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, {
|
||||
run,
|
||||
snapshot: {
|
||||
executionStatus: "PENDING_CANCEL",
|
||||
description: "Run was cancelled",
|
||||
},
|
||||
previousSnapshotId: latestSnapshot.id,
|
||||
environmentId: latestSnapshot.environmentId,
|
||||
environmentType: latestSnapshot.environmentType,
|
||||
projectId: latestSnapshot.projectId,
|
||||
organizationId: latestSnapshot.organizationId,
|
||||
workerId,
|
||||
runnerId,
|
||||
});
|
||||
|
||||
//the worker needs to be notified so it can kill the run and complete the attempt
|
||||
await sendNotificationToWorker({
|
||||
runId,
|
||||
snapshot: newSnapshot,
|
||||
eventBus: this.$.eventBus,
|
||||
});
|
||||
return {
|
||||
alreadyFinished: false,
|
||||
...executionResultFromSnapshot(newSnapshot),
|
||||
};
|
||||
//the worker needs to be notified so it can kill the run and complete the attempt
|
||||
await sendNotificationToWorker({
|
||||
runId,
|
||||
snapshot: newSnapshot,
|
||||
eventBus: this.$.eventBus,
|
||||
});
|
||||
return {
|
||||
alreadyFinished: false,
|
||||
...executionResultFromSnapshot(newSnapshot),
|
||||
};
|
||||
}
|
||||
// finalizeRun is true — fall through to finish the run immediately
|
||||
}
|
||||
|
||||
//not executing, so we will actually finish the run
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
DevConfigResponseBody,
|
||||
DevDequeueRequestBody,
|
||||
DevDequeueResponseBody,
|
||||
DevDisconnectRequestBody,
|
||||
DevDisconnectResponseBody,
|
||||
EnvironmentVariableResponseBody,
|
||||
FailDeploymentRequestBody,
|
||||
FailDeploymentResponseBody,
|
||||
@@ -557,6 +559,7 @@ export class CliApiClient {
|
||||
heartbeatRun: this.devHeartbeatRun.bind(this),
|
||||
startRunAttempt: this.devStartRunAttempt.bind(this),
|
||||
completeRunAttempt: this.devCompleteRunAttempt.bind(this),
|
||||
disconnect: this.devDisconnect.bind(this),
|
||||
setEngineURL: this.setEngineURL.bind(this),
|
||||
} as const;
|
||||
}
|
||||
@@ -681,6 +684,23 @@ export class CliApiClient {
|
||||
return eventSource;
|
||||
}
|
||||
|
||||
private async devDisconnect(
|
||||
body: DevDisconnectRequestBody
|
||||
): Promise<ApiResult<DevDisconnectResponseBody>> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("devDisconnect: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(DevDisconnectResponseBody, `${this.engineURL}/engine/v1/dev/disconnect`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
private async devDequeue(
|
||||
body: DevDequeueRequestBody
|
||||
): Promise<ApiResult<DevDequeueResponseBody>> {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setTimeout as awaitTimeout } from "node:timers/promises";
|
||||
import {
|
||||
BuildManifest,
|
||||
@@ -71,6 +75,11 @@ class DevSupervisor implements WorkerRuntime {
|
||||
private runLimiter?: ReturnType<typeof pLimit>;
|
||||
private taskRunProcessPool?: TaskRunProcessPool;
|
||||
|
||||
/** Detached watchdog process that cancels runs if the CLI is killed */
|
||||
private watchdogProcess?: ChildProcess;
|
||||
private activeRunsPath?: string;
|
||||
private watchdogPidPath?: string;
|
||||
|
||||
constructor(public readonly options: WorkerRuntimeOptions) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -138,25 +147,39 @@ class DevSupervisor implements WorkerRuntime {
|
||||
//start an SSE connection for presence
|
||||
this.disconnectPresence = await this.#startPresenceConnection();
|
||||
|
||||
// Handle SIGTERM to gracefully stop all run controllers
|
||||
// Handle SIGTERM/SIGINT to gracefully stop all run controllers
|
||||
process.on("SIGTERM", this.#handleSigterm);
|
||||
process.on("SIGINT", this.#handleSigterm);
|
||||
|
||||
// Spawn detached watchdog to cancel runs if CLI is killed (e.g. pnpm SIGKILL)
|
||||
this.#spawnWatchdog();
|
||||
|
||||
//start dequeuing
|
||||
await this.#dequeueRuns();
|
||||
}
|
||||
|
||||
#handleSigterm = async () => {
|
||||
logger.debug("[DevSupervisor] Received SIGTERM, stopping all run controllers");
|
||||
logger.debug("[DevSupervisor] Received SIGTERM/SIGINT, stopping all run controllers");
|
||||
|
||||
const stopPromises = Array.from(this.runControllers.values()).map((controller) =>
|
||||
controller.stop()
|
||||
);
|
||||
await this.shutdown();
|
||||
|
||||
await Promise.allSettled(stopPromises);
|
||||
// Must exit explicitly since registering a custom SIGINT handler
|
||||
// overrides Node's default process termination behavior.
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
process.off("SIGTERM", this.#handleSigterm);
|
||||
process.off("SIGINT", this.#handleSigterm);
|
||||
|
||||
// Stop all local run controllers first so active-runs.json is up-to-date
|
||||
const stopPromises = Array.from(this.runControllers.values()).map((controller) =>
|
||||
controller.stop()
|
||||
);
|
||||
await Promise.allSettled(stopPromises);
|
||||
|
||||
// Kill watchdog on clean shutdown — no disconnect needed since runs are stopped locally
|
||||
this.#killWatchdog();
|
||||
|
||||
this.disconnectPresence?.();
|
||||
try {
|
||||
@@ -177,6 +200,107 @@ class DevSupervisor implements WorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
#spawnWatchdog() {
|
||||
const triggerDir = join(this.options.config.workingDir, ".trigger");
|
||||
if (!existsSync(triggerDir)) {
|
||||
mkdirSync(triggerDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.activeRunsPath = join(triggerDir, "active-runs.json");
|
||||
this.watchdogPidPath = join(triggerDir, "watchdog.pid");
|
||||
|
||||
// Write empty active-runs file
|
||||
this.#updateActiveRunsFile();
|
||||
|
||||
// Resolve the compiled watchdog script path relative to this file
|
||||
const thisDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const watchdogScript = join(thisDir, "devWatchdog.js");
|
||||
|
||||
if (!existsSync(watchdogScript)) {
|
||||
logger.debug("[DevSupervisor] Watchdog script not found, skipping", { watchdogScript });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.watchdogProcess = spawn(process.execPath, [watchdogScript], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
WATCHDOG_PARENT_PID: process.pid.toString(),
|
||||
WATCHDOG_API_URL: this.config?.engineUrl ?? this.options.client.apiURL,
|
||||
WATCHDOG_API_KEY: this.options.client.accessToken ?? "",
|
||||
WATCHDOG_ACTIVE_RUNS: this.activeRunsPath,
|
||||
WATCHDOG_PID_FILE: this.watchdogPidPath,
|
||||
},
|
||||
});
|
||||
|
||||
this.watchdogProcess.unref();
|
||||
|
||||
logger.debug("[DevSupervisor] Spawned watchdog", {
|
||||
watchdogPid: this.watchdogProcess.pid,
|
||||
parentPid: process.pid,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.debug("[DevSupervisor] Failed to spawn watchdog", { error });
|
||||
}
|
||||
}
|
||||
|
||||
#killWatchdog() {
|
||||
const knownPid = this.watchdogProcess?.pid;
|
||||
|
||||
if (knownPid) {
|
||||
try {
|
||||
process.kill(knownPid, "SIGTERM");
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
this.watchdogProcess = undefined;
|
||||
}
|
||||
|
||||
// Fallback: try via PID file, but only if the PID matches our spawned watchdog
|
||||
// to avoid killing an unrelated process that reused a stale PID
|
||||
if (this.watchdogPidPath) {
|
||||
try {
|
||||
const content = readFileSync(this.watchdogPidPath, "utf8");
|
||||
const prefix = "trigger-watchdog:";
|
||||
if (content.startsWith(prefix)) {
|
||||
const pid = parseInt(content.slice(prefix.length), 10);
|
||||
if (pid && (!knownPid || pid === knownPid)) {
|
||||
process.kill(pid, "SIGTERM");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Already dead or no file
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up files
|
||||
try {
|
||||
if (this.activeRunsPath) unlinkSync(this.activeRunsPath);
|
||||
} catch {}
|
||||
try {
|
||||
if (this.watchdogPidPath) unlinkSync(this.watchdogPidPath);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
#updateActiveRunsFile() {
|
||||
if (!this.activeRunsPath) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
parentPid: process.pid,
|
||||
runFriendlyIds: Array.from(this.runControllers.keys()),
|
||||
};
|
||||
// Atomic write: write to temp file then rename to avoid corrupt reads
|
||||
const tmpPath = this.activeRunsPath + ".tmp";
|
||||
writeFileSync(tmpPath, JSON.stringify(data));
|
||||
renameSync(tmpPath, this.activeRunsPath);
|
||||
} catch (error) {
|
||||
logger.debug("[DevSupervisor] Failed to update active-runs file", { error });
|
||||
}
|
||||
}
|
||||
|
||||
async initializeWorker(
|
||||
manifest: BuildManifest,
|
||||
metafile: Metafile,
|
||||
@@ -386,6 +510,7 @@ class DevSupervisor implements WorkerRuntime {
|
||||
//stop the run controller, and remove it
|
||||
runController?.stop();
|
||||
this.runControllers.delete(message.run.friendlyId);
|
||||
this.#updateActiveRunsFile();
|
||||
this.#unsubscribeFromRunNotifications(message.run.friendlyId);
|
||||
|
||||
//stop the worker if it is deprecated and there are no more runs
|
||||
@@ -402,6 +527,7 @@ class DevSupervisor implements WorkerRuntime {
|
||||
});
|
||||
|
||||
this.runControllers.set(message.run.friendlyId, runController);
|
||||
this.#updateActiveRunsFile();
|
||||
|
||||
if (this.runLimiter) {
|
||||
this.runLimiter(() => runController.start(message)).then(() => {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Dev Watchdog — a detached process that cancels in-flight runs when the dev CLI exits.
|
||||
*
|
||||
* Spawned by the dev CLI with `detached: true, stdio: "ignore", unref()`.
|
||||
* Survives when pnpm sends SIGKILL to the CLI process tree.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. CLI spawns this script, passing config via env vars
|
||||
* 2. Writes PID file for single-instance guarantee
|
||||
* 3. Polls parent PID to detect when the CLI exits
|
||||
* 4. On parent death: reads active-runs file → calls disconnect endpoint → exits
|
||||
*
|
||||
* Environment variables:
|
||||
* WATCHDOG_PARENT_PID - The PID of the parent dev CLI process
|
||||
* WATCHDOG_API_URL - The Trigger.dev API/engine URL
|
||||
* WATCHDOG_API_KEY - The API key for authentication
|
||||
* WATCHDOG_ACTIVE_RUNS - Path to the active-runs JSON file
|
||||
* WATCHDOG_PID_FILE - Path to write the watchdog PID file
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
// Safety timeout: if the watchdog has been running for 24 hours, exit regardless.
|
||||
// Prevents zombie watchdogs from PID reuse scenarios.
|
||||
const MAX_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const parentPid = parseInt(process.env.WATCHDOG_PARENT_PID!, 10);
|
||||
const apiUrl = process.env.WATCHDOG_API_URL!;
|
||||
const apiKey = process.env.WATCHDOG_API_KEY!;
|
||||
const activeRunsPath = process.env.WATCHDOG_ACTIVE_RUNS!;
|
||||
const pidFilePath = process.env.WATCHDOG_PID_FILE!;
|
||||
|
||||
if (!parentPid || !apiUrl || !apiKey || !activeRunsPath || !pidFilePath) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = dirname(pidFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const PID_FILE_PREFIX = "trigger-watchdog:";
|
||||
|
||||
// Single instance: kill any existing watchdog
|
||||
try {
|
||||
const pidFileContent = readFileSync(pidFilePath, "utf8");
|
||||
if (pidFileContent.startsWith(PID_FILE_PREFIX)) {
|
||||
const existingPid = parseInt(pidFileContent.slice(PID_FILE_PREFIX.length), 10);
|
||||
if (existingPid && existingPid !== process.pid) {
|
||||
try {
|
||||
process.kill(existingPid, 0); // Check if alive
|
||||
process.kill(existingPid, "SIGTERM"); // Kill it
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No PID file or invalid format
|
||||
}
|
||||
|
||||
// Write our PID with prefix so we can verify ownership later
|
||||
writeFileSync(pidFilePath, `${PID_FILE_PREFIX}${process.pid}`);
|
||||
|
||||
function cleanup() {
|
||||
try {
|
||||
unlinkSync(pidFilePath);
|
||||
} catch {}
|
||||
try {
|
||||
unlinkSync(activeRunsPath);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isParentAlive(): boolean {
|
||||
try {
|
||||
process.kill(parentPid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readActiveRuns(): string[] {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(activeRunsPath, "utf8"));
|
||||
return data.runFriendlyIds ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function callDisconnect(runFriendlyIds: string[]): Promise<void> {
|
||||
const response = await fetch(`${apiUrl}/engine/v1/dev/disconnect`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ runFriendlyIds }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Disconnect failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DISCONNECT_ATTEMPTS = 5;
|
||||
const INITIAL_BACKOFF_MS = 500;
|
||||
|
||||
async function onParentDied(): Promise<void> {
|
||||
const runFriendlyIds = readActiveRuns();
|
||||
|
||||
if (runFriendlyIds.length > 0) {
|
||||
for (let attempt = 0; attempt < MAX_DISCONNECT_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await callDisconnect(runFriendlyIds);
|
||||
break;
|
||||
} catch {
|
||||
if (attempt < MAX_DISCONNECT_ATTEMPTS - 1) {
|
||||
const backoff = INITIAL_BACKOFF_MS * 2 ** attempt;
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff));
|
||||
}
|
||||
// Final attempt failed — runs will eventually time out via heartbeat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Guard against overlapping async callbacks
|
||||
let checking = false;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (checking) return;
|
||||
checking = true;
|
||||
|
||||
try {
|
||||
if (!isParentAlive()) {
|
||||
clearInterval(interval);
|
||||
clearTimeout(lifetimeTimeout);
|
||||
await onParentDied();
|
||||
}
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
// Safety timeout: exit after MAX_LIFETIME_MS to prevent zombie watchdogs
|
||||
const lifetimeTimeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}, MAX_LIFETIME_MS);
|
||||
|
||||
// Unref the timeout so it doesn't keep the process alive if the interval is cleared
|
||||
lifetimeTimeout.unref();
|
||||
|
||||
// Clean exit on any termination signal
|
||||
function handleSignal() {
|
||||
clearInterval(interval);
|
||||
clearTimeout(lifetimeTimeout);
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", handleSignal);
|
||||
process.on("SIGINT", handleSignal);
|
||||
@@ -838,6 +838,17 @@ export const DevDequeueResponseBody = z.object({
|
||||
});
|
||||
export type DevDequeueResponseBody = z.infer<typeof DevDequeueResponseBody>;
|
||||
|
||||
export const DevDisconnectRequestBody = z.object({
|
||||
runFriendlyIds: z.string().array(),
|
||||
});
|
||||
export type DevDisconnectRequestBody = z.infer<typeof DevDisconnectRequestBody>;
|
||||
|
||||
export const DevDisconnectResponseBody = z.object({
|
||||
cancelled: z.number(),
|
||||
bulkActionId: z.string().optional(),
|
||||
});
|
||||
export type DevDisconnectResponseBody = z.infer<typeof DevDisconnectResponseBody>;
|
||||
|
||||
export type CreateUploadPayloadUrlResponseBody = z.infer<typeof CreateUploadPayloadUrlResponseBody>;
|
||||
|
||||
export const ReplayRunResponse = z.object({
|
||||
|
||||
Reference in New Issue
Block a user