diff --git a/apps/api/src/runner-jobs/runner-jobs.service.spec.ts b/apps/api/src/runner-jobs/runner-jobs.service.spec.ts index 39b12bf..c53ace7 100644 --- a/apps/api/src/runner-jobs/runner-jobs.service.spec.ts +++ b/apps/api/src/runner-jobs/runner-jobs.service.spec.ts @@ -608,14 +608,11 @@ describe("RunnerJobsService", () => { const jobId = "job-123"; const workspaceId = "workspace-123"; - let closeHandler: (() => void) | null = null; - const mockRes = { write: vi.fn(), end: vi.fn(), on: vi.fn((event: string, handler: () => void) => { if (event === "close") { - closeHandler = handler; // Immediately trigger close to break the loop setTimeout(() => handler(), 10); } @@ -638,6 +635,89 @@ describe("RunnerJobsService", () => { expect(mockRes.end).toHaveBeenCalled(); }); + it("should call clearInterval in finally block to prevent memory leaks", async () => { + const jobId = "job-123"; + const workspaceId = "workspace-123"; + + // Spy on global setInterval and clearInterval + const mockIntervalId = 12345; + const setIntervalSpy = vi + .spyOn(global, "setInterval") + .mockReturnValue(mockIntervalId as never); + const clearIntervalSpy = vi.spyOn(global, "clearInterval").mockImplementation(() => {}); + + const mockRes = { + write: vi.fn(), + end: vi.fn(), + on: vi.fn(), + writableEnded: false, + }; + + // Mock job to complete immediately + mockPrismaService.runnerJob.findUnique + .mockResolvedValueOnce({ + id: jobId, + status: RunnerJobStatus.RUNNING, + }) + .mockResolvedValueOnce({ + id: jobId, + status: RunnerJobStatus.COMPLETED, + }); + + mockPrismaService.jobEvent.findMany.mockResolvedValue([]); + + await service.streamEvents(jobId, workspaceId, mockRes as never); + + // Verify setInterval was called for keep-alive ping + expect(setIntervalSpy).toHaveBeenCalled(); + + // Verify clearInterval was called with the interval ID to prevent memory leak + expect(clearIntervalSpy).toHaveBeenCalledWith(mockIntervalId); + + // Cleanup spies + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + + it("should clear interval even when stream throws an error", async () => { + const jobId = "job-123"; + const workspaceId = "workspace-123"; + + // Spy on global setInterval and clearInterval + const mockIntervalId = 54321; + const setIntervalSpy = vi + .spyOn(global, "setInterval") + .mockReturnValue(mockIntervalId as never); + const clearIntervalSpy = vi.spyOn(global, "clearInterval").mockImplementation(() => {}); + + const mockRes = { + write: vi.fn(), + end: vi.fn(), + on: vi.fn(), + writableEnded: false, + }; + + mockPrismaService.runnerJob.findUnique.mockResolvedValueOnce({ + id: jobId, + status: RunnerJobStatus.RUNNING, + }); + + // Simulate a fatal error during event polling + mockPrismaService.jobEvent.findMany.mockRejectedValue(new Error("Fatal database failure")); + + // The method should throw but still clean up + await expect(service.streamEvents(jobId, workspaceId, mockRes as never)).rejects.toThrow( + "Fatal database failure" + ); + + // Verify clearInterval was called even on error (via finally block) + expect(clearIntervalSpy).toHaveBeenCalledWith(mockIntervalId); + + // Cleanup spies + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + // ERROR RECOVERY TESTS - Issue #187 it("should support resuming stream from lastEventId", async () => {