From 34a6ea12d1a0ec73325718141de3bac7f6f05cdc Mon Sep 17 00:00:00 2001 From: Yarchik Date: Fri, 12 Jun 2026 14:38:55 +0100 Subject: [PATCH 1/3] fix(task): add request timeout to devFetch so a stalled dev server socket rejects instead of hanging --- src/task.ts | 7 +++++++ src/types/runtime/task.ts | 6 ++++++ test/unit/task.test.ts | 40 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 test/unit/task.test.ts diff --git a/src/task.ts b/src/task.ts index 0344a4c3fe..7c618e23e4 100644 --- a/src/task.ts +++ b/src/task.ts @@ -37,6 +37,7 @@ const _devHint = `(is dev server running?)`; async function _getTasksContext(opts?: TaskRunnerOptions) { const cwd = resolve(process.cwd(), opts?.cwd || "."); const buildDir = resolve(cwd, opts?.buildDir || "node_modules/.nitro"); + const timeout = opts?.timeout ?? 30_000; const buildInfoPath = resolve(buildDir, "nitro.dev.json"); if (!existsSync(buildInfoPath)) { @@ -75,6 +76,7 @@ async function _getTasksContext(opts?: TaskRunnerOptions) { { socketPath, method: options?.method, + timeout, headers: { Accept: "application/json", "Content-Type": "application/json", @@ -95,6 +97,11 @@ async function _getTasksContext(opts?: TaskRunnerOptions) { } ); + request.on("timeout", () => { + // destroy with an error so the `error` handler rejects instead of + // leaving the promise pending forever on a stalled socket + request.destroy(new Error(`Request timed out after ${timeout}ms ${_devHint}`)); + }); request.on("error", (e) => reject(e)); if (options?.body) { diff --git a/src/types/runtime/task.ts b/src/types/runtime/task.ts index 2d997f746b..53fc478444 100644 --- a/src/types/runtime/task.ts +++ b/src/types/runtime/task.ts @@ -36,4 +36,10 @@ export interface Task { export interface TaskRunnerOptions { cwd?: string; buildDir?: string; + /** + * Timeout in milliseconds for requests to the dev server. + * + * @default 30_000 + */ + timeout?: number; } diff --git a/test/unit/task.test.ts b/test/unit/task.test.ts new file mode 100644 index 0000000000..a2b5de1e51 --- /dev/null +++ b/test/unit/task.test.ts @@ -0,0 +1,40 @@ +import http from "node:http"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "pathe"; +import { afterAll, describe, expect, it } from "vitest"; +import { listTasks } from "../../src/task.ts"; + +describe("task runner devFetch", () => { + const cleanups: Array<() => Promise | void> = []; + + afterAll(async () => { + for (const cleanup of cleanups) { + await cleanup(); + } + }); + + // https://github.com/unjs/nitro/issues/4292 + it("rejects instead of hanging when the dev server socket stalls", async () => { + const cwd = await mkdtemp(join(tmpdir(), "nitro-task-test-")); + cleanups.push(() => rm(cwd, { recursive: true, force: true })); + + // A worker socket that accepts connections but never responds, like a + // stalled dev server whose pid is still alive. + const socketPath = join(cwd, "worker.sock"); + const server = http.createServer(() => {}); + cleanups.push(() => new Promise((resolve) => server.close(() => resolve()))); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + const buildDir = join(cwd, "node_modules/.nitro"); + await mkdir(buildDir, { recursive: true }); + await writeFile( + join(buildDir, "nitro.dev.json"), + JSON.stringify({ + dev: { pid: process.pid, workerAddress: { socketPath } }, + }) + ); + + await expect(listTasks({ cwd, timeout: 200 })).rejects.toThrow(/timed out/i); + }, 5000); +}); From 26961c2fa0f6b6007ead877ab0d79996b1f7cfb3 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Tue, 8 Sep 2026 22:49:57 +0100 Subject: [PATCH 2/3] fix(task): default the timeout for listTasks only, and never set it unasked A flat default capped runTask itself, since the socket stays idle for as long as the task runs. listTasks keeps a 30s default because the dev server answers it immediately; runTask now has none and takes one through TaskRunnerOptions. Passing `timeout: undefined` was not equivalent to omitting it: the socket still emitted a `timeout` event when the peer closed the idle connection, and the handler rejected with "after undefinedms". Both the option and the handler are now set only when a timeout is configured. Renames the test file to task-runner.test.ts to leave test/unit/task.test.ts to #4416, which covers the runtime scheduler. Co-Authored-By: Claude Opus 5 (1M context) --- src/task.ts | 27 ++++++++++++------- src/types/runtime/task.ts | 6 +++-- .../{task.test.ts => task-runner.test.ts} | 22 +++++++++++---- 3 files changed, 39 insertions(+), 16 deletions(-) rename test/unit/{task.test.ts => task-runner.test.ts} (71%) diff --git a/src/task.ts b/src/task.ts index 7c618e23e4..99290ae63d 100644 --- a/src/task.ts +++ b/src/task.ts @@ -13,6 +13,8 @@ export async function runTask( taskEvent: TaskEvent, opts?: TaskRunnerOptions ): Promise<{ result: unknown }> { + // No default timeout: the socket stays idle while the task runs, so any + // default would cap how long a task may take. Opt in via `opts.timeout`. const ctx = await _getTasksContext(opts); const result = await ctx.devFetch(`/_nitro/tasks/${taskEvent.name}`, { method: "POST", @@ -23,7 +25,9 @@ export async function runTask( /** @experimental */ export async function listTasks(opts?: TaskRunnerOptions) { - const ctx = await _getTasksContext(opts); + // Listing is metadata the dev server answers immediately, so a default is + // safe here and stops a stalled socket from hanging the caller forever. + const ctx = await _getTasksContext(opts, 30_000); const res = (await ctx.devFetch("/_nitro/tasks")) as { tasks: Record; }; @@ -34,10 +38,10 @@ export async function listTasks(opts?: TaskRunnerOptions) { const _devHint = `(is dev server running?)`; -async function _getTasksContext(opts?: TaskRunnerOptions) { +async function _getTasksContext(opts?: TaskRunnerOptions, defaultTimeout?: number) { const cwd = resolve(process.cwd(), opts?.cwd || "."); const buildDir = resolve(cwd, opts?.buildDir || "node_modules/.nitro"); - const timeout = opts?.timeout ?? 30_000; + const timeout = opts?.timeout ?? defaultTimeout; const buildInfoPath = resolve(buildDir, "nitro.dev.json"); if (!existsSync(buildInfoPath)) { @@ -76,7 +80,10 @@ async function _getTasksContext(opts?: TaskRunnerOptions) { { socketPath, method: options?.method, - timeout, + // Only set it when asked. Passing `timeout: undefined` still leaves + // the socket reporting the peer's own idle close as a `timeout` + // event, which would reject a request that was never capped. + ...(timeout ? { timeout } : {}), headers: { Accept: "application/json", "Content-Type": "application/json", @@ -97,11 +104,13 @@ async function _getTasksContext(opts?: TaskRunnerOptions) { } ); - request.on("timeout", () => { - // destroy with an error so the `error` handler rejects instead of - // leaving the promise pending forever on a stalled socket - request.destroy(new Error(`Request timed out after ${timeout}ms ${_devHint}`)); - }); + if (timeout) { + request.on("timeout", () => { + // destroy with an error so the `error` handler rejects instead of + // leaving the promise pending forever on a stalled socket + request.destroy(new Error(`Request timed out after ${timeout}ms ${_devHint}`)); + }); + } request.on("error", (e) => reject(e)); if (options?.body) { diff --git a/src/types/runtime/task.ts b/src/types/runtime/task.ts index 53fc478444..9b5cd34ba0 100644 --- a/src/types/runtime/task.ts +++ b/src/types/runtime/task.ts @@ -37,9 +37,11 @@ export interface TaskRunnerOptions { cwd?: string; buildDir?: string; /** - * Timeout in milliseconds for requests to the dev server. + * Socket inactivity timeout in milliseconds for requests to the dev server. * - * @default 30_000 + * `listTasks()` defaults to 30 seconds, since the dev server answers it + * immediately. `runTask()` has no default: the socket stays idle for as long + * as the task runs, so a default would cap the task's own duration. */ timeout?: number; } diff --git a/test/unit/task.test.ts b/test/unit/task-runner.test.ts similarity index 71% rename from test/unit/task.test.ts rename to test/unit/task-runner.test.ts index a2b5de1e51..bf2f1dcb2a 100644 --- a/test/unit/task.test.ts +++ b/test/unit/task-runner.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "pathe"; import { afterAll, describe, expect, it } from "vitest"; -import { listTasks } from "../../src/task.ts"; +import { listTasks, runTask } from "../../src/task.ts"; describe("task runner devFetch", () => { const cleanups: Array<() => Promise | void> = []; @@ -14,13 +14,12 @@ describe("task runner devFetch", () => { } }); - // https://github.com/unjs/nitro/issues/4292 - it("rejects instead of hanging when the dev server socket stalls", async () => { + // A worker socket that accepts connections but never responds, like a + // stalled dev server whose pid is still alive. + async function stalledDevServer() { const cwd = await mkdtemp(join(tmpdir(), "nitro-task-test-")); cleanups.push(() => rm(cwd, { recursive: true, force: true })); - // A worker socket that accepts connections but never responds, like a - // stalled dev server whose pid is still alive. const socketPath = join(cwd, "worker.sock"); const server = http.createServer(() => {}); cleanups.push(() => new Promise((resolve) => server.close(() => resolve()))); @@ -35,6 +34,19 @@ describe("task runner devFetch", () => { }) ); + return cwd; + } + + // https://github.com/unjs/nitro/issues/4292 + it("rejects instead of hanging when the dev server socket stalls", async () => { + const cwd = await stalledDevServer(); await expect(listTasks({ cwd, timeout: 200 })).rejects.toThrow(/timed out/i); }, 5000); + + it("honours an opt-in timeout for runTask", async () => { + const cwd = await stalledDevServer(); + await expect(runTask({ name: "db:migrate" }, { cwd, timeout: 200 })).rejects.toThrow( + /timed out/i + ); + }, 5000); }); From 8108a627289903549546bc12dcc475458335b68c Mon Sep 17 00:00:00 2001 From: Yarchik Date: Wed, 9 Sep 2026 15:35:53 +0100 Subject: [PATCH 3/3] test(task): pin the default timeout by reading the request options Waiting out the 30s default is too slow to assert, so read what devFetch passes to http.request instead. Fails both ways: if runTask regains a default, and if listTasks loses one. Co-Authored-By: Claude Opus 5 (1M context) --- test/unit/task-runner.test.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/test/unit/task-runner.test.ts b/test/unit/task-runner.test.ts index bf2f1dcb2a..41d3829c82 100644 --- a/test/unit/task-runner.test.ts +++ b/test/unit/task-runner.test.ts @@ -2,7 +2,7 @@ import http from "node:http"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "pathe"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { listTasks, runTask } from "../../src/task.ts"; describe("task runner devFetch", () => { @@ -49,4 +49,29 @@ describe("task runner devFetch", () => { /timed out/i ); }, 5000); + + // The socket stays idle for as long as the task runs, so only listTasks may + // carry a default. Read the request options rather than waiting 30s for it. + it("defaults the timeout for listTasks but not for runTask", async () => { + const cwd = await stalledDevServer(); + const request = vi.spyOn(http, "request"); + + const requested = async (call: Promise) => { + call.catch(() => {}); + await vi.waitFor(() => expect(request).toHaveBeenCalled()); + const [, options] = request.mock.calls[0] as [unknown, { timeout?: number }]; + (request.mock.results[0].value as http.ClientRequest).destroy(); + request.mockClear(); + return options; + }; + + expect(await requested(listTasks({ cwd }))).toHaveProperty("timeout", 30_000); + expect(await requested(runTask({ name: "db:migrate" }, { cwd }))).not.toHaveProperty("timeout"); + expect(await requested(runTask({ name: "db:migrate" }, { cwd, timeout: 200 }))).toHaveProperty( + "timeout", + 200 + ); + + request.mockRestore(); + }, 5000); });