1import * as Clock from "effect/Clock";
2import * as Console from "effect/Console";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Schedule from "effect/Schedule";
10import * as Semaphore from "effect/Semaphore";
11import { rootDir } from "./Paths.ts";
12
13const semaphores = new Map<string, Semaphore.Semaphore>();
14
15// Keep STALE well above REFRESH — a saturated machine can starve the
16// heartbeat fiber for seconds.
17const STALE = Duration.seconds(30);
18const REFRESH = Duration.seconds(10);
19const RETRY_INTERVAL = Duration.millis(50);
20const DEFAULT_TIMEOUT = Duration.minutes(2);
21// How often to remind the user that a lock wait / locked operation is still
22// outstanding. Long enough that healthy runs never print it.
23const STALL_INTERVAL = Duration.seconds(30);
24
25class LockHeld extends Data.TaggedError("LockHeld") {}
26
27class LockTimeout extends Data.TaggedError("LockTimeout")<{
28 readonly lockPath: string;
29 readonly timeout: Duration.Input;
30 readonly message: string;
31}> {}
32
33/**
34 * Make a lock key safe to use as a file name on every platform.
35 *
36 * Keys are derived from user-controlled values (profile names), which
37 * have shown up in production containing shell placeholders like
38 * `${ALCHEMY_PROFILE:-default}` — `:`/`{`/`$` are invalid in Windows
39 * file names and mkdir fails with EINVAL. Collapse anything outside a
40 * conservative allow-list to `_`.
41 *
42 * @internal exported for unit testing.
43 */
44export const sanitizeLockKey = (key: string): string =>
45 key.replace(/[^A-Za-z0-9._-]/g, "_");
46
47/**
48 * Take the cross-process lock. On success the ambient scope owns it: a
49 * finalizer removes it and a forked heartbeat keeps its mtime fresh.
50 */
51const acquireFileLock = Effect.fn(function* (
52 lockPath: string,
53 timeout: Duration.Input,
54) {
55 const fs = yield* FileSystem.FileSystem;
56 const path = yield* Path.Path;
57 const ownerPath = path.join(lockPath, "owner");
58 const owner = yield* Effect.sync(() => crypto.randomUUID());
59
60 yield* fs.makeDirectory(path.dirname(lockPath), { recursive: true });
61
62 // Reap a lock whose crashed holder stopped refreshing its mtime.
63 const reapStale = Effect.gen(function* () {
64 const info = yield* fs.stat(lockPath);
65 const now = yield* Clock.currentTimeMillis;
66 const isStale = Option.exists(
67 info.mtime,
68 (mtime) => mtime.getTime() < now - Duration.toMillis(STALE),
69 );
70 if (isStale) {
71 yield* fs.remove(lockPath, { recursive: true, force: true });
72 }
73 }).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.void));
74
75 // A non-recursive mkdir is the atomic test-and-set; the owner marker lets
76 // release and refresh verify the lock wasn't reaped and re-taken.
77 yield* Effect.acquireRelease(
78 fs
79 .makeDirectory(lockPath)
80 .pipe(
81 Effect.andThen(
82 fs
83 .writeFileString(ownerPath, owner)
84 .pipe(
85 Effect.onError(() =>
86 fs
87 .remove(lockPath, { recursive: true, force: true })
88 .pipe(Effect.ignore),
89 ),
90 ),
91 ),
92 ),
93 () =>
94 fs.readFileString(ownerPath).pipe(
95 Effect.filterOrFail((current) => current === owner),
96 Effect.andThen(fs.remove(lockPath, { recursive: true, force: true })),
97 Effect.ignore,
98 ),
99 ).pipe(
100 Effect.catchReason("PlatformError", "AlreadyExists", () =>
101 reapStale.pipe(Effect.andThen(Effect.fail(new LockHeld()))),
102 ),
103 Effect.retry({
104 while: (error) => error._tag === "LockHeld",
105 schedule: Schedule.spaced(RETRY_INTERVAL).pipe(
106 Schedule.upTo({ duration: timeout }),
107 ),
108 }),
109 Effect.catchTag("LockHeld", () =>
110 Effect.die(
111 new LockTimeout({
112 lockPath,
113 timeout,
114 message:
115 `Timed out waiting for the alchemy auth lock '${lockPath}' — another alchemy ` +
116 `process has held it for over ${Duration.toSeconds(timeout)}s. If no other ` +
117 `alchemy process is running, delete the lock directory and retry.`,
118 }),
119 ),
120 ),
121 );
122
123 yield* fs.readFileString(ownerPath).pipe(
124 Effect.filterOrFail((current) => current === owner),
125 Effect.andThen(
126 Clock.currentTimeMillis.pipe(Effect.map((now) => new Date(now))),
127 ),
128 // NB: utimes interprets a bare number as *seconds* since epoch.
129 Effect.flatMap((now) => fs.utimes(lockPath, now, now)),
130 Effect.repeat(Schedule.spaced(REFRESH)),
131 Effect.catch(() =>
132 Effect.logWarning(
133 `auth lock compromised (continuing): '${lockPath}' is no longer owned by this process`,
134 ),
135 ),
136 // Acquisition returns while this fiber refreshes until the lock scope ends.
137 Effect.forkScoped,
138 );
139});
140
141type Phase = { current: "waiting" | "held" };
142
143/**
144 * Periodic "I am not dead, I am stuck here" notice, forked for the lifetime
145 * of a {@link withLock} call.
146 *
147 * Deliberately `Console.error` rather than `Effect.logWarning`: the CLI's
148 * telemetry layer replaces the console logger with the OTLP one (see
149 * `Telemetry/Layer.ts`), so log records reach `.alchemy/log/out` but never
150 * the terminal. The whole point of this notice is to reach the user who is
151 * watching a frozen terminal, so it must not go through the logger.
152 */
153const stallNotice = (
154 lockPath: string,
155 label: string,
156 phase: Phase,
157 interval: Duration.Input,
158) =>
159 Effect.gen(function* () {
160 const start = yield* Clock.currentTimeMillis;
161 const notice = Effect.gen(function* () {
162 // Report real elapsed time, not an accumulated interval count — the
163 // notice cadence and the reported wait must not drift apart.
164 const now = yield* Clock.currentTimeMillis;
165 const seconds = Math.round((now - start) / 1000);
166 yield* Console.error(
167 phase.current === "waiting"
168 ? `alchemy: ${seconds}s waiting for the auth lock '${lockPath}' (${label}). ` +
169 `Another alchemy process holds it; if none is running, delete that directory.`
170 : `alchemy: ${label} has been running for ${seconds}s while holding the auth lock ` +
171 `'${lockPath}'. Re-run with --log-level debug for detail.`,
172 );
173 });
174 // `Effect.schedule` (unlike `Effect.repeat`) consults the schedule
175 // before the first run, and `Schedule.fixed`'s first recurrence comes
176 // after one full interval — so notices fire at interval, 2*interval, …
177 // on true wall-clock boundaries.
178 yield* Effect.schedule(notice, Schedule.fixed(interval));
179 });
180
181/**
182 * Serialise execution of `effect` for the same `key`, both within this
183 * process (a semaphore) and across processes on the same machine (an atomic
184 * lock directory whose mtime is refreshed while held so another process can
185 * recover it after a crash).
186 *
187 * Failure to create or acquire the lock is fatal. Authentication writes its
188 * profile state beneath the same root, so continuing without a writable lock
189 * cannot produce a valid deployment and would permit concurrent corruption.
190 */
191export const withLock = <A, E, R>(
192 key: string,
193 effect: Effect.Effect<A, E, R>,
194 options?: {
195 readonly timeout?: Duration.Input;
196 /**
197 * Human-readable name of the work being serialised, used in the debug
198 * log lines and the stall notice. Defaults to `key`.
199 */
200 readonly label?: string;
201 /**
202 * Print a periodic notice to stderr while this lock is being waited on
203 * or held (see {@link stallNotice}). Pass `false` for flows that
204 * legitimately block on the user — a browser OAuth round-trip, an
205 * `aws sso login` child process — where a repeating notice would be
206 * both wrong and destructive to the prompt on screen.
207 *
208 * @default true
209 */
210 readonly watchdog?: boolean;
211 /**
212 * Interval between stall notices.
213 *
214 * @internal exposed so tests need not wait 30 real seconds.
215 */
216 readonly stallInterval?: Duration.Input;
217 },
218) => {
219 const safeKey = sanitizeLockKey(key);
220 const label = options?.label ?? key;
221 let semaphore = semaphores.get(safeKey);
222 if (semaphore === undefined) {
223 semaphore = Semaphore.makeUnsafe(1);
224 semaphores.set(safeKey, semaphore);
225 }
226 return semaphore.withPermit(
227 Effect.gen(function* () {
228 const path = yield* Path.Path;
229 const lockPath = path.join(rootDir(), "lock", `${safeKey}.lock`);
230 const phase: Phase = { current: "waiting" };
231 if (options?.watchdog !== false) {
232 yield* Effect.forkScoped(
233 stallNotice(
234 lockPath,
235 label,
236 phase,
237 options?.stallInterval ?? STALL_INTERVAL,
238 ),
239 );
240 }
241 yield* Effect.logDebug(`auth lock: acquiring '${lockPath}' for ${label}`);
242 yield* acquireFileLock(
243 lockPath,
244 options?.timeout ?? DEFAULT_TIMEOUT,
245 ).pipe(Effect.orDie);
246 phase.current = "held";
247 yield* Effect.logDebug(`auth lock: acquired '${lockPath}' for ${label}`);
248 return yield* effect.pipe(
249 Effect.onExit(() =>
250 Effect.logDebug(`auth lock: releasing '${lockPath}' for ${label}`),
251 ),
252 );
253 }).pipe(Effect.scoped),
254 );
255};
256
257/**
258 * Serialize an operation with every credential mutation for a profile. The
259 * lock key is shared by every credential operation for the profile,
260 * including profile-wide rename and delete operations.
261 */
262export const withProfileCredentialsLock = <A, E, R>(
263 profileName: string,
264 effect: Effect.Effect<A, E, R>,
265) => withLock(`profile-credentials-${profileName}`, effect);