Skip to content

Commit da86e33

Browse files
committed
refactor(render): centralize execution ownership
1 parent feb7e4c commit da86e33

5 files changed

Lines changed: 331 additions & 271 deletions

File tree

Lines changed: 3 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,144 +1,7 @@
1-
/**
2-
* Tests for the cancel/error-path helpers in `./cleanup.ts`.
3-
*/
1+
/** Tests for render failure-detail construction. */
42

5-
import { describe, expect, it, vi } from "vitest";
6-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
7-
import { tmpdir } from "node:os";
8-
import { join } from "node:path";
9-
import type { CaptureSession } from "@hyperframes/engine";
10-
import type { FileServerHandle } from "../fileServer.js";
11-
import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./cleanup.js";
12-
13-
function makeLog() {
14-
return { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
15-
}
16-
17-
describe("safeCleanup", () => {
18-
it("returns normally when the operation succeeds", async () => {
19-
const log = makeLog();
20-
const op = vi.fn().mockResolvedValue(undefined);
21-
await safeCleanup("close x", op, log);
22-
expect(op).toHaveBeenCalledOnce();
23-
expect(log.debug).not.toHaveBeenCalled();
24-
});
25-
26-
it("swallows thrown errors and logs them at debug", async () => {
27-
const log = makeLog();
28-
await safeCleanup(
29-
"close x",
30-
() => {
31-
throw new Error("boom");
32-
},
33-
log,
34-
);
35-
expect(log.debug).toHaveBeenCalledWith("Cleanup failed (close x)", { error: "boom" });
36-
});
37-
38-
it("swallows async rejections", async () => {
39-
const log = makeLog();
40-
await safeCleanup("close x", async () => Promise.reject(new Error("async boom")), log);
41-
expect(log.debug).toHaveBeenCalledWith("Cleanup failed (close x)", { error: "async boom" });
42-
});
43-
});
44-
45-
describe("cleanupRenderResources", () => {
46-
it("closes fileServer, probeSession, then removes workDir (non-debug)", async () => {
47-
const log = makeLog();
48-
const workDir = mkdtempSync(join(tmpdir(), "cleanup-test-"));
49-
writeFileSync(join(workDir, "marker.txt"), "x");
50-
51-
const order: string[] = [];
52-
const fileServer = {
53-
close: () => {
54-
order.push("fileServer.close");
55-
},
56-
} as unknown as FileServerHandle;
57-
const probeSession = {
58-
_markClosed: () => {
59-
order.push("probeSession.close");
60-
},
61-
} as unknown as CaptureSession;
62-
63-
// closeCaptureSession is the engine helper; the helper itself isn't
64-
// mockable per-call without intercepting the module import. Instead
65-
// we verify the higher-level invariants: fileServer.close was called,
66-
// and the workDir was rmSync'd.
67-
await cleanupRenderResources({
68-
fileServer,
69-
probeSession: null, // skip probe to keep this test focused on the workDir invariant
70-
workDir,
71-
debug: false,
72-
log,
73-
label: "cancel",
74-
});
75-
76-
expect(order).toEqual(["fileServer.close"]);
77-
expect(existsSync(workDir)).toBe(false);
78-
void probeSession; // suppress unused-var (kept as a doc of the surface)
79-
});
80-
81-
it("keeps workDir when debug=true", async () => {
82-
const log = makeLog();
83-
const workDir = mkdtempSync(join(tmpdir(), "cleanup-debug-"));
84-
writeFileSync(join(workDir, "marker.txt"), "x");
85-
86-
await cleanupRenderResources({
87-
fileServer: null,
88-
probeSession: null,
89-
workDir,
90-
debug: true,
91-
log,
92-
label: "error",
93-
});
94-
95-
expect(existsSync(workDir)).toBe(true);
96-
rmSync(workDir, { recursive: true, force: true });
97-
});
98-
99-
it("is a no-op for missing workDir thanks to rmSync force:true", async () => {
100-
const log = makeLog();
101-
const workDir = join(tmpdir(), `cleanup-missing-${Date.now()}`);
102-
expect(existsSync(workDir)).toBe(false);
103-
104-
await cleanupRenderResources({
105-
fileServer: null,
106-
probeSession: null,
107-
workDir,
108-
debug: false,
109-
log,
110-
label: "error",
111-
});
112-
113-
// No throw; nothing logged at debug for the rmSync step.
114-
expect(log.debug).not.toHaveBeenCalled();
115-
});
116-
117-
it("logs (and continues past) a fileServer.close that throws", async () => {
118-
const log = makeLog();
119-
const workDir = mkdtempSync(join(tmpdir(), "cleanup-throw-"));
120-
121-
const fileServer = {
122-
close: () => {
123-
throw new Error("server stuck");
124-
},
125-
} as unknown as FileServerHandle;
126-
127-
await cleanupRenderResources({
128-
fileServer,
129-
probeSession: null,
130-
workDir,
131-
debug: false,
132-
log,
133-
label: "error",
134-
});
135-
136-
expect(log.debug).toHaveBeenCalledWith("Cleanup failed (close file server (error))", {
137-
error: "server stuck",
138-
});
139-
expect(existsSync(workDir)).toBe(false);
140-
});
141-
});
3+
import { describe, expect, it } from "vitest";
4+
import { buildRenderErrorDetails } from "./cleanup.js";
1425

1436
describe("buildRenderErrorDetails", () => {
1447
const baseDiagnostics = { videoExtractionFailures: 0, imageDecodeFailures: 0 };
@@ -235,6 +98,3 @@ describe("buildRenderErrorDetails", () => {
23598
expect(failed.hdrDiagnostics).toEqual({ videoExtractionFailures: 2, imageDecodeFailures: 0 });
23699
});
237100
});
238-
239-
// Quiet unused-import warning — these are referenced via type-only paths.
240-
void mkdirSync;

packages/producer/src/services/render/cleanup.ts

Lines changed: 2 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,11 @@
1-
/**
2-
* Sequencer cleanup + error-details helpers shared by the cancel and
3-
* error paths in `executeRenderJob`.
4-
*/
1+
/** Error-detail construction shared by render failure paths. */
52

6-
import { rmSync } from "node:fs";
73
import { freemem } from "node:os";
8-
import {
9-
type CaptureSession,
10-
type SubTimelineWaitOutcome,
11-
closeCaptureSession,
12-
} from "@hyperframes/engine";
13-
import type { FileServerHandle } from "../fileServer.js";
14-
import { defaultLogger, type ProducerLogger } from "../../logger.js";
4+
import { type SubTimelineWaitOutcome } from "@hyperframes/engine";
155
import type { HdrDiagnostics, RenderJob } from "../renderOrchestrator.js";
166
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
177
import type { RenderObservabilitySummary } from "./observability.js";
188

19-
/**
20-
* Wrap a cleanup operation so it never throws, but logs any failure.
21-
* The sequencer needs to keep tearing down resources even when one of
22-
* them is stuck (e.g. a `fileServer.close()` hitting a TCP race); a
23-
* thrown cleanup error would mask the original render failure.
24-
*/
25-
export async function safeCleanup(
26-
label: string,
27-
fn: () => Promise<void> | void,
28-
log: ProducerLogger = defaultLogger,
29-
): Promise<void> {
30-
try {
31-
await fn();
32-
} catch (err) {
33-
log.debug(`Cleanup failed (${label})`, {
34-
error: err instanceof Error ? err.message : String(err),
35-
});
36-
}
37-
}
38-
39-
/**
40-
* Close the file server, close the probe session, and remove the
41-
* working directory. Each step runs through `safeCleanup` so a stuck
42-
* resource doesn't mask the original render error.
43-
*/
44-
export async function cleanupRenderResources(input: {
45-
fileServer: FileServerHandle | null;
46-
probeSession: CaptureSession | null;
47-
workDir: string;
48-
debug: boolean;
49-
log: ProducerLogger;
50-
/** Suffix appended to safeCleanup labels. Pinned to the existing diagnostic payloads. */
51-
label: "cancel" | "error";
52-
}): Promise<void> {
53-
const { fileServer, probeSession, workDir, debug, log, label } = input;
54-
if (fileServer) {
55-
const fs = fileServer;
56-
await safeCleanup(
57-
`close file server (${label})`,
58-
() => {
59-
fs.close();
60-
},
61-
log,
62-
);
63-
}
64-
if (probeSession) {
65-
const session = probeSession;
66-
await safeCleanup(`close probe session (${label})`, () => closeCaptureSession(session), log);
67-
}
68-
if (!debug) {
69-
// `force: true` swallows ENOENT, so no need to existsSync first.
70-
await safeCleanup(
71-
`remove workDir (${label})`,
72-
() => rmSync(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
73-
log,
74-
);
75-
}
76-
}
77-
789
/**
7910
* Build the `RenderJob.errorDetails` shape downstream consumers (SSE,
8011
* sync `/render` response, queue introspection) read on failure.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { ProducerLogger } from "../../logger.js";
3+
import type { RenderJob } from "../renderOrchestrator.js";
4+
import { RenderExecutionContext } from "./renderExecutionContext.js";
5+
6+
function logger(): ProducerLogger {
7+
return { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
8+
}
9+
10+
function job(): RenderJob {
11+
return {
12+
id: "job-1",
13+
status: "queued",
14+
outcome: null,
15+
warnings: [],
16+
progress: 0,
17+
currentStage: "queued",
18+
config: { fps: { num: 30, den: 1 }, quality: "standard" },
19+
createdAt: new Date(),
20+
};
21+
}
22+
23+
describe("RenderExecutionContext", () => {
24+
it("freezes request identity and scopes every log record", () => {
25+
const base = logger();
26+
const context = new RenderExecutionContext({
27+
request: { renderJobId: "job-1", projectDir: "/project", outputPath: "/out.mp4" },
28+
logger: base,
29+
});
30+
31+
expect(Object.isFrozen(context.request)).toBe(true);
32+
context.logger.info("started", { phase: "compile" });
33+
expect(base.info).toHaveBeenCalledWith("started", {
34+
renderJobId: "job-1",
35+
phase: "compile",
36+
});
37+
});
38+
39+
it("runs disposers once in reverse acquisition order and contains failures", async () => {
40+
const base = logger();
41+
const calls: string[] = [];
42+
const context = new RenderExecutionContext({
43+
request: { renderJobId: "job-1", projectDir: "/project", outputPath: "/out.mp4" },
44+
logger: base,
45+
});
46+
context.defer("first", () => calls.push("first"));
47+
context.defer("broken", () => {
48+
calls.push("broken");
49+
throw new Error("close failed");
50+
});
51+
context.defer("last", async () => calls.push("last"));
52+
53+
await Promise.all([context.dispose(), context.dispose()]);
54+
55+
expect(calls).toEqual(["last", "broken", "first"]);
56+
expect(base.debug).toHaveBeenCalledWith(
57+
"Cleanup failed (broken)",
58+
expect.objectContaining({ renderJobId: "job-1", error: "close failed" }),
59+
);
60+
});
61+
62+
it("can release ownership after a resource is closed early", async () => {
63+
const dispose = vi.fn();
64+
const context = new RenderExecutionContext({
65+
request: { renderJobId: "job-1", projectDir: "/project", outputPath: "/out.mp4" },
66+
logger: logger(),
67+
});
68+
const release = context.defer("resource", dispose);
69+
release();
70+
71+
await context.dispose();
72+
73+
expect(dispose).not.toHaveBeenCalled();
74+
});
75+
76+
it("serializes progress before disposal completes", async () => {
77+
const deliveries: string[] = [];
78+
const context = new RenderExecutionContext({
79+
request: { renderJobId: "job-1", projectDir: "/project", outputPath: "/out.mp4" },
80+
logger: logger(),
81+
progressSink: async (_job, message) => {
82+
await Promise.resolve();
83+
deliveries.push(message);
84+
},
85+
});
86+
context.defer("resource", () => deliveries.push("disposed"));
87+
const renderJob = job();
88+
context.onProgress?.(renderJob, "first");
89+
context.onProgress?.(renderJob, "second");
90+
91+
await context.dispose();
92+
93+
expect(deliveries).toEqual(["first", "second", "disposed"]);
94+
});
95+
96+
it("does not let a broken cleanup logger reject disposal", async () => {
97+
const base = logger();
98+
vi.mocked(base.debug).mockImplementation(() => {
99+
throw new Error("logger unavailable");
100+
});
101+
const context = new RenderExecutionContext({
102+
request: { renderJobId: "job-1", projectDir: "/project", outputPath: "/out.mp4" },
103+
logger: base,
104+
});
105+
context.defer("broken", () => {
106+
throw new Error("close failed");
107+
});
108+
109+
await expect(context.dispose()).resolves.toBeUndefined();
110+
});
111+
112+
it("composes caller cancellation with a deadline", async () => {
113+
const controller = new AbortController();
114+
const context = new RenderExecutionContext({
115+
request: { renderJobId: "job-1", projectDir: "/project", outputPath: "/out.mp4" },
116+
logger: logger(),
117+
signal: controller.signal,
118+
deadlineAtMs: Date.now() + 60_000,
119+
});
120+
controller.abort();
121+
122+
expect(() => context.assertActive()).toThrow("render_cancelled");
123+
await context.dispose();
124+
});
125+
126+
it("is already aborted when its deadline has elapsed", async () => {
127+
const context = new RenderExecutionContext({
128+
request: { renderJobId: "job-1", projectDir: "/project", outputPath: "/out.mp4" },
129+
logger: logger(),
130+
deadlineAtMs: Date.now() - 1,
131+
});
132+
133+
expect(context.signal?.aborted).toBe(true);
134+
expect(() => context.assertActive()).toThrow("render_cancelled");
135+
await context.dispose();
136+
});
137+
});

0 commit comments

Comments
 (0)