From 8675218df188e22f1d1efa226cd8a0fa6a25fef5 Mon Sep 17 00:00:00 2001 From: Prai'ns <17417227+Prains@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:58:10 +0300 Subject: [PATCH 1/5] fix(tasks): unref scheduled task timers --- src/runtime/internal/task.ts | 2 +- test/unit/task.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 test/unit/task.test.ts diff --git a/src/runtime/internal/task.ts b/src/runtime/internal/task.ts index f119cefd3d..36db38fdb6 100644 --- a/src/runtime/internal/task.ts +++ b/src/runtime/internal/task.ts @@ -65,7 +65,7 @@ export function startScheduleRunner({ }; for (const schedule of scheduledTasks) { - new Cron(schedule.cron, async () => { + new Cron(schedule.cron, { unref: true }, async () => { await Promise.all( schedule.tasks.map((name) => runTask(name, { diff --git a/test/unit/task.test.ts b/test/unit/task.test.ts new file mode 100644 index 0000000000..bfd4a6cb65 --- /dev/null +++ b/test/unit/task.test.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const cronMock = vi.hoisted(() => vi.fn()); + +vi.mock("croner", () => ({ Cron: cronMock })); +vi.mock("#nitro/virtual/tasks", () => ({ + scheduledTasks: [{ cron: "*/5 * * * *", tasks: ["test"] }], + tasks: {}, +})); + +import { startScheduleRunner } from "../../src/runtime/internal/task.ts"; + +describe("startScheduleRunner", () => { + let testEnvironment: string | undefined; + + beforeEach(() => { + testEnvironment = process.env.TEST; + delete process.env.TEST; + cronMock.mockClear(); + }); + + afterEach(() => { + process.env.TEST = testEnvironment; + }); + + it("does not keep the process alive between scheduled runs", () => { + startScheduleRunner(); + + expect(cronMock).toHaveBeenCalledWith("*/5 * * * *", { unref: true }, expect.any(Function)); + }); +}); From 626c9129683e483490cb6ffe8f7d2feef89148e8 Mon Sep 17 00:00:00 2001 From: Prai'ns <17417227+Prains@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:13:45 +0300 Subject: [PATCH 2/5] fix(tasks): stop schedules on runtime close --- src/runtime/internal/task.ts | 34 ++++++++++++++++++++++------------ test/unit/task.test.ts | 31 +++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/runtime/internal/task.ts b/src/runtime/internal/task.ts index 36db38fdb6..e2bb58df6f 100644 --- a/src/runtime/internal/task.ts +++ b/src/runtime/internal/task.ts @@ -1,6 +1,7 @@ import { Cron } from "croner"; import { HTTPError } from "h3"; import type { Task, TaskContext, TaskEvent, TaskPayload, TaskResult } from "nitro/types"; +import { useNitroHooks } from "#nitro/runtime/app"; import { scheduledTasks, tasks } from "#nitro/virtual/tasks"; /** @experimental */ @@ -64,19 +65,28 @@ export function startScheduleRunner({ scheduledTime: Date.now(), }; + const cronJobs: Cron[] = []; + useNitroHooks().hook("close", () => { + for (const job of cronJobs) { + job.stop(); + } + }); + for (const schedule of scheduledTasks) { - new Cron(schedule.cron, { unref: true }, async () => { - await Promise.all( - schedule.tasks.map((name) => - runTask(name, { - payload, - context: { waitUntil }, - }).catch((error) => { - console.error(`Error while running scheduled task "${name}"`, error); - }) - ) - ); - }); + cronJobs.push( + new Cron(schedule.cron, async () => { + await Promise.all( + schedule.tasks.map((name) => + runTask(name, { + payload, + context: { waitUntil }, + }).catch((error) => { + console.error(`Error while running scheduled task "${name}"`, error); + }) + ) + ); + }) + ); } } diff --git a/test/unit/task.test.ts b/test/unit/task.test.ts index bfd4a6cb65..356831fc56 100644 --- a/test/unit/task.test.ts +++ b/test/unit/task.test.ts @@ -1,8 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const cronMock = vi.hoisted(() => vi.fn()); +const { closeHookMock, cronMock, cronStopMock } = vi.hoisted(() => { + const cronStopMock = vi.fn(); + return { + closeHookMock: vi.fn(), + cronMock: vi.fn(function () { + return { stop: cronStopMock }; + }), + cronStopMock, + }; +}); vi.mock("croner", () => ({ Cron: cronMock })); +vi.mock("#nitro/runtime/app", () => ({ + useNitroHooks: () => ({ hook: closeHookMock }), +})); vi.mock("#nitro/virtual/tasks", () => ({ scheduledTasks: [{ cron: "*/5 * * * *", tasks: ["test"] }], tasks: {}, @@ -16,16 +28,27 @@ describe("startScheduleRunner", () => { beforeEach(() => { testEnvironment = process.env.TEST; delete process.env.TEST; - cronMock.mockClear(); + vi.clearAllMocks(); }); afterEach(() => { process.env.TEST = testEnvironment; }); - it("does not keep the process alive between scheduled runs", () => { + it("keeps the process alive between scheduled runs", () => { + startScheduleRunner(); + + expect(cronMock).toHaveBeenCalledWith("*/5 * * * *", expect.any(Function)); + }); + + it("stops the schedule runner when the Nitro app closes", () => { startScheduleRunner(); - expect(cronMock).toHaveBeenCalledWith("*/5 * * * *", { unref: true }, expect.any(Function)); + expect(closeHookMock).toHaveBeenCalledWith("close", expect.any(Function)); + + const closeScheduleRunner = closeHookMock.mock.calls[0]![1]; + closeScheduleRunner(); + + expect(cronStopMock).toHaveBeenCalledOnce(); }); }); From 050248b3f9c0215d10395198827442de0370c860 Mon Sep 17 00:00:00 2001 From: Prai'ns <17417227+Prains@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:20:14 +0300 Subject: [PATCH 3/5] fix(tasks): isolate shutdown cleanup failures --- src/runtime/internal/task.ts | 6 +++++- test/unit/task.test.ts | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/runtime/internal/task.ts b/src/runtime/internal/task.ts index e2bb58df6f..f213b6bfe0 100644 --- a/src/runtime/internal/task.ts +++ b/src/runtime/internal/task.ts @@ -68,7 +68,11 @@ export function startScheduleRunner({ const cronJobs: Cron[] = []; useNitroHooks().hook("close", () => { for (const job of cronJobs) { - job.stop(); + try { + job.stop(); + } catch (error) { + console.error("Error while stopping scheduled task", error); + } } }); diff --git a/test/unit/task.test.ts b/test/unit/task.test.ts index 356831fc56..9ac9c65906 100644 --- a/test/unit/task.test.ts +++ b/test/unit/task.test.ts @@ -16,7 +16,10 @@ vi.mock("#nitro/runtime/app", () => ({ useNitroHooks: () => ({ hook: closeHookMock }), })); vi.mock("#nitro/virtual/tasks", () => ({ - scheduledTasks: [{ cron: "*/5 * * * *", tasks: ["test"] }], + scheduledTasks: [ + { cron: "*/5 * * * *", tasks: ["test"] }, + { cron: "*/10 * * * *", tasks: ["test"] }, + ], tasks: {}, })); @@ -33,6 +36,7 @@ describe("startScheduleRunner", () => { afterEach(() => { process.env.TEST = testEnvironment; + vi.restoreAllMocks(); }); it("keeps the process alive between scheduled runs", () => { @@ -49,6 +53,30 @@ describe("startScheduleRunner", () => { const closeScheduleRunner = closeHookMock.mock.calls[0]![1]; closeScheduleRunner(); - expect(cronStopMock).toHaveBeenCalledOnce(); + expect(cronStopMock).toHaveBeenCalledTimes(2); + }); + + it("continues stopping schedules when one Cron job fails to stop", () => { + const error = new Error("stop failed"); + const firstStopMock = vi.fn(() => { + throw error; + }); + const secondStopMock = vi.fn(); + const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {}); + cronMock + .mockImplementationOnce(function () { + return { stop: firstStopMock }; + }) + .mockImplementationOnce(function () { + return { stop: secondStopMock }; + }); + + startScheduleRunner(); + const closeScheduleRunner = closeHookMock.mock.calls[0]![1]; + + expect(() => closeScheduleRunner()).not.toThrow(); + expect(firstStopMock).toHaveBeenCalledOnce(); + expect(secondStopMock).toHaveBeenCalledOnce(); + expect(consoleErrorMock).toHaveBeenCalledWith("Error while stopping scheduled task", error); }); }); From 7612a1a03537d95e784cfef728321108d8a331e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B5=D1=80=D0=B3=D0=B5=D0=B9=20=D0=9B=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2?= Date: Tue, 8 Sep 2026 12:40:00 +0300 Subject: [PATCH 4/5] fix(tasks): import runtime hooks directly Co-Authored-By: Claude Code --- src/runtime/internal/task.ts | 2 +- test/unit/task.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/internal/task.ts b/src/runtime/internal/task.ts index f213b6bfe0..0b03edecfa 100644 --- a/src/runtime/internal/task.ts +++ b/src/runtime/internal/task.ts @@ -1,7 +1,7 @@ import { Cron } from "croner"; import { HTTPError } from "h3"; import type { Task, TaskContext, TaskEvent, TaskPayload, TaskResult } from "nitro/types"; -import { useNitroHooks } from "#nitro/runtime/app"; +import { useNitroHooks } from "./app.ts"; import { scheduledTasks, tasks } from "#nitro/virtual/tasks"; /** @experimental */ diff --git a/test/unit/task.test.ts b/test/unit/task.test.ts index 9ac9c65906..cc1e9b65ff 100644 --- a/test/unit/task.test.ts +++ b/test/unit/task.test.ts @@ -12,7 +12,7 @@ const { closeHookMock, cronMock, cronStopMock } = vi.hoisted(() => { }); vi.mock("croner", () => ({ Cron: cronMock })); -vi.mock("#nitro/runtime/app", () => ({ +vi.mock("../../src/runtime/internal/app.ts", () => ({ useNitroHooks: () => ({ hook: closeHookMock }), })); vi.mock("#nitro/virtual/tasks", () => ({ From 7879c6f831123e76f3fb4557a5bd73eba5cc5fb6 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 3 Oct 2026 16:59:13 +0000 Subject: [PATCH 5/5] chore(tasks): address review nits Rename leftover unref test, use vi.stubEnv for TEST, and document close-hook cleanup for node_middleware. --- docs/1.docs/50.tasks.md | 2 +- test/unit/task.test.ts | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/1.docs/50.tasks.md b/docs/1.docs/50.tasks.md index 744cca90b8..940cf78417 100644 --- a/docs/1.docs/50.tasks.md +++ b/docs/1.docs/50.tasks.md @@ -158,7 +158,7 @@ Prefer `scheduledTasks` when your deployment target supports it (see [platform s ### Platform support -- **`dev`**, **`node_server`**, **`node_cluster`**, **`node_middleware`**, **`bun`** and **`deno_server`** presets are supported by the [croner](https://croner.56k.guru/) engine. +- **`dev`**, **`node_server`**, **`node_cluster`**, **`node_middleware`**, **`bun`** and **`deno_server`** presets are supported by the [croner](https://croner.56k.guru/) engine. Schedules stop when the Nitro `close` hook runs. With `node_middleware`, Nitro does not own the server, so call `useNitroHooks().callHook("close")` when your host server shuts down. - **`cloudflare_module`** and **`cloudflare_pages`** presets have native integration with [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/). Nitro automatically generates the cron triggers in the wrangler config at build time - no manual wrangler setup required. - **`vercel`** preset has native integration with [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs). Nitro automatically generates the cron job configuration at build time - no manual `vercel.json` setup required. You can secure cron endpoints by setting the `CRON_SECRET` environment variable. - More presets (with native primitives support) are planned to be supported! diff --git a/test/unit/task.test.ts b/test/unit/task.test.ts index cc1e9b65ff..37eba95a76 100644 --- a/test/unit/task.test.ts +++ b/test/unit/task.test.ts @@ -26,23 +26,22 @@ vi.mock("#nitro/virtual/tasks", () => ({ import { startScheduleRunner } from "../../src/runtime/internal/task.ts"; describe("startScheduleRunner", () => { - let testEnvironment: string | undefined; - beforeEach(() => { - testEnvironment = process.env.TEST; - delete process.env.TEST; + vi.stubEnv("TEST", ""); vi.clearAllMocks(); }); afterEach(() => { - process.env.TEST = testEnvironment; + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); - it("keeps the process alive between scheduled runs", () => { + it("creates a Cron job for each schedule", () => { startScheduleRunner(); + expect(cronMock).toHaveBeenCalledTimes(2); expect(cronMock).toHaveBeenCalledWith("*/5 * * * *", expect.any(Function)); + expect(cronMock).toHaveBeenCalledWith("*/10 * * * *", expect.any(Function)); }); it("stops the schedule runner when the Nitro app closes", () => {