Skip to content
Source
Sign inSign up

serviceSelectionLock.ts

88 lines3.6 KB

1// @effect-diagnostics nodeBuiltinImport:off -- Shared with the standalone launcher, which has no Effect runtime.
2// @effect-diagnostics globalTimers:off -- Bounded cross-process lease acquisition in the standalone launcher.
3// @effect-diagnostics globalDate:off -- The standalone launcher has no Effect clock for its acquisition deadline.
4import * as NodeCrypto from "node:crypto";
5import * as NodeFS from "node:fs";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8​
9// Coarse boot time: a lease from an earlier boot is dead even when its PID is live again.
10const bootEpochSeconds = () => Math.round(Date.now() / 1000 - NodeOS.uptime());
11​
12/** Serialize durable selection and unit writes, never manager stop/start commands. */
13export async function acquireServiceSelectionLock(selectionPath: string): Promise<() => void> {
14 const directory = NodePath.dirname(selectionPath);
15 NodeFS.mkdirSync(directory, { recursive: true, mode: 0o700 });
16 const lock = selectionPath + ".selection-lock";
17 const owner = `${process.pid}.${bootEpochSeconds()}.${NodeCrypto.randomUUID()}`;
18 const candidate = `${lock}.${owner}`;
19 const deadline = Date.now() + 30_000;
20 const removeEmptyLock = () => {
21 try {
22 NodeFS.rmdirSync(lock);
23 } catch (error) {
24 if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? ""))
25 throw error;
26 }
27 };
28 NodeFS.mkdirSync(candidate, { mode: 0o700 });
29 try {
30 NodeFS.writeFileSync(NodePath.join(candidate, owner), "", { mode: 0o600 });
31 while (true) {
32 try {
33 // Publish the owner atomically; rename cannot replace another populated directory.
34 NodeFS.renameSync(candidate, lock);
35 return () => {
36 NodeFS.unlinkSync(NodePath.join(lock, owner));
37 removeEmptyLock();
38 };
39 } catch (error) {
40 if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? ""))
41 throw error;
42 }
43 let owners: string[];
44 try {
45 owners = NodeFS.readdirSync(lock);
46 } catch (error) {
47 if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
48 throw error;
49 }
50 if (owners.length === 1) {
51 const previous = owners[0]!;
52 const [pidText, bootText] = previous.split(".");
53 const pid = Number(pidText);
54 if (!Number.isSafeInteger(pid) || pid <= 0)
55 throw new Error(`Invalid service selection lock owner at ${lock}.`);
56 const ownerBoot = Number(bootText);
57 const fromEarlierBoot =
58 Number.isSafeInteger(ownerBoot) && Math.abs(ownerBoot - bootEpochSeconds()) > 60;
59 let dead = fromEarlierBoot;
60 if (!dead) {
61 try {
62 process.kill(pid, 0);
63 } catch (error) {
64 const code = (error as NodeJS.ErrnoException).code;
65 if (code !== "ESRCH" && code !== "EPERM") throw error;
66 dead = code === "ESRCH";
67 }
68 }
69 if (dead) {
70 // Remove only this dead owner's unique file, never a replacement owner's.
71 try {
72 NodeFS.unlinkSync(NodePath.join(lock, previous));
73 } catch (error) {
74 if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
75 }
76 }
77 }
78 removeEmptyLock();
79 if (Date.now() >= deadline)
80 throw new Error(
81 `Service selection is locked at ${lock}. Retry when the other operation finishes.`,
82 );
83 await new Promise<void>((resolve) => setTimeout(resolve, 50));
84 }
85 } finally {
86 NodeFS.rmSync(candidate, { recursive: true, force: true });
87 }