|
| 1 | +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; |
| 2 | +import { |
| 3 | + chmodSync, |
| 4 | + existsSync, |
| 5 | + mkdirSync, |
| 6 | + mkdtempSync, |
| 7 | + readFileSync, |
| 8 | + readdirSync, |
| 9 | + realpathSync, |
| 10 | + rmSync, |
| 11 | + symlinkSync, |
| 12 | + utimesSync, |
| 13 | + writeFileSync, |
| 14 | +} from "node:fs"; |
| 15 | +import { tmpdir } from "node:os"; |
| 16 | +import { basename, join } from "node:path"; |
| 17 | +import { runCommand } from "citty"; |
| 18 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 19 | +import { createOwnedRenderDir } from "@hyperframes/producer"; |
| 20 | +import { consumeCommandResult } from "../utils/commandResult.js"; |
| 21 | +import cleanCommand, { cleanLeftovers } from "./clean.js"; |
| 22 | + |
| 23 | +const history = vi.hoisted(() => ({ unreadable: false })); |
| 24 | +vi.mock("@hyperframes/studio-server", async (importOriginal) => { |
| 25 | + const actual = await importOriginal<typeof import("@hyperframes/studio-server")>(); |
| 26 | + return { |
| 27 | + ...actual, |
| 28 | + listProjectHistories: (root: string) => { |
| 29 | + if (history.unreadable) throw new Error("history unreadable"); |
| 30 | + return actual.listProjectHistories(root); |
| 31 | + }, |
| 32 | + }; |
| 33 | +}); |
| 34 | + |
| 35 | +const JOB = "0f8c2b1e-5d3a-4c7b-9e21-6a4f0d8b3c19"; |
| 36 | +const SEVEN_HOURS_AGO = (Date.now() - 7 * 60 * 60 * 1000) / 1000; |
| 37 | + |
| 38 | +describe("cleanLeftovers", () => { |
| 39 | + let scratch: string; |
| 40 | + let project: string; |
| 41 | + let renders: string; |
| 42 | + let liveRender: ChildProcess; |
| 43 | + // This machine's real owner stamp, so planted owners differ from it only in pid. |
| 44 | + let stamp: object; |
| 45 | + |
| 46 | + /** A render temp dir holding one 1000-byte frame, optionally owned and optionally last written 7 h ago. */ |
| 47 | + function plant(parent: string, name: string, owner?: number, stale = false): string { |
| 48 | + const dir = join(parent, name); |
| 49 | + mkdirSync(dir, { recursive: true }); |
| 50 | + writeFileSync(join(dir, "frame_000001.jpg"), Buffer.alloc(1000)); |
| 51 | + if (owner) writeFileSync(join(dir, "owner.json"), JSON.stringify({ ...stamp, pid: owner })); |
| 52 | + if (stale) |
| 53 | + for (const path of [join(dir, "frame_000001.jpg"), dir]) |
| 54 | + utimesSync(path, SEVEN_HOURS_AGO, SEVEN_HOURS_AGO); |
| 55 | + return dir; |
| 56 | + } |
| 57 | + |
| 58 | + const run = (overrides: { dryRun?: boolean; snapshots?: boolean } = {}) => |
| 59 | + cleanLeftovers({ |
| 60 | + projectDir: project, |
| 61 | + dryRun: overrides.dryRun ?? false, |
| 62 | + snapshots: overrides.snapshots ?? false, |
| 63 | + historyRoot: join(scratch, "history"), |
| 64 | + tempDir: join(scratch, "tmp"), |
| 65 | + debugDir: join(scratch, "debug"), |
| 66 | + }); |
| 67 | + |
| 68 | + beforeEach(() => { |
| 69 | + scratch = realpathSync(mkdtempSync(join(tmpdir(), "hf-clean-"))); |
| 70 | + project = join(scratch, "project"); |
| 71 | + renders = join(project, "renders"); |
| 72 | + mkdirSync(renders, { recursive: true }); |
| 73 | + mkdirSync(join(scratch, "tmp")); |
| 74 | + writeFileSync(join(project, "index.html"), '<div data-composition-id="main"></div>'); |
| 75 | + const own = createOwnedRenderDir(join(scratch, "self-")); |
| 76 | + stamp = JSON.parse(readFileSync(join(own, "owner.json"), "utf-8")); |
| 77 | + rmSync(own, { recursive: true }); |
| 78 | + vi.stubEnv("HYPERFRAMES_EXTRACT_CACHE_DIR", join(scratch, "extract-cache")); |
| 79 | + // Stands in for a render in progress: a running process that owns its work dir. |
| 80 | + liveRender = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { |
| 81 | + stdio: "ignore", |
| 82 | + }); |
| 83 | + }); |
| 84 | + afterEach(() => { |
| 85 | + liveRender.kill("SIGKILL"); |
| 86 | + vi.unstubAllEnvs(); |
| 87 | + rmSync(scratch, { recursive: true, force: true }); |
| 88 | + }); |
| 89 | + |
| 90 | + it("removes what dead renders left and keeps what a running render uses", async () => { |
| 91 | + const dead = spawnSync(process.execPath, ["-e", ""]).pid; |
| 92 | + const abandoned = plant(renders, `work-${JOB}-aB3dE9`, dead); |
| 93 | + const staging = plant(renders, ".final.hf-transaction-Zx81Qa", dead); |
| 94 | + const beforeOwners = plant(renders, `work-${JOB}-Oo0000`, undefined, true); |
| 95 | + const oldDebug = plant(join(scratch, "debug"), JOB, undefined, true); |
| 96 | + const windowsLeftover = plant(join(scratch, "tmp"), "hf-render-Qw12Er", dead); |
| 97 | + const live = plant(renders, `work-${JOB}-Ll0000`, liveRender.pid); |
| 98 | + const freshOwnerless = plant(renders, `work-${JOB}-Ff0000`); |
| 99 | + const userFolder = plant(renders, "work-in-progress", undefined, true); |
| 100 | + const debugNotes = plant(join(scratch, "debug"), "my-notes", undefined, true); |
| 101 | + writeFileSync(join(renders, "final.mp4"), "the render"); |
| 102 | + plant(project, "snapshots"); |
| 103 | + |
| 104 | + const listed = await run({ dryRun: true }); |
| 105 | + expect(listed.removed.map((item) => item.path).sort()).toEqual( |
| 106 | + [abandoned, staging, beforeOwners, oldDebug, windowsLeftover].sort(), |
| 107 | + ); |
| 108 | + expect(listed.removed.every((item) => item.bytes >= 1000)).toBe(true); |
| 109 | + expect(existsSync(abandoned)).toBe(true); |
| 110 | + |
| 111 | + await run(); |
| 112 | + expect(readdirSync(renders).sort()).toEqual( |
| 113 | + ["final.mp4", live, freshOwnerless, userFolder].map((path) => basename(path)).sort(), |
| 114 | + ); |
| 115 | + expect(existsSync(oldDebug) || existsSync(windowsLeftover)).toBe(false); |
| 116 | + expect(existsSync(debugNotes)).toBe(true); |
| 117 | + }); |
| 118 | + |
| 119 | + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( |
| 120 | + "reports a leftover it cannot remove and still removes the rest", |
| 121 | + async () => { |
| 122 | + const dead = spawnSync(process.execPath, ["-e", ""]).pid; |
| 123 | + const stuck = plant(renders, `work-${JOB}-St0000`, dead); |
| 124 | + const locked = join(stuck, "locked"); |
| 125 | + mkdirSync(locked); |
| 126 | + writeFileSync(join(locked, "frame.jpg"), "x"); |
| 127 | + chmodSync(locked, 0o555); |
| 128 | + const other = plant(join(scratch, "tmp"), "hf-render-Ot0000", dead); |
| 129 | + try { |
| 130 | + const result = await run(); |
| 131 | + expect(result.errors).toHaveLength(1); |
| 132 | + expect(result.removed.map((item) => item.path)).toEqual([other]); |
| 133 | + expect(existsSync(other)).toBe(false); |
| 134 | + } finally { |
| 135 | + chmodSync(locked, 0o755); |
| 136 | + } |
| 137 | + }, |
| 138 | + ); |
| 139 | + |
| 140 | + it("leaves snapshots alone in a folder that is not a HyperFrames project", async () => { |
| 141 | + writeFileSync(join(project, "index.html"), "<title>my website</title>"); |
| 142 | + const snapshots = plant(project, "snapshots"); |
| 143 | + |
| 144 | + const result = await run({ snapshots: true }); |
| 145 | + expect(result.alsoReclaimable).toEqual([]); |
| 146 | + expect(existsSync(snapshots)).toBe(true); |
| 147 | + }); |
| 148 | + |
| 149 | + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( |
| 150 | + "still cleans when the history folder cannot be read", |
| 151 | + async () => { |
| 152 | + const history = join(scratch, "history"); |
| 153 | + mkdirSync(history); |
| 154 | + chmodSync(history, 0o000); |
| 155 | + const dead = spawnSync(process.execPath, ["-e", ""]).pid; |
| 156 | + const leftover = plant(join(scratch, "tmp"), "hf-render-Hi0000", dead); |
| 157 | + try { |
| 158 | + const result = await run(); |
| 159 | + expect(result.errors.length).toBeGreaterThan(0); |
| 160 | + expect(existsSync(leftover)).toBe(false); |
| 161 | + } finally { |
| 162 | + chmodSync(history, 0o755); |
| 163 | + } |
| 164 | + }, |
| 165 | + ); |
| 166 | + |
| 167 | + it("lists a leftover once when the project folder is a link to the temp dir", async () => { |
| 168 | + const dead = spawnSync(process.execPath, ["-e", ""]).pid; |
| 169 | + const leftover = plant(join(scratch, "tmp"), "hf-render-Ln0000", dead); |
| 170 | + symlinkSync(join(scratch, "tmp"), join(scratch, "tmp-link")); |
| 171 | + |
| 172 | + const result = await cleanLeftovers({ |
| 173 | + projectDir: join(scratch, "tmp-link"), |
| 174 | + dryRun: true, |
| 175 | + snapshots: false, |
| 176 | + historyRoot: join(scratch, "history"), |
| 177 | + tempDir: join(scratch, "tmp"), |
| 178 | + debugDir: join(scratch, "debug"), |
| 179 | + }); |
| 180 | + expect(result.removed.map((item) => item.path)).toEqual([leftover]); |
| 181 | + }); |
| 182 | + |
| 183 | + it("exits 1 when something could not be read or removed", async () => { |
| 184 | + history.unreadable = true; |
| 185 | + const log = vi.spyOn(console, "log").mockImplementation(() => {}); |
| 186 | + try { |
| 187 | + await runCommand(cleanCommand, { rawArgs: [project, "--dry-run", "--json"] }); |
| 188 | + expect(JSON.parse(String(log.mock.calls.at(-1)?.[0])).errors).toContain("history unreadable"); |
| 189 | + expect(consumeCommandResult().exitCode).toBe(1); |
| 190 | + } finally { |
| 191 | + history.unreadable = false; |
| 192 | + log.mockRestore(); |
| 193 | + } |
| 194 | + }); |
| 195 | + |
| 196 | + it("lists snapshots as reclaimable and removes them only when asked", async () => { |
| 197 | + const snapshots = plant(project, "snapshots"); |
| 198 | + |
| 199 | + const plain = await run(); |
| 200 | + expect(plain.alsoReclaimable).toEqual([{ what: "Snapshots", path: snapshots, bytes: 1000 }]); |
| 201 | + expect(existsSync(snapshots)).toBe(true); |
| 202 | + |
| 203 | + await run({ snapshots: true }); |
| 204 | + expect(existsSync(snapshots)).toBe(false); |
| 205 | + }); |
| 206 | +}); |
0 commit comments