Skip to content

Commit 9082233

Browse files
committed
fix: unblock timed cron cancellation
1 parent 24196e0 commit 9082233

3 files changed

Lines changed: 44 additions & 31 deletions

File tree

src/cron/service/timer.regression.test.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,7 @@ describe("cron service timer regressions", () => {
937937
}
938938
});
939939

940-
it("keeps timeout cleanup armed after operator cancellation", async () => {
940+
it("unwinds timed cron runs immediately after operator cancellation", async () => {
941941
vi.useFakeTimers();
942942
try {
943943
const store = timerRegressionFixtures.makeStorePath();
@@ -973,6 +973,10 @@ describe("cron service timer regressions", () => {
973973
const timerPromise = onTimer(state);
974974
const observedAbortSignal = await runnerStarted.promise;
975975
const runId = `cron:cancel-before-timeout:${scheduledAt}`;
976+
let timerSettled = false;
977+
void timerPromise.then(() => {
978+
timerSettled = true;
979+
});
976980
const cancelled = cancelActiveCronTaskRun({
977981
runId,
978982
reason: "Cancelled by operator.",
@@ -981,18 +985,17 @@ describe("cron service timer regressions", () => {
981985
expect(cancelled).toBe(true);
982986
expect(observedAbortSignal?.aborted).toBe(true);
983987

984-
await vi.advanceTimersByTimeAsync(Math.ceil(FAST_TIMEOUT_SECONDS * 1_000) + 10);
988+
for (let attempt = 0; attempt < 5 && !timerSettled; attempt += 1) {
989+
await vi.advanceTimersByTimeAsync(0);
990+
await Promise.resolve();
991+
}
992+
expect(timerSettled).toBe(true);
985993
await timerPromise;
986994

987-
expect(cleanupTimedOutAgentRun).toHaveBeenCalledWith(
988-
expect.objectContaining({
989-
job: expect.objectContaining({ id: "cancel-before-timeout" }),
990-
timeoutMs: FAST_TIMEOUT_SECONDS * 1_000,
991-
}),
992-
);
995+
expect(cleanupTimedOutAgentRun).not.toHaveBeenCalled();
993996
const job = state.store?.jobs.find((entry) => entry.id === "cancel-before-timeout");
994997
expect(job?.state.lastStatus).toBe("error");
995-
expect(job?.state.lastError).toContain("timed out");
998+
expect(job?.state.lastError).toBe("Cancelled by operator.");
996999
} finally {
9971000
vi.useRealTimers();
9981001
}

src/cron/service/timer.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,21 @@ export async function executeJobCoreWithTimeout(
125125
opts?: { runId?: string },
126126
): Promise<Awaited<ReturnType<typeof executeJobCore>>> {
127127
const runAbortController = new AbortController();
128+
const operatorCancellationMarker = Symbol("cron-operator-cancelled");
129+
let resolveOperatorCancellation: ((value: typeof operatorCancellationMarker) => void) | undefined;
130+
const operatorCancellationPromise = new Promise<typeof operatorCancellationMarker>((resolve) => {
131+
resolveOperatorCancellation = resolve;
132+
});
133+
const createOperatorCancellationOutcome = () => {
134+
const error = abortErrorMessage(runAbortController.signal);
135+
return {
136+
status: "error" as const,
137+
error,
138+
diagnostics: createCronRunDiagnosticsFromError("cron-setup", error, {
139+
nowMs: state.deps.nowMs,
140+
}),
141+
};
142+
};
128143
// Main-session cron jobs enqueue work into a downstream child session. The
129144
// cron wrapper does not own that queued run, so it must not expose a task
130145
// cancellation handle that could make the wrapper row lie about child state.
@@ -133,20 +148,12 @@ export async function executeJobCoreWithTimeout(
133148
? registerActiveCronTaskRun({
134149
runId: opts?.runId,
135150
controller: runAbortController,
151+
onCancel: () => resolveOperatorCancellation?.(operatorCancellationMarker),
136152
})
137153
: undefined;
138154
const jobTimeoutMs = resolveCronJobTimeoutMs(job);
139155
try {
140156
if (typeof jobTimeoutMs !== "number") {
141-
const cancellationMarker = Symbol("cron-cancelled");
142-
const cancellationPromise = new Promise<typeof cancellationMarker>((resolve) => {
143-
const resolveCancelled = () => resolve(cancellationMarker);
144-
if (runAbortController.signal.aborted) {
145-
resolveCancelled();
146-
return;
147-
}
148-
runAbortController.signal.addEventListener("abort", resolveCancelled, { once: true });
149-
});
150157
const corePromise = executeJobCore(state, job, runAbortController.signal);
151158
void corePromise.catch((err: unknown) => {
152159
if (runAbortController.signal.aborted) {
@@ -156,18 +163,11 @@ export async function executeJobCoreWithTimeout(
156163
);
157164
}
158165
});
159-
const first = await Promise.race([corePromise, cancellationPromise]);
160-
if (first !== cancellationMarker) {
166+
const first = await Promise.race([corePromise, operatorCancellationPromise]);
167+
if (first !== operatorCancellationMarker) {
161168
return first;
162169
}
163-
const error = abortErrorMessage(runAbortController.signal);
164-
return {
165-
status: "error",
166-
error,
167-
diagnostics: createCronRunDiagnosticsFromError("cron-setup", error, {
168-
nowMs: state.deps.nowMs,
169-
}),
170-
};
170+
return createOperatorCancellationOutcome();
171171
}
172172

173173
let timeoutReason: string | undefined;
@@ -207,7 +207,10 @@ export async function executeJobCoreWithTimeout(
207207
}
208208
});
209209
try {
210-
const first = await Promise.race([corePromise, timeoutPromise]);
210+
const first = await Promise.race([corePromise, timeoutPromise, operatorCancellationPromise]);
211+
if (first === operatorCancellationMarker) {
212+
return createOperatorCancellationOutcome();
213+
}
211214
if (first !== timeoutMarker) {
212215
return first;
213216
}

src/tasks/cron-task-cancel.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@
22

33
type CronTaskCancelHandle = {
44
controller: AbortController;
5+
onCancel?: (reason: string) => void;
56
};
67

78
const activeCronTaskRunsByRunId = new Map<string, CronTaskCancelHandle>();
89

910
export function registerActiveCronTaskRun(params: {
1011
runId: string | undefined;
1112
controller: AbortController;
13+
onCancel?: (reason: string) => void;
1214
}): (() => void) | undefined {
1315
const runId = params.runId?.trim();
1416
if (!runId) {
1517
return undefined;
1618
}
17-
activeCronTaskRunsByRunId.set(runId, { controller: params.controller });
19+
activeCronTaskRunsByRunId.set(runId, {
20+
controller: params.controller,
21+
onCancel: params.onCancel,
22+
});
1823
return () => {
1924
if (activeCronTaskRunsByRunId.get(runId)?.controller === params.controller) {
2025
activeCronTaskRunsByRunId.delete(runId);
@@ -37,7 +42,9 @@ export function cancelActiveCronTaskRun(params: {
3742
if (handle.controller.signal.aborted) {
3843
return false;
3944
}
40-
handle.controller.abort(params.reason?.trim() || "Cancelled by operator.");
45+
const reason = params.reason?.trim() || "Cancelled by operator.";
46+
handle.controller.abort(reason);
47+
handle.onCancel?.(reason);
4148
return true;
4249
}
4350

0 commit comments

Comments
 (0)