From 9b54b107e94ce494d0fd3cfea4248d98ea32e5c4 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 17:55:25 +0800 Subject: [PATCH 01/14] fix: skip async task manager init on edge --- src/lib/async-task-manager.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/lib/async-task-manager.ts b/src/lib/async-task-manager.ts index 9ac0e71e9..97223a6a5 100644 --- a/src/lib/async-task-manager.ts +++ b/src/lib/async-task-manager.ts @@ -28,9 +28,17 @@ class AsyncTaskManagerClass { private cleanupInterval: NodeJS.Timeout | null = null; constructor() { - // Skip initialization during CI/build phase to avoid unnecessary logs and side effects - if (process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") { - logger.debug("[AsyncTaskManager] Skipping initialization in CI/build environment"); + // Skip initialization in non-Node runtimes / build phases to avoid Node-only APIs and side effects. + if ( + process.env.NEXT_RUNTIME === "edge" || + process.env.CI === "true" || + process.env.NEXT_PHASE === "phase-production-build" + ) { + logger.debug("[AsyncTaskManager] Skipping initialization in edge/CI/build environment", { + nextRuntime: process.env.NEXT_RUNTIME, + nextPhase: process.env.NEXT_PHASE, + ci: process.env.CI, + }); return; } From 56a0125511e4c02e5f6140029b2eca84fb415cb7 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 17:58:10 +0800 Subject: [PATCH 02/14] fix: avoid static async task manager import --- src/lib/price-sync/cloud-price-updater.ts | 89 +++++++++++++++-------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/src/lib/price-sync/cloud-price-updater.ts b/src/lib/price-sync/cloud-price-updater.ts index a64b28164..2bf1cb6ea 100644 --- a/src/lib/price-sync/cloud-price-updater.ts +++ b/src/lib/price-sync/cloud-price-updater.ts @@ -1,4 +1,3 @@ -import { AsyncTaskManager } from "@/lib/async-task-manager"; import { logger } from "@/lib/logger"; import type { PriceUpdateResult } from "@/types/model-price"; import { @@ -58,47 +57,73 @@ export function requestCloudPriceTableSync(options: { reason: "missing-model" | "scheduled" | "manual"; throttleMs?: number; }): void { - const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; - const taskId = "cloud-price-table-sync"; - - // 去重:已有任务在跑则不重复触发 - const active = AsyncTaskManager.getActiveTasks(); - if (active.some((t) => t.taskId === taskId)) { + if (process.env.NEXT_RUNTIME === "edge") { return; } + const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; + const taskId = "cloud-price-table-sync"; + // 节流:避免短时间内频繁拉取云端价格表 - const g = globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }; + const g = globalThis as unknown as { + __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number; + __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean; + }; const lastAt = g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ ?? 0; const now = Date.now(); if (now - lastAt < throttleMs) { return; } - AsyncTaskManager.register( - taskId, - (async () => { - try { - const result = await syncCloudPriceTableToDatabase(); - if (!result.ok) { - logger.warn("[PriceSync] Cloud price sync task failed", { - reason: options.reason, - error: result.error, - }); - return; - } + // 避免并发请求在 AsyncTaskManager 加载前重复触发(例如多请求同时命中 missing-model) + if (g.__CCH_CLOUD_PRICE_SYNC_SCHEDULING__) { + return; + } + g.__CCH_CLOUD_PRICE_SYNC_SCHEDULING__ = true; + + void (async () => { + try { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); - logger.info("[PriceSync] Cloud price sync task completed", { - reason: options.reason, - added: result.data.added.length, - updated: result.data.updated.length, - skippedConflicts: result.data.skippedConflicts?.length ?? 0, - total: result.data.total, - }); - } finally { - g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + // 去重:已有任务在跑则不重复触发 + const active = AsyncTaskManager.getActiveTasks(); + if (active.some((t) => t.taskId === taskId)) { + return; } - })(), - "cloud_price_table_sync" - ); + + AsyncTaskManager.register( + taskId, + (async () => { + try { + const result = await syncCloudPriceTableToDatabase(); + if (!result.ok) { + logger.warn("[PriceSync] Cloud price sync task failed", { + reason: options.reason, + error: result.error, + }); + return; + } + + logger.info("[PriceSync] Cloud price sync task completed", { + reason: options.reason, + added: result.data.added.length, + updated: result.data.updated.length, + skippedConflicts: result.data.skippedConflicts?.length ?? 0, + total: result.data.total, + }); + } finally { + g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + } + })(), + "cloud_price_table_sync" + ); + } catch (error) { + logger.warn("[PriceSync] Cloud price sync scheduling failed", { + reason: options.reason, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + g.__CCH_CLOUD_PRICE_SYNC_SCHEDULING__ = false; + } + })(); } From 1152cdadc5b749af356be1b92b9723a368f9aded Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 18:30:15 +0800 Subject: [PATCH 03/14] test: cover edge runtime task scheduling --- .../async-task-manager-edge-runtime.test.ts | 309 ++++++++++++++++++ .../price-sync/cloud-price-updater.test.ts | 213 ++++++++++-- 2 files changed, 503 insertions(+), 19 deletions(-) create mode 100644 tests/unit/lib/async-task-manager-edge-runtime.test.ts diff --git a/tests/unit/lib/async-task-manager-edge-runtime.test.ts b/tests/unit/lib/async-task-manager-edge-runtime.test.ts new file mode 100644 index 000000000..340f50c04 --- /dev/null +++ b/tests/unit/lib/async-task-manager-edge-runtime.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", () => ({ + isClientAbortError: vi.fn(() => false), +})); + +describe.sequential("AsyncTaskManager edge runtime", () => { + const prevRuntime = process.env.NEXT_RUNTIME; + const prevCi = process.env.CI; + const prevPhase = process.env.NEXT_PHASE; + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.useRealTimers(); + + delete (globalThis as unknown as { __ASYNC_TASK_MANAGER__?: unknown }).__ASYNC_TASK_MANAGER__; + + delete process.env.CI; + delete process.env.NEXT_PHASE; + }); + + afterEach(() => { + process.env.NEXT_RUNTIME = prevRuntime; + if (prevCi === undefined) { + delete process.env.CI; + } else { + process.env.CI = prevCi; + } + if (prevPhase === undefined) { + delete process.env.NEXT_PHASE; + } else { + process.env.NEXT_PHASE = prevPhase; + } + + delete (globalThis as unknown as { __ASYNC_TASK_MANAGER__?: unknown }).__ASYNC_TASK_MANAGER__; + + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("does not call process.once when NEXT_RUNTIME is edge", async () => { + const processOnceSpy = vi.spyOn(process, "once"); + process.env.NEXT_RUNTIME = "edge"; + + await import("@/lib/async-task-manager"); + + expect(processOnceSpy).not.toHaveBeenCalled(); + }); + + it("registers exit hooks when NEXT_RUNTIME is nodejs", async () => { + vi.useFakeTimers(); + + const processOnceSpy = vi.spyOn(process, "once"); + process.env.NEXT_RUNTIME = "nodejs"; + + await import("@/lib/async-task-manager"); + + expect(processOnceSpy).toHaveBeenCalledTimes(3); + expect(processOnceSpy).toHaveBeenNthCalledWith(1, "SIGTERM", expect.any(Function)); + expect(processOnceSpy).toHaveBeenNthCalledWith(2, "SIGINT", expect.any(Function)); + expect(processOnceSpy).toHaveBeenNthCalledWith(3, "beforeExit", expect.any(Function)); + }); + + it("handles exit signal callback by running cleanupAll", async () => { + vi.useFakeTimers(); + + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + const processOnceSpy = vi.spyOn(process, "once"); + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const controller = AsyncTaskManager.register("t1", taskPromise); + + const sigtermHandler = processOnceSpy.mock.calls.find((c) => c[0] === "SIGTERM")?.[1]; + const sigintHandler = processOnceSpy.mock.calls.find((c) => c[0] === "SIGINT")?.[1]; + const beforeExitHandler = processOnceSpy.mock.calls.find((c) => c[0] === "beforeExit")?.[1]; + expect(sigtermHandler).toBeTypeOf("function"); + expect(sigintHandler).toBeTypeOf("function"); + expect(beforeExitHandler).toBeTypeOf("function"); + + sigtermHandler?.(); + sigintHandler?.(); + beforeExitHandler?.(); + + expect(controller.signal.aborted).toBe(true); + expect(clearIntervalSpy).toHaveBeenCalled(); + + resolveTask!(); + await taskPromise; + }); + + it("runs cleanupCompletedTasks on interval tick", async () => { + vi.useFakeTimers(); + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + const cleanupSpy = vi.spyOn(AsyncTaskManager as unknown as { cleanupCompletedTasks: () => void }, "cleanupCompletedTasks"); + + vi.advanceTimersByTime(60_000); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); + + it("registers and auto-cleans task after resolve", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + + const controller = AsyncTaskManager.register("t1", taskPromise); + expect(controller.signal.aborted).toBe(false); + expect(AsyncTaskManager.getActiveTaskCount()).toBe(1); + + resolveTask!(); + await taskPromise; + await new Promise((resolve) => queueMicrotask(() => resolve())); + + expect(AsyncTaskManager.getActiveTaskCount()).toBe(0); + }); + + it("does nothing when cancelling unknown taskId", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { logger } = await import("@/lib/logger"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + AsyncTaskManager.cancel("missing"); + + expect(vi.mocked(logger.debug)).toHaveBeenCalled(); + }); + + it("getActiveTasks returns task metadata", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + + AsyncTaskManager.register("t1", taskPromise, "custom_type"); + + const tasks = AsyncTaskManager.getActiveTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ taskId: "t1", taskType: "custom_type" }); + expect(typeof tasks[0]?.age).toBe("number"); + + resolveTask!(); + await taskPromise; + }); + + it("cancels old task when registering same taskId again", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveFirst: () => void; + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + const firstController = AsyncTaskManager.register("t1", firstPromise); + expect(firstController.signal.aborted).toBe(false); + + let resolveSecond: () => void; + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve; + }); + + AsyncTaskManager.register("t1", secondPromise); + + expect(firstController.signal.aborted).toBe(true); + + resolveFirst!(); + resolveSecond!(); + await Promise.all([firstPromise, secondPromise]); + }); + + it("logs task cancelled when isClientAbortError returns true", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { isClientAbortError } = await import("@/app/v1/_lib/proxy/errors"); + vi.mocked(isClientAbortError).mockReturnValue(true); + + const { logger } = await import("@/lib/logger"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + const taskPromise = Promise.reject(new Error("aborted")); + AsyncTaskManager.register("t1", taskPromise); + + await taskPromise.catch(() => {}); + + expect(vi.mocked(logger.info)).toHaveBeenCalled(); + }); + + it("logs task failed when isClientAbortError returns false", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { isClientAbortError } = await import("@/app/v1/_lib/proxy/errors"); + vi.mocked(isClientAbortError).mockReturnValue(false); + + const { logger } = await import("@/lib/logger"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + const taskPromise = Promise.reject(new Error("boom")); + AsyncTaskManager.register("t1", taskPromise); + + await taskPromise.catch(() => {}); + + expect(vi.mocked(logger.error)).toHaveBeenCalled(); + }); + + it("cleanupCompletedTasks cancels stale tasks", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { logger } = await import("@/lib/logger"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + + const controller = AsyncTaskManager.register("stale-task", taskPromise, "custom_type"); + + const managerAny = AsyncTaskManager as unknown as { + tasks: Map; + cleanupCompletedTasks: () => void; + }; + const info = managerAny.tasks.get("stale-task"); + expect(info).toBeDefined(); + info!.createdAt = Date.now() - 11 * 60 * 1000; + + let resolveFresh: () => void; + const freshPromise = new Promise((resolve) => { + resolveFresh = resolve; + }); + const freshController = AsyncTaskManager.register("fresh-task", freshPromise, "custom_type"); + + managerAny.cleanupCompletedTasks(); + + expect(controller.signal.aborted).toBe(true); + expect(freshController.signal.aborted).toBe(false); + expect(vi.mocked(logger.warn)).toHaveBeenCalled(); + + resolveTask!(); + resolveFresh!(); + await Promise.all([taskPromise, freshPromise]); + }); + + it("cleanupAll cancels tasks and clears interval", async () => { + process.env.CI = "true"; + process.env.NEXT_RUNTIME = "nodejs"; + + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const controller = AsyncTaskManager.register("t1", taskPromise); + + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + const intervalId = setInterval(() => {}, 1_000); + const managerAny = AsyncTaskManager as unknown as { + cleanupInterval: ReturnType | null; + cleanupAll: () => void; + }; + managerAny.cleanupInterval = intervalId; + + managerAny.cleanupAll(); + + expect(controller.signal.aborted).toBe(true); + expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId); + expect(managerAny.cleanupInterval).toBeNull(); + + resolveTask!(); + await taskPromise; + clearInterval(intervalId); + }); +}); diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts index 6b2b69582..ae4837b81 100644 --- a/tests/unit/price-sync/cloud-price-updater.test.ts +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -1,15 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CloudPriceTableResult } from "@/lib/price-sync/cloud-price-table"; -import { logger } from "@/lib/logger"; -import { - syncCloudPriceTableToDatabase, - requestCloudPriceTableSync, -} from "@/lib/price-sync/cloud-price-updater"; -import { AsyncTaskManager } from "@/lib/async-task-manager"; -import { processPriceTableInternal } from "@/actions/model-prices"; const asyncTasks: Promise[] = []; +let asyncTaskManagerLoaded = false; + vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), @@ -20,15 +15,18 @@ vi.mock("@/lib/logger", () => ({ }, })); -vi.mock("@/lib/async-task-manager", () => ({ - AsyncTaskManager: { - getActiveTasks: vi.fn(() => []), - register: vi.fn((_taskId: string, promise: Promise) => { - asyncTasks.push(promise); - return new AbortController(); - }), - }, -})); +vi.mock("@/lib/async-task-manager", () => { + asyncTaskManagerLoaded = true; + return { + AsyncTaskManager: { + getActiveTasks: vi.fn(() => []), + register: vi.fn((_taskId: string, promise: Promise) => { + asyncTasks.push(promise); + return new AbortController(); + }), + }, + }; +}); vi.mock("@/actions/model-prices", () => ({ processPriceTableInternal: vi.fn(async () => ({ @@ -43,11 +41,17 @@ vi.mock("@/actions/model-prices", () => ({ })), })); +async function flushAsync(): Promise { + await new Promise((resolve) => setTimeout(() => resolve(), 0)); +} + describe("syncCloudPriceTableToDatabase", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); asyncTasks.splice(0, asyncTasks.length); vi.unstubAllGlobals(); + asyncTaskManagerLoaded = false; }); it("returns ok=false when cloud fetch fails with HTTP error", async () => { @@ -60,6 +64,7 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -74,6 +79,7 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -88,6 +94,7 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -102,11 +109,13 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { processPriceTableInternal } = await import("@/actions/model-prices"); vi.mocked(processPriceTableInternal).mockResolvedValue({ ok: false, error: "write failed", } as unknown as CloudPriceTableResult); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -121,11 +130,13 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { processPriceTableInternal } = await import("@/actions/model-prices"); vi.mocked(processPriceTableInternal).mockResolvedValue({ ok: true, data: undefined, } as unknown as CloudPriceTableResult); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(false); }); @@ -143,6 +154,7 @@ describe("syncCloudPriceTableToDatabase", () => { })) ); + const { processPriceTableInternal } = await import("@/actions/model-prices"); vi.mocked(processPriceTableInternal).mockResolvedValue({ ok: true, data: { @@ -154,42 +166,195 @@ describe("syncCloudPriceTableToDatabase", () => { }, } as any); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(true); expect(processPriceTableInternal).toHaveBeenCalledTimes(1); }); + + it("falls back to default error message when write returns ok=false without error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: false, + error: undefined, + } as unknown as CloudPriceTableResult); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(false); + expect(result).toMatchObject({ ok: false, error: "云端价格表写入失败" }); + }); + + it("returns ok=false when write throws Error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + vi.mocked(processPriceTableInternal).mockImplementationOnce(async () => { + throw new Error("boom"); + }); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(false); + expect(result).toMatchObject({ ok: false, error: expect.stringContaining("boom") }); + }); + + it("returns ok=false when write throws non-Error value", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + vi.mocked(processPriceTableInternal).mockImplementationOnce(async () => { + throw "boom"; + }); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(false); + expect(result).toMatchObject({ ok: false, error: expect.stringContaining("boom") }); + }); }); describe("requestCloudPriceTableSync", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); asyncTasks.splice(0, asyncTasks.length); vi.unstubAllGlobals(); + asyncTaskManagerLoaded = false; delete (globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }) .__CCH_CLOUD_PRICE_SYNC_LAST_AT__; + delete ( + globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean } + ).__CCH_CLOUD_PRICE_SYNC_SCHEDULING__; }); - it("does nothing when same task is already active", () => { + it("no-ops in Edge runtime (does not load AsyncTaskManager)", async () => { + const prevRuntime = process.env.NEXT_RUNTIME; + process.env.NEXT_RUNTIME = "edge"; + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + await flushAsync(); + + expect(asyncTaskManagerLoaded).toBe(false); + + process.env.NEXT_RUNTIME = prevRuntime; + }); + + it("does nothing when same task is already active", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + vi.mocked(AsyncTaskManager.getActiveTasks).mockReturnValue([ { taskId: "cloud-price-table-sync" }, ] as any); requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + await flushAsync(); expect(AsyncTaskManager.register).not.toHaveBeenCalled(); }); - it("throttles when called within throttle window", () => { + it("throttles when called within throttle window", async () => { ( globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number } ).__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 60_000 }); + await flushAsync(); - expect(AsyncTaskManager.register).not.toHaveBeenCalled(); + expect(asyncTaskManagerLoaded).toBe(false); + }); + + it("uses default throttleMs when not provided", async () => { + ( + globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number } + ).__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "missing-model" }); + await flushAsync(); + + expect(asyncTaskManagerLoaded).toBe(false); + }); + + it("does nothing when scheduling flag is already set", async () => { + ( + globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean } + ).__CCH_CLOUD_PRICE_SYNC_SCHEDULING__ = true; + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + await flushAsync(); + + expect(asyncTaskManagerLoaded).toBe(false); + }); + + it("logs warn when scheduling fails with Error", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + vi.mocked(AsyncTaskManager.getActiveTasks).mockImplementationOnce(() => { + throw new Error("import fail"); + }); + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + await flushAsync(); + + const { logger } = await import("@/lib/logger"); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "[PriceSync] Cloud price sync scheduling failed", + expect.objectContaining({ error: "import fail" }) + ); + }); + + it("logs warn when scheduling fails with non-Error value", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + vi.mocked(AsyncTaskManager.getActiveTasks).mockImplementationOnce(() => { + throw "import fail"; + }); + + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + await flushAsync(); + + const { logger } = await import("@/lib/logger"); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "[PriceSync] Cloud price sync scheduling failed", + expect.objectContaining({ error: "import fail" }) + ); }); it("registers a task and updates throttle timestamp after completion", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + const { processPriceTableInternal } = await import("@/actions/model-prices"); + let resolveFetch: (value: unknown) => void; const fetchPromise = new Promise((resolve) => { resolveFetch = resolve; @@ -211,7 +376,9 @@ describe("requestCloudPriceTableSync", () => { }, } as any); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + await flushAsync(); expect(AsyncTaskManager.register).toHaveBeenCalledTimes(1); @@ -227,11 +394,15 @@ describe("requestCloudPriceTableSync", () => { await Promise.all(asyncTasks.splice(0, asyncTasks.length)); expect(processPriceTableInternal).toHaveBeenCalledTimes(1); + const { logger } = await import("@/lib/logger"); expect(vi.mocked(logger.info)).toHaveBeenCalled(); expect(typeof g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__).toBe("number"); }); it("logs warn when sync task fails", async () => { + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + vi.stubGlobal( "fetch", vi.fn(async () => ({ @@ -242,8 +413,12 @@ describe("requestCloudPriceTableSync", () => { ); requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + + await flushAsync(); + expect(AsyncTaskManager.register).toHaveBeenCalledTimes(1); await Promise.all(asyncTasks.splice(0, asyncTasks.length)); + const { logger } = await import("@/lib/logger"); expect(vi.mocked(logger.warn)).toHaveBeenCalled(); }); }); From cf63b046601a0d8ded4b7181ed5b1ebc8ca5eb3a Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 18:37:16 +0800 Subject: [PATCH 04/14] chore: document edge runtime process.once fix --- docs/edge-runtime-process-once.md | 42 +++++++++++++++++++ .../async-task-manager-edge-runtime.test.ts | 5 ++- .../price-sync/cloud-price-updater.test.ts | 21 +++++----- 3 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 docs/edge-runtime-process-once.md diff --git a/docs/edge-runtime-process-once.md b/docs/edge-runtime-process-once.md new file mode 100644 index 000000000..29df223ea --- /dev/null +++ b/docs/edge-runtime-process-once.md @@ -0,0 +1,42 @@ +# Fix: Edge Runtime `process.once` build warning + +## 背景 + +`next build` 过程中出现 Edge Runtime 不支持 Node API 的告警:`process.once`。 + +相关导入链路(import trace)包含: + +- `src/lib/async-task-manager.ts` +- `src/lib/price-sync/cloud-price-updater.ts` +- `src/instrumentation.ts` + +## 变更 + +- `AsyncTaskManager`: + - 在 `process.env.NEXT_RUNTIME === "edge"` 时跳过初始化,避免触发 `process.once` 等 Node-only API。 +- `cloud-price-updater`: + - 移除对 `AsyncTaskManager` 的顶层静态 import。 + - 在 `requestCloudPriceTableSync()` 内部按需动态 import `AsyncTaskManager`,并在 Edge runtime 下直接 no-op。 + +## 验证 + +- `bun run lint` +- `bun run typecheck` +- Targeted coverage(仅统计本次相关文件): + - `bunx vitest run tests/unit/lib/async-task-manager-edge-runtime.test.ts tests/unit/price-sync/cloud-price-updater.test.ts --coverage --coverage.provider v8 --coverage.reporter text --coverage.reporter html --coverage.reporter json --coverage.reportsDirectory ./coverage-edgeonce --coverage.include src/lib/async-task-manager.ts --coverage.include src/lib/price-sync/cloud-price-updater.ts` + - 结果:All files 100%(Statements / Branches / Functions / Lines) +- `bun run build` + - 结果:不再出现 Edge Runtime `process.once` 相关告警 + +## 回滚 + +如需回滚,优先按提交粒度回退(示例 commit hash): + +- `fix: skip async task manager init on edge`(`9b54b107`) +- `fix: avoid static async task manager import`(`56a01255`) +- `test: cover edge runtime task scheduling`(`1152cdad`) + +## 备注 + +`.codex/plan/` 与 `.codex/issues/` 属于本地任务落盘目录,不应提交到 Git。 + diff --git a/tests/unit/lib/async-task-manager-edge-runtime.test.ts b/tests/unit/lib/async-task-manager-edge-runtime.test.ts index 340f50c04..908200927 100644 --- a/tests/unit/lib/async-task-manager-edge-runtime.test.ts +++ b/tests/unit/lib/async-task-manager-edge-runtime.test.ts @@ -110,7 +110,10 @@ describe.sequential("AsyncTaskManager edge runtime", () => { process.env.NEXT_RUNTIME = "nodejs"; const { AsyncTaskManager } = await import("@/lib/async-task-manager"); - const cleanupSpy = vi.spyOn(AsyncTaskManager as unknown as { cleanupCompletedTasks: () => void }, "cleanupCompletedTasks"); + const cleanupSpy = vi.spyOn( + AsyncTaskManager as unknown as { cleanupCompletedTasks: () => void }, + "cleanupCompletedTasks" + ); vi.advanceTimersByTime(60_000); diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts index ae4837b81..4059c419c 100644 --- a/tests/unit/price-sync/cloud-price-updater.test.ts +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -218,14 +218,14 @@ describe("syncCloudPriceTableToDatabase", () => { }); it("returns ok=false when write throws non-Error value", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => ({ - ok: true, - status: 200, - text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), - })) - ); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); const { processPriceTableInternal } = await import("@/actions/model-prices"); vi.mocked(processPriceTableInternal).mockImplementationOnce(async () => { @@ -249,9 +249,8 @@ describe("requestCloudPriceTableSync", () => { asyncTaskManagerLoaded = false; delete (globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }) .__CCH_CLOUD_PRICE_SYNC_LAST_AT__; - delete ( - globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean } - ).__CCH_CLOUD_PRICE_SYNC_SCHEDULING__; + delete (globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean }) + .__CCH_CLOUD_PRICE_SYNC_SCHEDULING__; }); it("no-ops in Edge runtime (does not load AsyncTaskManager)", async () => { From f968ac48c11512455c489da845ecb7ab963fab1e Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 18:53:35 +0800 Subject: [PATCH 05/14] chore: record edge runtime warning baseline --- docs/edge-runtime-process-once.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/edge-runtime-process-once.md b/docs/edge-runtime-process-once.md index 29df223ea..d281a9be1 100644 --- a/docs/edge-runtime-process-once.md +++ b/docs/edge-runtime-process-once.md @@ -4,6 +4,18 @@ `next build` 过程中出现 Edge Runtime 不支持 Node API 的告警:`process.once`。 +### 复现基线(修复前) + +在修复前的提交 `820b519b` 上运行 `bun run build` 可稳定看到 3 条告警(分别指向 `process.once` 的 47/48/49 行),其 import trace 包含: + +```text +A Node.js API is used (process.once at line: 47) which is not supported in the Edge Runtime. +Import traces: + ./src/lib/async-task-manager.ts + ./src/lib/price-sync/cloud-price-updater.ts + ./src/instrumentation.ts +``` + 相关导入链路(import trace)包含: - `src/lib/async-task-manager.ts` @@ -36,7 +48,10 @@ - `fix: avoid static async task manager import`(`56a01255`) - `test: cover edge runtime task scheduling`(`1152cdad`) +## 备选方案(若回归) + +如果未来 Next/Turbopack 的静态分析行为变化导致告警回归,可将 Node-only 的 signal hooks 拆分到 `*.node.ts`(例如 `async-task-manager.node.ts`),并仅在 `NEXT_RUNTIME !== "edge"` 的分支里动态引入。 + ## 备注 `.codex/plan/` 与 `.codex/issues/` 属于本地任务落盘目录,不应提交到 Git。 - From b26899bd53b6d7aa5657761cd6bd0ac8e6f6ea32 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 22:58:41 +0800 Subject: [PATCH 06/14] fix: drop NEXT_PHASE and lazy-init async task manager --- src/lib/async-task-manager.ts | 23 ++++++++++++------- .../async-task-manager-edge-runtime.test.ts | 11 +++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/lib/async-task-manager.ts b/src/lib/async-task-manager.ts index 97223a6a5..5b79cb69e 100644 --- a/src/lib/async-task-manager.ts +++ b/src/lib/async-task-manager.ts @@ -26,17 +26,22 @@ interface TaskInfo { class AsyncTaskManagerClass { private tasks: Map = new Map(); private cleanupInterval: NodeJS.Timeout | null = null; + private initialized = false; constructor() { - // Skip initialization in non-Node runtimes / build phases to avoid Node-only APIs and side effects. - if ( - process.env.NEXT_RUNTIME === "edge" || - process.env.CI === "true" || - process.env.NEXT_PHASE === "phase-production-build" - ) { - logger.debug("[AsyncTaskManager] Skipping initialization in edge/CI/build environment", { + // Intentionally empty: initialization is lazy to avoid side effects at import time. + } + + private initializeIfNeeded(): void { + if (this.initialized) { + return; + } + this.initialized = true; + + // Skip initialization in Edge/CI environments to avoid Node-only APIs and side effects. + if (process.env.NEXT_RUNTIME === "edge" || process.env.CI === "true") { + logger.debug("[AsyncTaskManager] Skipping initialization in edge/CI environment", { nextRuntime: process.env.NEXT_RUNTIME, - nextPhase: process.env.NEXT_PHASE, ci: process.env.CI, }); return; @@ -71,6 +76,8 @@ class AsyncTaskManagerClass { * @returns AbortController(可用于取消任务) */ register(taskId: string, promise: Promise, taskType = "unknown"): AbortController { + this.initializeIfNeeded(); + // 如果任务已存在,先取消旧任务 if (this.tasks.has(taskId)) { logger.warn("[AsyncTaskManager] Task already exists, cancelling old task", { diff --git a/tests/unit/lib/async-task-manager-edge-runtime.test.ts b/tests/unit/lib/async-task-manager-edge-runtime.test.ts index 908200927..135294b1a 100644 --- a/tests/unit/lib/async-task-manager-edge-runtime.test.ts +++ b/tests/unit/lib/async-task-manager-edge-runtime.test.ts @@ -17,7 +17,6 @@ vi.mock("@/app/v1/_lib/proxy/errors", () => ({ describe.sequential("AsyncTaskManager edge runtime", () => { const prevRuntime = process.env.NEXT_RUNTIME; const prevCi = process.env.CI; - const prevPhase = process.env.NEXT_PHASE; beforeEach(() => { vi.resetModules(); @@ -27,7 +26,6 @@ describe.sequential("AsyncTaskManager edge runtime", () => { delete (globalThis as unknown as { __ASYNC_TASK_MANAGER__?: unknown }).__ASYNC_TASK_MANAGER__; delete process.env.CI; - delete process.env.NEXT_PHASE; }); afterEach(() => { @@ -37,11 +35,6 @@ describe.sequential("AsyncTaskManager edge runtime", () => { } else { process.env.CI = prevCi; } - if (prevPhase === undefined) { - delete process.env.NEXT_PHASE; - } else { - process.env.NEXT_PHASE = prevPhase; - } delete (globalThis as unknown as { __ASYNC_TASK_MANAGER__?: unknown }).__ASYNC_TASK_MANAGER__; @@ -64,7 +57,8 @@ describe.sequential("AsyncTaskManager edge runtime", () => { const processOnceSpy = vi.spyOn(process, "once"); process.env.NEXT_RUNTIME = "nodejs"; - await import("@/lib/async-task-manager"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + AsyncTaskManager.register("t1", Promise.resolve()); expect(processOnceSpy).toHaveBeenCalledTimes(3); expect(processOnceSpy).toHaveBeenNthCalledWith(1, "SIGTERM", expect.any(Function)); @@ -115,6 +109,7 @@ describe.sequential("AsyncTaskManager edge runtime", () => { "cleanupCompletedTasks" ); + AsyncTaskManager.register("t1", new Promise(() => {})); vi.advanceTimersByTime(60_000); expect(cleanupSpy).toHaveBeenCalledTimes(1); From 1aaf8e4e3a8c5baa4bcbb37a97aa23e988351160 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 23:00:29 +0800 Subject: [PATCH 07/14] test: isolate NEXT_RUNTIME in cloud price sync tests --- tests/unit/price-sync/cloud-price-updater.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts index 4059c419c..3c3cfc2bf 100644 --- a/tests/unit/price-sync/cloud-price-updater.test.ts +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CloudPriceTableResult } from "@/lib/price-sync/cloud-price-table"; const asyncTasks: Promise[] = []; @@ -241,6 +241,8 @@ describe("syncCloudPriceTableToDatabase", () => { }); describe("requestCloudPriceTableSync", () => { + const prevRuntime = process.env.NEXT_RUNTIME; + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); @@ -251,6 +253,16 @@ describe("requestCloudPriceTableSync", () => { .__CCH_CLOUD_PRICE_SYNC_LAST_AT__; delete (globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_SCHEDULING__?: boolean }) .__CCH_CLOUD_PRICE_SYNC_SCHEDULING__; + + process.env.NEXT_RUNTIME = "nodejs"; + }); + + afterEach(() => { + if (prevRuntime === undefined) { + delete process.env.NEXT_RUNTIME; + return; + } + process.env.NEXT_RUNTIME = prevRuntime; }); it("no-ops in Edge runtime (does not load AsyncTaskManager)", async () => { From 214556db554478e5b2f67d80c8a26d8cf827b1ef Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 23:01:51 +0800 Subject: [PATCH 08/14] docs: stabilize edge process.once repro baseline --- docs/edge-runtime-process-once.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/edge-runtime-process-once.md b/docs/edge-runtime-process-once.md index d281a9be1..4c419c1b4 100644 --- a/docs/edge-runtime-process-once.md +++ b/docs/edge-runtime-process-once.md @@ -6,10 +6,10 @@ ### 复现基线(修复前) -在修复前的提交 `820b519b` 上运行 `bun run build` 可稳定看到 3 条告警(分别指向 `process.once` 的 47/48/49 行),其 import trace 包含: +在修复前的版本(例如 tag `v0.4.1`)上运行 `bun run build` 可看到 Edge Runtime 不支持 Node API 的告警,其 import trace 包含: ```text -A Node.js API is used (process.once at line: 47) which is not supported in the Edge Runtime. +A Node.js API is used (process.once) which is not supported in the Edge Runtime. Import traces: ./src/lib/async-task-manager.ts ./src/lib/price-sync/cloud-price-updater.ts From 7333669adeeb2864d457b7886881e860197da2d3 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 23:03:03 +0800 Subject: [PATCH 09/14] docs: make rollback instructions hashless --- docs/edge-runtime-process-once.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/edge-runtime-process-once.md b/docs/edge-runtime-process-once.md index 4c419c1b4..632eaa7d5 100644 --- a/docs/edge-runtime-process-once.md +++ b/docs/edge-runtime-process-once.md @@ -42,11 +42,19 @@ Import traces: ## 回滚 -如需回滚,优先按提交粒度回退(示例 commit hash): +如需回滚,优先按提交粒度回退(以 commit message 为准): -- `fix: skip async task manager init on edge`(`9b54b107`) -- `fix: avoid static async task manager import`(`56a01255`) -- `test: cover edge runtime task scheduling`(`1152cdad`) +- `fix: skip async task manager init on edge` +- `fix: avoid static async task manager import` +- `test: cover edge runtime task scheduling` + +定位对应提交(示例): + +```bash +git log --oneline --grep "fix: skip async task manager init on edge" +git log --oneline --grep "fix: avoid static async task manager import" +git log --oneline --grep "test: cover edge runtime task scheduling" +``` ## 备选方案(若回归) From d391f8eb8943b7dce98e64e4b8badac5facbaca3 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 23:05:08 +0800 Subject: [PATCH 10/14] docs: add grep checklist for edge warning audit --- docs/edge-runtime-process-once.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/edge-runtime-process-once.md b/docs/edge-runtime-process-once.md index 632eaa7d5..3dc044f01 100644 --- a/docs/edge-runtime-process-once.md +++ b/docs/edge-runtime-process-once.md @@ -60,6 +60,14 @@ git log --oneline --grep "test: cover edge runtime task scheduling" 如果未来 Next/Turbopack 的静态分析行为变化导致告警回归,可将 Node-only 的 signal hooks 拆分到 `*.node.ts`(例如 `async-task-manager.node.ts`),并仅在 `NEXT_RUNTIME !== "edge"` 的分支里动态引入。 +## 快速定位(避免文档漂移) + +```bash +rg -n "process\\.once" src/lib/async-task-manager.ts +rg -n "NEXT_RUNTIME|NEXT_PHASE" src/lib tests +rg -n "requestCloudPriceTableSync" src/lib/price-sync/cloud-price-updater.ts tests/unit/price-sync/cloud-price-updater.test.ts +``` + ## 备注 `.codex/plan/` 与 `.codex/issues/` 属于本地任务落盘目录,不应提交到 Git。 From 57ad3d858a6f8ae6055dcb74f8496f347368f058 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 23:07:48 +0800 Subject: [PATCH 11/14] chore: run regression gate and align docs --- docs/edge-runtime-process-once.md | 4 ++-- src/lib/async-task-manager.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/edge-runtime-process-once.md b/docs/edge-runtime-process-once.md index 3dc044f01..94c074cc5 100644 --- a/docs/edge-runtime-process-once.md +++ b/docs/edge-runtime-process-once.md @@ -35,8 +35,8 @@ Import traces: - `bun run lint` - `bun run typecheck` - Targeted coverage(仅统计本次相关文件): - - `bunx vitest run tests/unit/lib/async-task-manager-edge-runtime.test.ts tests/unit/price-sync/cloud-price-updater.test.ts --coverage --coverage.provider v8 --coverage.reporter text --coverage.reporter html --coverage.reporter json --coverage.reportsDirectory ./coverage-edgeonce --coverage.include src/lib/async-task-manager.ts --coverage.include src/lib/price-sync/cloud-price-updater.ts` - - 结果:All files 100%(Statements / Branches / Functions / Lines) + - `bunx vitest run tests/unit/lib/async-task-manager-edge-runtime.test.ts tests/unit/price-sync/cloud-price-updater.test.ts --coverage --coverage.provider v8 --coverage.reporter text --coverage.include src/lib/async-task-manager.ts --coverage.include src/lib/price-sync/cloud-price-updater.ts` + - 结果:All files >= 90%(Statements / Branches / Functions / Lines) - `bun run build` - 结果:不再出现 Edge Runtime `process.once` 相关告警 diff --git a/src/lib/async-task-manager.ts b/src/lib/async-task-manager.ts index 5b79cb69e..d86c48867 100644 --- a/src/lib/async-task-manager.ts +++ b/src/lib/async-task-manager.ts @@ -26,12 +26,9 @@ interface TaskInfo { class AsyncTaskManagerClass { private tasks: Map = new Map(); private cleanupInterval: NodeJS.Timeout | null = null; + // Lazily initialize Node-only hooks on first use to avoid side effects at import time. private initialized = false; - constructor() { - // Intentionally empty: initialization is lazy to avoid side effects at import time. - } - private initializeIfNeeded(): void { if (this.initialized) { return; From 4bc4d159ebad27d0dc82ded0f0d086cff03ef3e0 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <1665486570@qq.com> Date: Sun, 11 Jan 2026 23:31:57 +0800 Subject: [PATCH 12/14] test: cover edge runtime guard on register --- tests/unit/lib/async-task-manager-edge-runtime.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/lib/async-task-manager-edge-runtime.test.ts b/tests/unit/lib/async-task-manager-edge-runtime.test.ts index 135294b1a..5173a81df 100644 --- a/tests/unit/lib/async-task-manager-edge-runtime.test.ts +++ b/tests/unit/lib/async-task-manager-edge-runtime.test.ts @@ -46,7 +46,8 @@ describe.sequential("AsyncTaskManager edge runtime", () => { const processOnceSpy = vi.spyOn(process, "once"); process.env.NEXT_RUNTIME = "edge"; - await import("@/lib/async-task-manager"); + const { AsyncTaskManager } = await import("@/lib/async-task-manager"); + AsyncTaskManager.register("t1", Promise.resolve()); expect(processOnceSpy).not.toHaveBeenCalled(); }); From bee7e190f3a018c8e685c114d83ae3b2f1b6e663 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <56943790+YangQing-Lin@users.noreply.github.com> Date: Sun, 11 Jan 2026 23:45:32 +0800 Subject: [PATCH 13/14] Update src/lib/async-task-manager.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 NEXT_PHASE === "phase-production-build" 检查 Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/lib/async-task-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/async-task-manager.ts b/src/lib/async-task-manager.ts index d86c48867..007bc9903 100644 --- a/src/lib/async-task-manager.ts +++ b/src/lib/async-task-manager.ts @@ -36,7 +36,7 @@ class AsyncTaskManagerClass { this.initialized = true; // Skip initialization in Edge/CI environments to avoid Node-only APIs and side effects. - if (process.env.NEXT_RUNTIME === "edge" || process.env.CI === "true") { + if (process.env.NEXT_RUNTIME === "edge" || process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") { logger.debug("[AsyncTaskManager] Skipping initialization in edge/CI environment", { nextRuntime: process.env.NEXT_RUNTIME, ci: process.env.CI, From 65ae2aadd6c2cc96f67938b62a6e85df9c865ec6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 11 Jan 2026 15:45:58 +0000 Subject: [PATCH 14/14] chore: format code (fix-edge-runtime-process-once-bee7e19) --- src/lib/async-task-manager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/async-task-manager.ts b/src/lib/async-task-manager.ts index 007bc9903..0728744f4 100644 --- a/src/lib/async-task-manager.ts +++ b/src/lib/async-task-manager.ts @@ -36,7 +36,11 @@ class AsyncTaskManagerClass { this.initialized = true; // Skip initialization in Edge/CI environments to avoid Node-only APIs and side effects. - if (process.env.NEXT_RUNTIME === "edge" || process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") { + if ( + process.env.NEXT_RUNTIME === "edge" || + process.env.CI === "true" || + process.env.NEXT_PHASE === "phase-production-build" + ) { logger.debug("[AsyncTaskManager] Skipping initialization in edge/CI environment", { nextRuntime: process.env.NEXT_RUNTIME, ci: process.env.CI,