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); +});