aamio 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ import { constants } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { open, readFile, realpath, rename, unlink } from "node:fs/promises";
4
+ import { spawn } from "node:child_process";
5
+ import { once } from "node:events";
6
+ import { join } from "node:path";
7
+ import { DEFAULT_BASE } from "./aamio.js";
8
+
9
+ /*
10
+ * One live runtime per home, whichever client it is. aamio-python and
11
+ * aamio-php take the operating system's lock on owner.lock for as long as they
12
+ * run: flock() on Linux and macOS, a lock on its first byte on Windows, which
13
+ * PHP's flock() covers by locking the whole file. The system lets go of it when
14
+ * the process ends, however it ends, so no lock outlives its owner and none is
15
+ * ever taken by age.
16
+ *
17
+ * Node has no flock(). Each system gets the nearest thing the other clients'
18
+ * lock sees, so that a home has one owner whichever client starts first:
19
+ *
20
+ * Windows owner.lock opened with no sharing: libuv's UV_FS_O_EXLOCK, which
21
+ * Node passes through but does not name. While Python or PHP holds
22
+ * the file open and locked, the open fails with EBUSY. While Node
23
+ * holds it, they cannot open it, and refuse the home.
24
+ * Linux util-linux flock locks a duplicate of Node's open file descriptor
25
+ * and exits. The lock stays on the shared open file description
26
+ * until Node closes its handle or exits.
27
+ * BSD O_EXLOCK with the open, which is flock()'s lock, on macOS and the
28
+ * BSDs. Not yet tried on a Mac.
29
+ *
30
+ * Where none of these can be had, the home is not taken: held by nobody and
31
+ * not by this one either, nothing proves it free.
32
+ */
33
+
34
+ export const OWNER_LOCK = "owner.lock";
35
+ // The pid file the other clients write beside owner.lock. It names the owner
36
+ // in the message a second runtime gives, and os_lock says its writer held the
37
+ // lock, so a runtime that holds the lock now knows that writer is gone.
38
+ export const PID_FILE = "lock";
39
+
40
+ // libuv opens the file with no sharing for this flag on Windows.
41
+ const WINDOWS_NO_SHARING = 0x10000000;
42
+ // BSD's O_EXLOCK, the same value on macOS, FreeBSD and OpenBSD.
43
+ const BSD_EXLOCK = 0x20;
44
+ const BSD = ["darwin", "freebsd", "openbsd"];
45
+
46
+ // Homes this process holds, for a plainer message when it asks twice.
47
+ const held = new Set();
48
+
49
+ function lockError(code, message) {
50
+ return Object.assign(new Error(message), { code });
51
+ }
52
+
53
+ function unavailable(home, path, why) {
54
+ return lockError("lock_unavailable",
55
+ `cannot establish exclusive ownership of ${home}: ${path} could not be opened or locked (${why}). ` +
56
+ "Restore file access or locking support, or use a different AAMIO_HOME on a local disk.");
57
+ }
58
+
59
+ /** Opens owner.lock in a way that only one process can hold it. The holder, or null when another holds it. */
60
+ async function holdWithOpen(home, path, flags, busy) {
61
+ let handle;
62
+ try {
63
+ handle = await open(path, constants.O_RDWR | constants.O_CREAT | flags, 0o600);
64
+ } catch (error) {
65
+ if (busy.includes(error.code)) return null;
66
+ throw unavailable(home, path, error.code || error.message);
67
+ }
68
+ return { release: () => handle.close() };
69
+ }
70
+
71
+ /** Pass Node's descriptor as fd 3. flock(1) exits before the caller starts serving. */
72
+ async function holdWithFlock(home, path, command) {
73
+ const conflict = 42; // Outside util-linux's sysexits range and distinct from generic failure (1).
74
+ let handle;
75
+ try {
76
+ handle = await open(path, constants.O_RDWR | constants.O_CREAT, 0o600);
77
+ const child = spawn(command, ["-x", "-n", "-E", String(conflict), "3"], {
78
+ stdio: ["ignore", "ignore", "pipe", handle.fd],
79
+ });
80
+ let diagnostic = "";
81
+ child.stderr.setEncoding("utf8");
82
+ child.stderr.on("data", (chunk) => { diagnostic += chunk; });
83
+ const [code, signal] = await once(child, "close");
84
+ if (code === 0) return { release: () => handle.close() };
85
+ if (code !== conflict) {
86
+ throw new Error(diagnostic.trim() || `flock ended with ${signal || code}`);
87
+ }
88
+ } catch (error) {
89
+ await handle?.close();
90
+ const reason = handle && error.code === "ENOENT"
91
+ ? "the flock command was not found: install util-linux"
92
+ : error.code || error.message;
93
+ throw unavailable(home, path, reason);
94
+ }
95
+ await handle.close();
96
+ return null;
97
+ }
98
+
99
+ /** The operating system's lock on owner.lock. The holder, or null when another process holds it. */
100
+ export async function holdOwnerLock(home, { flock = "flock", platform = process.platform } = {}) {
101
+ const path = join(home, OWNER_LOCK);
102
+ if (platform === "win32") return holdWithOpen(home, path, WINDOWS_NO_SHARING, ["EBUSY"]);
103
+ if (BSD.includes(platform)) return holdWithOpen(home, path, BSD_EXLOCK | constants.O_NONBLOCK, ["EAGAIN", "EWOULDBLOCK"]);
104
+ if (platform === "linux") return holdWithFlock(home, path, flock);
105
+ throw unavailable(home, path, `no lock the other clients see is known for ${platform}`);
106
+ }
107
+
108
+ async function readPidFile(home) {
109
+ try {
110
+ const value = JSON.parse(await readFile(join(home, PID_FILE), "utf8"));
111
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ async function writePidFile(home, value) {
118
+ const path = join(home, PID_FILE);
119
+ const temporary = join(home, `lock.${randomUUID()}.tmp`);
120
+ const handle = await open(temporary, "wx", 0o600);
121
+ try {
122
+ try {
123
+ await handle.writeFile(JSON.stringify(value, null, 2), "utf8");
124
+ await handle.sync();
125
+ } finally {
126
+ await handle.close();
127
+ }
128
+ await rename(temporary, path);
129
+ } catch (error) {
130
+ await unlink(temporary).catch(() => {});
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ /** True, false, or null when it cannot be told: a pid that answers signal 0 is alive, and one that is not there is not. */
136
+ function pidAlive(pid) {
137
+ try {
138
+ process.kill(pid, 0);
139
+ return true;
140
+ } catch (error) {
141
+ if (error.code === "ESRCH") return false;
142
+ if (error.code === "EPERM") return true;
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * An owner the lock cannot see: a client from before owner.lock, which wrote
149
+ * the pid file alone. One that held the lock says so with os_lock, and is gone,
150
+ * since this process holds the lock now. Not known to be gone is not gone.
151
+ */
152
+ async function checkOlderOwner(home) {
153
+ const last = await readPidFile(home);
154
+ if (!last || !Number.isSafeInteger(last.pid) || last.pid === process.pid || last.os_lock === true) return;
155
+ const alive = pidAlive(last.pid);
156
+ if (alive === null) {
157
+ throw lockError("home_in_use",
158
+ `could not determine whether aamio (pid ${last.pid}) is still using ${home}; the lock is left untouched. ` +
159
+ "Stop the other runtime, or use a different AAMIO_HOME. Do not remove the lock unless you have confirmed its owner has stopped.");
160
+ }
161
+ if (alive) {
162
+ throw lockError("home_in_use",
163
+ `another aamio (pid ${last.pid}) is using ${home}. Stop it, or use a different AAMIO_HOME. If no aamio is running, ` +
164
+ `the one that took the lock stopped without letting go of it: delete ${join(home, PID_FILE)} and start again.`);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Takes a home for this process, as the other clients do: owner.lock held,
170
+ * and the pid file written with os_lock. Throws home_in_use when another
171
+ * runtime has it, and lock_unavailable when the lock cannot be had at all.
172
+ * release() lets go of both, the pid file first, so a runtime that takes the
173
+ * lock the moment it is free finds none of this one's to wonder about.
174
+ */
175
+ export async function takeHome(home, { host = process.env.AAMIO_HOST || DEFAULT_BASE, ...options } = {}) {
176
+ const real = await realpath(home);
177
+ if (held.has(real)) throw lockError("home_in_use", `another aamio in this process is already using ${home}`);
178
+ const holder = await holdOwnerLock(home, options);
179
+ if (holder === null) {
180
+ const last = await readPidFile(home);
181
+ const named = Number.isSafeInteger(last?.pid) ? ` (pid ${last.pid})` : "";
182
+ throw lockError("home_in_use", `another aamio${named} is using ${home}. Stop it, or use a different AAMIO_HOME.`);
183
+ }
184
+ try {
185
+ await checkOlderOwner(home);
186
+ await writePidFile(home, { pid: process.pid, at: Math.floor(Date.now() / 1000), host, os_lock: true });
187
+ } catch (error) {
188
+ await holder.release();
189
+ throw error;
190
+ }
191
+ held.add(real);
192
+ let released = false;
193
+ return {
194
+ async release() {
195
+ if (released) return;
196
+ released = true;
197
+ const last = await readPidFile(home);
198
+ if (last?.pid === process.pid) await unlink(join(home, PID_FILE)).catch(() => {});
199
+ await holder.release();
200
+ held.delete(real);
201
+ },
202
+ };
203
+ }