Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/cli/src/server/studioServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,44 @@ describe("createStudioServer project history (D-491)", () => {
},
);

it("never makes a request wait on another process's history lock again once it was busy", async () => {
const waits: unknown[] = [];
historyState.open = async (options) => {
waits.push((options as { ownerWaitMs?: number }).ownerWaitMs);
throw new HistoryBusyError(1);
};
const projectDir = tmpProject();
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
await server.app.request(historyUrl);
await server.app.request(historyUrl);
historyState.open = null;
expect(waits).toEqual([undefined, 0]);
await server.shutdown();
});

it("waits for the history lock again once a history opened after a busy one", async () => {
const waits: unknown[] = [];
const reopenable = { replacedAtPath: () => true, close: async () => {} };
let opens = 0;
historyState.open = async (options) => {
waits.push((options as { ownerWaitMs?: number }).ownerWaitMs);
opens += 1;
if (opens === 1) throw new HistoryBusyError(1);
if (opens === 2) return reopenable;
historyState.open = null;
throw new HistoryBusyError(1);
};
const projectDir = tmpProject();
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
await server.app.request(historyUrl);
await server.app.request(historyUrl);
historyState.open = null;
expect(waits).toEqual([undefined, 0, undefined]);
await server.shutdown();
});

it("opens a new project's own history once it takes the folder's path", async () => {
const projectDir = tmpProject();
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
Expand Down
22 changes: 15 additions & 7 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,17 +436,25 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
};

// Opened on first use, so a server that never serves Studio's history never writes one. A failed open stays off
// for this run; one another process was holding is tried again on the next request.
// for this run; one another process was holding is tried again on the next request, which no write waits for.
let ownerWaitMs: number | undefined;
const histories = historyCache(() =>
openProjectHistory({
projectDir,
historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT,
}).catch((error: unknown) => {
console.warn(`[studio] Project history is off: ${String(error)}`);
if (error instanceof HistoryBusyError || error instanceof HistoryClosedError)
histories.forget(projectDir);
return null;
}),
ownerWaitMs,
})
.then((history) => {
ownerWaitMs = undefined;
return history;
})
.catch((error: unknown) => {
console.warn(`[studio] Project history is off: ${String(error)}`);
if (error instanceof HistoryBusyError) ownerWaitMs = 0;
if (error instanceof HistoryBusyError || error instanceof HistoryClosedError)
histories.forget(projectDir);
return null;
}),
);
const projectHistory = () => histories.get(projectDir);
watcher.addListener((changedPath) => {
Expand Down
9 changes: 9 additions & 0 deletions packages/studio-server/src/createStudioApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
.catch(() => null);
if (project) adapter.invalidateProjectSignature?.(project.dir);
});
api.use("/projects/:id/*", async function openHistorySoNoWriteBecomesItsBaseline(c, next) {
if (c.req.method !== "GET" && c.req.method !== "HEAD" && adapter.history) {
await Promise.resolve()
.then(() => adapter.resolveProject(c.req.param("id")))
.then((project) => project && adapter.history?.(project))
.catch(() => null);
}
await next();
});

registerProjectRoutes(api, adapter);
registerFileRoutes(api, adapter);
Expand Down
14 changes: 9 additions & 5 deletions packages/studio-server/src/history/historyLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,17 @@ export function saveRecord(file: string, log: HistoryLog, record: LogRecord): vo
}
}

export function baselineRecord(log: HistoryLog): LogRecord {
return {
type: "baseline",
files: Object.fromEntries(log.baseline),
...(log.keepsLedger && { keepsLedger: true }),
};
}

export function writeLog(file: string, log: HistoryLog): void {
const records: LogRecord[] = [
{
type: "baseline",
files: Object.fromEntries(log.baseline),
...(log.keepsLedger && { keepsLedger: true }),
},
baselineRecord(log),
...log.entries.map((entry) => ({ type: "entry" as const, entry })),
...[...log.pins].map((id) => ({ type: "pin" as const, id, pinned: true })),
];
Expand Down
163 changes: 163 additions & 0 deletions packages/studio-server/src/history/projectHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,44 @@ import { HistoryIdError } from "./historyId";
import { HistoryClosedError, openProjectHistory, type ProjectHistory } from "./projectHistory";
import { START, type HistoryWho } from "./historyLog";

// A test can hold a media copy, and run work after its blob is stored but before history records it.
const mediaCopy = vi.hoisted(() => ({
held: null as Promise<void> | null,
stored: null as (() => Promise<unknown>) | null,
copies: [] as string[],
fails: null as ((copy: number) => boolean) | null,
}));
vi.mock("./blobStore", async (importOriginal) => {
const real = await importOriginal<typeof import("./blobStore")>();
return {
...real,
openBlobStore: async (dir: string) => {
const store = await real.openBlobStore(dir);
return {
...store,
put: async (path: string) => {
if (path.endsWith(".mp4")) {
await mediaCopy.held;
mediaCopy.copies.push(basename(path));
if (mediaCopy.fails?.(mediaCopy.copies.length)) throw new Error("The disk is full.");
}
const hash = await store.put(path);
if (path.endsWith(".mp4")) await mediaCopy.stored?.();
return hash;
},
};
},
};
});

const you: HistoryWho = { kind: "person", name: "You" };
const pause = (ms: number) => new Promise((settle) => setTimeout(settle, ms));
const agent: HistoryWho = { kind: "agent", name: "Agent" };
const cleanup: Array<() => unknown> = [];

afterEach(async () => {
for (const step of cleanup.splice(0).reverse()) await step();
Object.assign(mediaCopy, { held: null, stored: null, copies: [], fails: null });
});

const inside = (dir: string, path: string) => readFileSync(join(dir, path), "utf-8");
Expand Down Expand Up @@ -265,6 +296,138 @@ describe("openProjectHistory", () => {
expect([read("index.html"), has(".media/manifest.jsonl")]).toEqual(["A", false]);
});

it("a ledger made while closed is still a change after the project's media was copied in the background", async () => {
const { history, write, has, projectDir, historyRoot } = await project(
{ "index.html": "A", "clip.mp4": Buffer.alloc(2 * 1024 ** 2, 7) },
{ quietMs: 30 },
);
await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), {
timeout: 10_000,
});
await history.close();
write("b.png", "png");
write(".media/manifest.jsonl", '{"path":"b.png"}\n');
const reopened = await open(projectDir, historyRoot, { quietMs: 30 });
await reopened.flush();
const [outside] = reopened.list();
expect(outside!.files.map((file) => file.path).sort()).toEqual([
".media/manifest.jsonl",
"b.png",
]);
expect((await reopened.undo(outside!.id, { who: you })).ok).toBe(true);
expect([has("b.png"), has(".media/manifest.jsonl")]).toEqual([false, false]);
});

it("names media in adopting.json until its copy is recorded, so an exit mid-copy resumes it", async () => {
let release = () => {};
mediaCopy.held = new Promise<void>((resolve) => (release = resolve));
const { history, historyRoot } = await project({
"index.html": "A",
"clip.mp4": Buffer.alloc(2 * 1024 ** 2, 7),
});
const adopting = join(historyRoot, history.projectId, "adopting.json");
expect(JSON.parse(readFileSync(adopting, "utf-8"))).toEqual(["clip.mp4"]);
release();
await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), {
timeout: 10_000,
});
expect(existsSync(adopting)).toBe(false);
});

it("keeps media whose recording failed for the next open, rather than log it as added", async () => {
const { history, write, historyRoot } = await project(
{ "index.html": "A", "clip.mp4": Buffer.alloc(2 * 1024 ** 2, 7) },
{ budgetBytes: 1, quietMs: 30 },
);
// The edit's prune takes the first copy; storing it again then fails.
mediaCopy.fails = (copy) => copy === 2;
mediaCopy.stored = () => {
mediaCopy.stored = null;
return change(history, you, "Color", () => write("index.html", "B"));
};
await vi.waitFor(() => expect(mediaCopy.copies).toHaveLength(2), { timeout: 10_000 });
await history.flush();
expect(history.list().flatMap((entry) => entry.files.map((file) => file.path))).not.toContain(
"clip.mp4",
);
const adopting = join(historyRoot, history.projectId, "adopting.json");
expect(JSON.parse(readFileSync(adopting, "utf-8"))).toEqual(["clip.mp4"]);
});

it("never copies the media of a folder that replaced the project", async () => {
let release = () => {};
mediaCopy.held = new Promise<void>((resolve) => (release = resolve));
const media = {
"a.mp4": Buffer.alloc(2 * 1024 ** 2, 1),
"b.mp4": Buffer.alloc(2 * 1024 ** 2, 2),
};
const { history, write, projectDir } = await project({ "index.html": "A", ...media });
renameSync(projectDir, `${projectDir}-moved`);
cleanup.push(() => rmSync(`${projectDir}-moved`, { recursive: true, force: true }));
mkdirSync(projectDir);
for (const [path, bytes] of Object.entries(media)) write(path, bytes);
release();
await history.close();
// Only the copy already running when the folder changed may finish.
expect(mediaCopy.copies).toEqual(["a.mp4"]);
});

it("undoes a clip replaced while closed, though the history closed before its copy finished", async () => {
const clip = Buffer.alloc(2 * 1024 ** 2, 7);
let release = () => {};
mediaCopy.held = new Promise<void>((resolve) => (release = resolve));
const { history, write, projectDir, historyRoot } = await project(
{ "index.html": "A", "clip.mp4": clip },
{ quietMs: 30 },
);
const closed = history.close();
release();
await closed;
write("clip.mp4", Buffer.alloc(2 * 1024 ** 2, 9));
const reopened = await open(projectDir, historyRoot, { quietMs: 30 });
await reopened.flush();
expect(reopened.list().map((entry) => entry.files.map((file) => file.path))).toEqual([
["clip.mp4"],
]);
expect(await reopened.step("back", you)).toMatchObject({ ok: true });
expect(readFileSync(join(projectDir, "clip.mp4")).equals(clip)).toBe(true);
});

it("adopts media whose copy a process exit cut short, rather than log it as added", async () => {
const { history, write, projectDir, historyRoot } = await project({ "index.html": "A" });
await history.close();
// What an exit mid-copy leaves: the clip on disk, named as still being copied, not in the baseline.
write("clip.mp4", Buffer.alloc(2 * 1024 ** 2, 7));
writeFileSync(join(historyRoot, history.projectId, "adopting.json"), '["clip.mp4"]');
const reopened = await open(projectDir, historyRoot, { quietMs: 30 });
await vi.waitFor(() => expect(reopened.peek(START)).toHaveProperty(["clip.mp4"]), {
timeout: 10_000,
});
await reopened.flush();
expect(reopened.list()).toEqual([]);
});

it("keeps a media copy that a budget prune ran into before history recorded it", async () => {
const clip = Buffer.alloc(2 * 1024 ** 2, 7);
let release = () => {};
mediaCopy.held = new Promise<void>((resolve) => (release = resolve));
const { history, write } = await project(
{ "index.html": "A", "clip.mp4": clip },
{ budgetBytes: 1, quietMs: 30 },
);
// The copy is stored; an edit commits and prunes before history records the copy.
mediaCopy.stored = () => {
mediaCopy.stored = null;
return change(history, you, "Color", () => write("index.html", "B"));
};
release();
await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), {
timeout: 10_000,
});
const start = history.peek(START)["clip.mp4"]!;
expect((await history.readBlob(start)).equals(clip)).toBe(true);
});

it("a log 0.8.123 wrote without a ledger takes none in, and one made later while closed is a change", async () => {
const { history, write, projectDir, historyRoot } = await project(
{ "index.html": "A" },
Expand Down
Loading
Loading