async-task-manager-edge-runtime.test.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. vi.mock("@/lib/logger", () => ({
  3. logger: {
  4. info: vi.fn(),
  5. warn: vi.fn(),
  6. error: vi.fn(),
  7. debug: vi.fn(),
  8. trace: vi.fn(),
  9. },
  10. }));
  11. vi.mock("@/app/v1/_lib/proxy/errors", () => ({
  12. isClientAbortError: vi.fn(() => false),
  13. }));
  14. describe.sequential("AsyncTaskManager edge runtime", () => {
  15. const prevRuntime = process.env.NEXT_RUNTIME;
  16. const prevCi = process.env.CI;
  17. beforeEach(() => {
  18. vi.resetModules();
  19. vi.restoreAllMocks();
  20. vi.useRealTimers();
  21. delete (globalThis as unknown as { __ASYNC_TASK_MANAGER__?: unknown }).__ASYNC_TASK_MANAGER__;
  22. delete process.env.CI;
  23. });
  24. afterEach(() => {
  25. process.env.NEXT_RUNTIME = prevRuntime;
  26. if (prevCi === undefined) {
  27. delete process.env.CI;
  28. } else {
  29. process.env.CI = prevCi;
  30. }
  31. delete (globalThis as unknown as { __ASYNC_TASK_MANAGER__?: unknown }).__ASYNC_TASK_MANAGER__;
  32. vi.restoreAllMocks();
  33. vi.useRealTimers();
  34. });
  35. it("does not call process.once when NEXT_RUNTIME is edge", async () => {
  36. const processOnceSpy = vi.spyOn(process, "once");
  37. process.env.NEXT_RUNTIME = "edge";
  38. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  39. AsyncTaskManager.register("t1", Promise.resolve());
  40. expect(processOnceSpy).not.toHaveBeenCalled();
  41. });
  42. it("registers exit hooks when NEXT_RUNTIME is nodejs", async () => {
  43. vi.useFakeTimers();
  44. const processOnceSpy = vi.spyOn(process, "once");
  45. process.env.NEXT_RUNTIME = "nodejs";
  46. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  47. AsyncTaskManager.register("t1", Promise.resolve());
  48. expect(processOnceSpy).toHaveBeenCalledTimes(3);
  49. expect(processOnceSpy).toHaveBeenNthCalledWith(1, "SIGTERM", expect.any(Function));
  50. expect(processOnceSpy).toHaveBeenNthCalledWith(2, "SIGINT", expect.any(Function));
  51. expect(processOnceSpy).toHaveBeenNthCalledWith(3, "beforeExit", expect.any(Function));
  52. });
  53. it("handles exit signal callback by running cleanupAll", async () => {
  54. vi.useFakeTimers();
  55. const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
  56. const processOnceSpy = vi.spyOn(process, "once");
  57. process.env.NEXT_RUNTIME = "nodejs";
  58. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  59. let resolveTask: () => void;
  60. const taskPromise = new Promise<void>((resolve) => {
  61. resolveTask = resolve;
  62. });
  63. const controller = AsyncTaskManager.register("t1", taskPromise);
  64. const sigtermHandler = processOnceSpy.mock.calls.find((c) => c[0] === "SIGTERM")?.[1];
  65. const sigintHandler = processOnceSpy.mock.calls.find((c) => c[0] === "SIGINT")?.[1];
  66. const beforeExitHandler = processOnceSpy.mock.calls.find((c) => c[0] === "beforeExit")?.[1];
  67. expect(sigtermHandler).toBeTypeOf("function");
  68. expect(sigintHandler).toBeTypeOf("function");
  69. expect(beforeExitHandler).toBeTypeOf("function");
  70. sigtermHandler?.();
  71. sigintHandler?.();
  72. beforeExitHandler?.();
  73. expect(controller.signal.aborted).toBe(true);
  74. expect(clearIntervalSpy).toHaveBeenCalled();
  75. resolveTask!();
  76. await taskPromise;
  77. });
  78. it("runs cleanupCompletedTasks on interval tick", async () => {
  79. vi.useFakeTimers();
  80. process.env.NEXT_RUNTIME = "nodejs";
  81. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  82. const cleanupSpy = vi.spyOn(
  83. AsyncTaskManager as unknown as { cleanupCompletedTasks: () => void },
  84. "cleanupCompletedTasks"
  85. );
  86. AsyncTaskManager.register("t1", new Promise<void>(() => {}));
  87. vi.advanceTimersByTime(60_000);
  88. expect(cleanupSpy).toHaveBeenCalledTimes(1);
  89. });
  90. it("registers and auto-cleans task after resolve", async () => {
  91. process.env.CI = "true";
  92. process.env.NEXT_RUNTIME = "nodejs";
  93. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  94. let resolveTask: () => void;
  95. const taskPromise = new Promise<void>((resolve) => {
  96. resolveTask = resolve;
  97. });
  98. const controller = AsyncTaskManager.register("t1", taskPromise);
  99. expect(controller.signal.aborted).toBe(false);
  100. expect(AsyncTaskManager.getActiveTaskCount()).toBe(1);
  101. resolveTask!();
  102. await taskPromise;
  103. await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
  104. expect(AsyncTaskManager.getActiveTaskCount()).toBe(0);
  105. });
  106. it("does nothing when cancelling unknown taskId", async () => {
  107. process.env.CI = "true";
  108. process.env.NEXT_RUNTIME = "nodejs";
  109. const { logger } = await import("@/lib/logger");
  110. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  111. AsyncTaskManager.cancel("missing");
  112. expect(vi.mocked(logger.debug)).toHaveBeenCalled();
  113. });
  114. it("getActiveTasks returns task metadata", async () => {
  115. process.env.CI = "true";
  116. process.env.NEXT_RUNTIME = "nodejs";
  117. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  118. let resolveTask: () => void;
  119. const taskPromise = new Promise<void>((resolve) => {
  120. resolveTask = resolve;
  121. });
  122. AsyncTaskManager.register("t1", taskPromise, "custom_type");
  123. const tasks = AsyncTaskManager.getActiveTasks();
  124. expect(tasks).toHaveLength(1);
  125. expect(tasks[0]).toMatchObject({ taskId: "t1", taskType: "custom_type" });
  126. expect(typeof tasks[0]?.age).toBe("number");
  127. resolveTask!();
  128. await taskPromise;
  129. });
  130. it("cancels old task when registering same taskId again", async () => {
  131. process.env.CI = "true";
  132. process.env.NEXT_RUNTIME = "nodejs";
  133. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  134. let resolveFirst: () => void;
  135. const firstPromise = new Promise<void>((resolve) => {
  136. resolveFirst = resolve;
  137. });
  138. const firstController = AsyncTaskManager.register("t1", firstPromise);
  139. expect(firstController.signal.aborted).toBe(false);
  140. let resolveSecond: () => void;
  141. const secondPromise = new Promise<void>((resolve) => {
  142. resolveSecond = resolve;
  143. });
  144. AsyncTaskManager.register("t1", secondPromise);
  145. expect(firstController.signal.aborted).toBe(true);
  146. resolveFirst!();
  147. resolveSecond!();
  148. await Promise.all([firstPromise, secondPromise]);
  149. });
  150. it("logs task cancelled when isClientAbortError returns true", async () => {
  151. process.env.CI = "true";
  152. process.env.NEXT_RUNTIME = "nodejs";
  153. const { isClientAbortError } = await import("@/app/v1/_lib/proxy/errors");
  154. vi.mocked(isClientAbortError).mockReturnValue(true);
  155. const { logger } = await import("@/lib/logger");
  156. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  157. const taskPromise = Promise.reject(new Error("aborted"));
  158. AsyncTaskManager.register("t1", taskPromise);
  159. await taskPromise.catch(() => {});
  160. expect(vi.mocked(logger.info)).toHaveBeenCalled();
  161. });
  162. it("logs task failed when isClientAbortError returns false", async () => {
  163. process.env.CI = "true";
  164. process.env.NEXT_RUNTIME = "nodejs";
  165. const { isClientAbortError } = await import("@/app/v1/_lib/proxy/errors");
  166. vi.mocked(isClientAbortError).mockReturnValue(false);
  167. const { logger } = await import("@/lib/logger");
  168. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  169. const taskPromise = Promise.reject(new Error("boom"));
  170. AsyncTaskManager.register("t1", taskPromise);
  171. await taskPromise.catch(() => {});
  172. expect(vi.mocked(logger.error)).toHaveBeenCalled();
  173. });
  174. it("cleanupCompletedTasks cancels stale tasks", async () => {
  175. process.env.CI = "true";
  176. process.env.NEXT_RUNTIME = "nodejs";
  177. const { logger } = await import("@/lib/logger");
  178. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  179. let resolveTask: () => void;
  180. const taskPromise = new Promise<void>((resolve) => {
  181. resolveTask = resolve;
  182. });
  183. const controller = AsyncTaskManager.register("stale-task", taskPromise, "custom_type");
  184. const managerAny = AsyncTaskManager as unknown as {
  185. tasks: Map<string, { createdAt: number }>;
  186. cleanupCompletedTasks: () => void;
  187. };
  188. const info = managerAny.tasks.get("stale-task");
  189. expect(info).toBeDefined();
  190. info!.createdAt = Date.now() - 11 * 60 * 1000;
  191. let resolveFresh: () => void;
  192. const freshPromise = new Promise<void>((resolve) => {
  193. resolveFresh = resolve;
  194. });
  195. const freshController = AsyncTaskManager.register("fresh-task", freshPromise, "custom_type");
  196. managerAny.cleanupCompletedTasks();
  197. expect(controller.signal.aborted).toBe(true);
  198. expect(freshController.signal.aborted).toBe(false);
  199. expect(vi.mocked(logger.warn)).toHaveBeenCalled();
  200. resolveTask!();
  201. resolveFresh!();
  202. await Promise.all([taskPromise, freshPromise]);
  203. });
  204. it("cleanupAll cancels tasks and clears interval", async () => {
  205. process.env.CI = "true";
  206. process.env.NEXT_RUNTIME = "nodejs";
  207. const { AsyncTaskManager } = await import("@/lib/async-task-manager");
  208. let resolveTask: () => void;
  209. const taskPromise = new Promise<void>((resolve) => {
  210. resolveTask = resolve;
  211. });
  212. const controller = AsyncTaskManager.register("t1", taskPromise);
  213. const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
  214. const intervalId = setInterval(() => {}, 1_000);
  215. const managerAny = AsyncTaskManager as unknown as {
  216. cleanupInterval: ReturnType<typeof setInterval> | null;
  217. cleanupAll: () => void;
  218. };
  219. managerAny.cleanupInterval = intervalId;
  220. managerAny.cleanupAll();
  221. expect(controller.signal.aborted).toBe(true);
  222. expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
  223. expect(managerAny.cleanupInterval).toBeNull();
  224. resolveTask!();
  225. await taskPromise;
  226. clearInterval(intervalId);
  227. });
  228. });