Skip to content
Source
Sign inSign up

workspaceLease.ts

114 lines4.0 KB

1import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";
3import type * as FileSystem from "effect/FileSystem";
4import type * as Path from "effect/Path";
5import * as Semaphore from "effect/Semaphore";
6​
7const leases = new Map<string, { semaphore: Semaphore.Semaphore; users: number }>();
8​
9export const withWorkspaceLease = <A, E, R>(
10 cwd: string,
11 effect: Effect.Effect<A, E, R>,
12): Effect.Effect<A, E, R> =>
13 Effect.suspend(() => {
14 const lease = leases.get(cwd) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 };
15 leases.set(cwd, lease);
16 lease.users++;
17 return lease.semaphore.withPermit(effect).pipe(
18 Effect.ensuring(
19 Effect.sync(() => {
20 lease.users--;
21 if (lease.users === 0) leases.delete(cwd);
22 }),
23 ),
24 );
25 });
26​
27// A restore rewrites files under its directory, so it must exclude provider
28// startup in any overlapping workspace: symlink aliases, parents, and children.
29// Kept apart from withWorkspaceLease because that lease is non-reentrant and
30// restores call Git work that leases the project root.
31interface Reservation {
32 readonly cwd: string;
33 readonly kind: "restore" | "turn-start";
35​
36const reservations = new Set<Reservation>();
37let reservationReleased = Deferred.makeUnsafe<void>();
38​
39// path.relative folds win32 drive-letter case and treats a POSIX backslash as
40// part of a name, unlike a raw prefix check.
41const contains = (path: Path.Path, parent: string, child: string) => {
42 const relative = path.relative(parent, child);
43 return (
44 relative === "" ||
45 (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
46 );
47};
48​
49const conflicts = (path: Path.Path, a: Reservation, b: Reservation) =>
50 (a.kind === "restore" || b.kind === "restore") &&
51 (contains(path, a.cwd, b.cwd) || contains(path, b.cwd, a.cwd));
52​
53const reserve = (path: Path.Path, reservation: Reservation): Effect.Effect<void> =>
54 Effect.suspend(() => {
55 for (const held of reservations) {
56 if (conflicts(path, held, reservation)) {
57 return Deferred.await(reservationReleased).pipe(Effect.andThen(reserve(path, reservation)));
58 }
59 }
60 reservations.add(reservation);
61 return Effect.void;
62 });
63​
64const release = (reservation: Reservation) =>
65 Effect.sync(() => {
66 if (!reservations.delete(reservation)) return;
67 const released = reservationReleased;
68 reservationReleased = Deferred.makeUnsafe<void>();
69 Deferred.doneUnsafe(released, Effect.void);
70 });
71​
72// A worktree removed by storage cleanup is recreated inside the reserved
73// section, so resolve its nearest existing ancestor: a symlink alias above it
74// must still canonicalize the same on both sides. Walks at most the path depth.
75const canonicalize = (
76 fileSystem: FileSystem.FileSystem,
77 path: Path.Path,
78 target: string,
79): Effect.Effect<string> =>
80 fileSystem.realPath(target).pipe(
81 Effect.catchReason("PlatformError", "NotFound", () => {
82 const parent = path.dirname(target);
83 return parent === target
84 ? Effect.succeed(target)
85 : canonicalize(fileSystem, path, parent).pipe(
86 Effect.map((resolved) => path.join(resolved, path.basename(target))),
87 );
88 }),
89 Effect.orElseSucceed(() => target),
90 );
91​
92const withReservation =
93 (kind: Reservation["kind"]) =>
94 <A, E, R>(
95 fileSystem: FileSystem.FileSystem,
96 path: Path.Path,
97 cwd: string,
98 effect: Effect.Effect<A, E, R>,
99 ): Effect.Effect<A, E, R> =>
100 canonicalize(fileSystem, path, cwd).pipe(
101 Effect.flatMap((canonical) => {
102 const reservation: Reservation = { cwd: canonical, kind };
103 return reserve(path, reservation).pipe(
104 Effect.andThen(effect),
105 Effect.ensuring(release(reservation)),
106 );
107 }),
108 );
109​
110/** Exclusive over every overlapping workspace while checkpoint files are restored. */
111export const withCheckpointRestoreReservation = withReservation("restore");
112​
113/** Waits out overlapping restores; turn startups never wait on each other. */
114export const withTurnStartReservation = withReservation("turn-start");