Skip to content

Commit 663b022

Browse files
feat(cli): add hyperframes clean to find and remove what HyperFrames left on disk (#4701)
* feat(cli): add hyperframes clean to find and remove what HyperFrames left on disk `hyperframes clean` lists leftover render work dirs, debug renders, the history of projects that no longer exist, idle video proxies and extracted video frames, with sizes, and removes them. `--dry-run` removes nothing, `--json` is for agents, `--snapshots` also removes snapshot folders (otherwise listed as reclaimable). It never touches outputs, sources or anything a running render or preview still owns. * docs(cli): say that clean does not search a project's subfolders * fix(cli): clean test compares folder names on Windows and the skills manifest is regenerated * fix(studio-server): the proxy cache sweep removes only files the transcoder named It removed any idle .mp4 or .webm in .transcode-cache, so a user file placed there, or reached through a symlinked cache folder, could go. Proxies are always <sha256>.<ext> and their temp files .tmp-<uuid>-<proxy name>; nothing else is swept. * style(producer): keep a renderDirOwner doc comment within 120 columns
1 parent cc792d4 commit 663b022

22 files changed

Lines changed: 719 additions & 76 deletions

File tree

‎.fallowrc.jsonc‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,8 @@
517517
"packages/studio-server/src/routes/files.test.ts",
518518
"packages/studio-server/src/routes/render.test.ts",
519519
"packages/studio-server/src/routes/lint.test.ts",
520+
// extractionCache.test.ts: pre-existing parallel describe setup and publish-case arrange/assert blocks.
521+
"packages/engine/src/services/extractionCache.test.ts",
520522
"packages/studio-server/src/routes/preview.test.ts",
521523
"packages/studio-server/src/routes/projects.test.ts",
522524
"packages/studio-server/src/helpers/backupJournal.test.ts",

‎docs/developers/cli.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ remains authoritative: run `npx hyperframes <command> --help`.
1919
| Render a video | `npx hyperframes render --output output.mp4` |
2020
| Publish a browser link | `npx hyperframes publish` |
2121
| Diagnose the machine | `npx hyperframes doctor` |
22+
| Free disk space HyperFrames used | `npx hyperframes clean` |
2223

2324
## Work with media
2425

‎docs/packages/cli.mdx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,6 +1137,27 @@ In JSON mode, paths inside `detail` and `hint` are redacted — your home
11371137
directory becomes the literal `$HOME`, so output is safe to paste into a bug
11381138
report or an agent context.
11391139

1140+
### `clean`
1141+
1142+
Find and remove what HyperFrames left on disk.
1143+
1144+
```bash
1145+
npx hyperframes clean --dry-run # list it, remove nothing
1146+
npx hyperframes clean
1147+
npx hyperframes clean --snapshots # also remove snapshots/ folders
1148+
npx hyperframes clean --json
1149+
```
1150+
1151+
It looks in the current project (not its subfolders) and every project the Studio history knows, and removes:
1152+
1153+
- frames and staging folders left by renders that were killed or crashed (a render that is still running keeps its own);
1154+
- work folders of `--debug` renders nothing has written to for six hours;
1155+
- the history of projects that no longer exist;
1156+
- extracted video frames and video proxies nobody has used for an hour (they rebuild on demand).
1157+
1158+
`snapshots/` folders are listed as also reclaimable and removed only with `--snapshots`. Rendered outputs, source
1159+
files, the browser download and models are never touched.
1160+
11401161
### `info` and `compositions`
11411162

11421163
`npx hyperframes info [dir]` prints project metadata: name, resolution,

‎packages/cli/src/cli.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ const commandLoaders = {
172172
tts: () => import("./commands/tts.js").then((m) => m.default),
173173
docs: () => import("./commands/docs.js").then((m) => m.default),
174174
doctor: () => import("./commands/doctor.js").then((m) => m.default),
175+
clean: () => import("./commands/clean.js").then((m) => m.default),
175176
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
176177
skills: () => import("./commands/skills.js").then((m) => m.default),
177178
feedback: () => import("./commands/feedback.js").then((m) => m.default),
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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

Comments
 (0)