Skip to content

Commit 9fce5af

Browse files
committed
Restore workspace watchers on server startup
1 parent 844f883 commit 9fce5af

4 files changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import chokidar, { type FSWatcher } from "chokidar";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import { createServer, type Server } from "../server.js";
7+
import { dispatch } from "../ws/dispatch.js";
8+
9+
import "../commands/workspace.js";
10+
11+
describe("workspace watcher hydrate restart", () => {
12+
let server: Server | undefined;
13+
let dataDir: string;
14+
let dbPath: string;
15+
let workspaceDir: string;
16+
let watchSpy: ReturnType<typeof vi.spyOn<typeof chokidar, "watch">>;
17+
18+
beforeEach(() => {
19+
dataDir = mkdtempSync(join(tmpdir(), "coder-studio-data-"));
20+
dbPath = join(dataDir, "coder-studio.db");
21+
workspaceDir = mkdtempSync(join(tmpdir(), "coder-studio-workspace-"));
22+
mkdirSync(join(workspaceDir, ".git"), { recursive: true });
23+
writeFileSync(join(workspaceDir, ".git", "HEAD"), "ref: refs/heads/main\n");
24+
25+
watchSpy = vi.spyOn(chokidar, "watch").mockReturnValue({
26+
on() {
27+
return this;
28+
},
29+
close: vi.fn().mockResolvedValue(undefined),
30+
} as unknown as FSWatcher);
31+
});
32+
33+
afterEach(async () => {
34+
if (server) {
35+
await server.stop();
36+
server = undefined;
37+
}
38+
watchSpy.mockRestore();
39+
rmSync(dataDir, { recursive: true, force: true });
40+
rmSync(workspaceDir, { recursive: true, force: true });
41+
});
42+
43+
it("restores persisted workspace watchers after server restart", async () => {
44+
server = await createServer({
45+
dataDir: dbPath,
46+
host: "127.0.0.1",
47+
port: 0,
48+
});
49+
50+
const firstCtx = server.__test__!.commandContext;
51+
52+
const openResult = await dispatch(
53+
{
54+
kind: "command",
55+
id: "workspace-open",
56+
op: "workspace.open",
57+
args: { path: workspaceDir },
58+
},
59+
firstCtx
60+
);
61+
62+
expect(openResult.ok).toBe(true);
63+
expect(watchSpy).toHaveBeenCalledTimes(1);
64+
65+
await server.stop();
66+
server = undefined;
67+
watchSpy.mockClear();
68+
69+
server = await createServer({
70+
dataDir: dbPath,
71+
host: "127.0.0.1",
72+
port: 0,
73+
});
74+
75+
expect(watchSpy).toHaveBeenCalledTimes(1);
76+
expect(watchSpy).toHaveBeenCalledWith(
77+
workspaceDir,
78+
expect.objectContaining({
79+
ignoreInitial: true,
80+
persistent: true,
81+
})
82+
);
83+
});
84+
});

packages/server/src/__tests__/workspace/manager.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { DatabaseSync } from "node:sqlite";
66
import type { DomainEvent } from "@coder-studio/core";
7+
import chokidar, { type FSWatcher } from "chokidar";
78
import { mkdir, rmdir } from "fs/promises";
89
import { tmpdir } from "os";
910
import { join } from "path";
@@ -20,6 +21,7 @@ describe("WorkspaceManager", () => {
2021
emit: (event: DomainEvent) => void;
2122
on: () => () => void;
2223
};
24+
let watchSpy: ReturnType<typeof vi.spyOn<typeof chokidar, "watch">>;
2325

2426
beforeEach(async () => {
2527
// Create test directory
@@ -53,10 +55,18 @@ describe("WorkspaceManager", () => {
5355
on: () => () => {},
5456
};
5557

58+
watchSpy = vi.spyOn(chokidar, "watch").mockReturnValue({
59+
on() {
60+
return this;
61+
},
62+
close: vi.fn().mockResolvedValue(undefined),
63+
} as unknown as FSWatcher);
64+
5665
manager = new WorkspaceManager({ db, eventBus });
5766
});
5867

5968
afterEach(async () => {
69+
watchSpy.mockRestore();
6070
try {
6171
db.close();
6272
await rmdir(testDir);
@@ -224,6 +234,46 @@ describe("WorkspaceManager", () => {
224234
});
225235
});
226236

237+
describe("hydrateWatchers", () => {
238+
it("starts file watchers for persisted workspaces", async () => {
239+
const persisted = await manager.open({ path: testDir });
240+
const broadcaster = { broadcast: vi.fn() };
241+
const restoredManager = new WorkspaceManager({ db, eventBus, broadcaster });
242+
243+
restoredManager.hydrateWatchers();
244+
245+
expect(watchSpy).toHaveBeenCalledTimes(1);
246+
expect(watchSpy).toHaveBeenCalledWith(
247+
testDir,
248+
expect.objectContaining({
249+
ignoreInitial: true,
250+
persistent: true,
251+
})
252+
);
253+
expect(
254+
(restoredManager as unknown as { watchers: Map<string, unknown> }).watchers.has(
255+
persisted.id
256+
)
257+
).toBe(true);
258+
});
259+
260+
it("does not create duplicate watchers when called multiple times", async () => {
261+
const persisted = await manager.open({ path: testDir });
262+
const broadcaster = { broadcast: vi.fn() };
263+
const restoredManager = new WorkspaceManager({ db, eventBus, broadcaster });
264+
265+
restoredManager.hydrateWatchers();
266+
restoredManager.hydrateWatchers();
267+
268+
expect(watchSpy).toHaveBeenCalledTimes(1);
269+
expect(
270+
(restoredManager as unknown as { watchers: Map<string, unknown> }).watchers.has(
271+
persisted.id
272+
)
273+
).toBe(true);
274+
});
275+
});
276+
227277
describe("updateUiState", () => {
228278
it("updates workspace pane layout and emits workspace meta changed", async () => {
229279
const workspace = await manager.open({ path: testDir });

packages/server/src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export async function createServer(
136136
console.warn("[uploads] cascade cleanup failed", { wsId: workspaceId, err })
137137
),
138138
});
139+
workspaceMgr.hydrateWatchers();
139140

140141
const authSessionRepo = new AuthSessionRepo(db);
141142
const authLoginBlockRepo = new AuthLoginBlockRepo(db);

packages/server/src/workspace/manager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ export class WorkspaceManager {
6060
);
6161
}
6262

63+
hydrateWatchers(): void {
64+
for (const workspace of this.list()) {
65+
this.startWatcher(workspace.id, workspace.path);
66+
}
67+
}
68+
6369
updateUiState(workspaceId: string, uiState: Workspace["uiState"]): void {
6470
const workspace = this.get(workspaceId);
6571
if (!workspace) {

0 commit comments

Comments
 (0)