@zq-silk/yui 0.6.6 → 0.6.8

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.
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstat, readFile } from "node:fs/promises";
3
3
  import { createConnection } from "node:net";
4
- import { join } from "node:path";
5
- import { CONTROLLER_DISCOVERY_PATH, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
6
6
  import { isControllerSocketPathForHome } from "./controllerEndpoint.js";
7
+ import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
8
+ import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "./controllerProcessIdentity.js";
7
9
  export class ControllerClientError extends Error {
8
10
  code;
9
11
  constructor(code, message) {
@@ -24,8 +26,11 @@ export async function readControllerDiscovery(home) {
24
26
  throw invalidDiscovery();
25
27
  }
26
28
  const value = JSON.parse(await readFile(discoveryPath, "utf8"));
27
- const socketPath = discoverySocketPath(home, value);
28
- return parseControllerDiscovery(value, socketPath);
29
+ const socketPath = discoverySocketPath(value);
30
+ return parseControllerDiscovery(value, {
31
+ homeFilesystemId: readHomeFilesystemId(home),
32
+ socketPath
33
+ });
29
34
  }
30
35
  catch (error) {
31
36
  if (error instanceof ControllerClientError)
@@ -36,13 +41,15 @@ export async function readControllerDiscovery(home) {
36
41
  throw invalidDiscovery();
37
42
  }
38
43
  }
39
- function discoverySocketPath(home, value) {
44
+ function discoverySocketPath(value) {
40
45
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
41
46
  throw invalidDiscovery();
42
47
  }
48
+ const homeId = Reflect.get(value, "homeId");
43
49
  const socketPath = Reflect.get(value, "socketPath");
44
- if (typeof socketPath !== "string"
45
- || !isControllerSocketPathForHome(home, socketPath)) {
50
+ if (typeof homeId !== "string"
51
+ || typeof socketPath !== "string"
52
+ || !isControllerSocketPathForHome(homeId, socketPath)) {
46
53
  throw invalidDiscovery();
47
54
  }
48
55
  return socketPath;
@@ -59,6 +66,10 @@ export async function callController(home, method, params = {}, options = {}) {
59
66
  requestLine = encodeControllerRequest({
60
67
  id,
61
68
  token: discovery.token,
69
+ protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
70
+ homeId: discovery.homeId,
71
+ homeFilesystemId: discovery.homeFilesystemId,
72
+ controllerInstanceId: discovery.controllerInstanceId,
62
73
  method,
63
74
  params
64
75
  });
@@ -71,6 +82,129 @@ export async function callController(home, method, params = {}, options = {}) {
71
82
  }
72
83
  return exchange(discovery.socketPath, requestLine, id, timeoutMs);
73
84
  }
85
+ /**
86
+ * One-generation transition used only by the documented `controller restart`
87
+ * upgrade path from the last released protocol (v3). Ordinary calls never
88
+ * accept or dispatch through this legacy shape.
89
+ */
90
+ export async function stopPreviousFileTaskController(home, timeoutMs) {
91
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
92
+ throw new TypeError("Controller timeout must be a positive integer.");
93
+ }
94
+ const discovery = await readPreviousControllerDiscovery(home);
95
+ const homeFilesystemId = readHomeFilesystemId(home);
96
+ if (inspectLiveControllerProcess(discovery.pid, homeFilesystemId, discovery.processStartIdentity) === undefined)
97
+ throw invalidDiscovery();
98
+ const id = randomUUID();
99
+ const requestLine = `${JSON.stringify({
100
+ id,
101
+ token: discovery.token,
102
+ method: "controller.stop",
103
+ params: { expectedPid: discovery.pid }
104
+ })}\n`;
105
+ const deadline = Date.now() + timeoutMs;
106
+ await exchange(discovery.socketPath, requestLine, id, timeoutMs);
107
+ while (inspectLiveControllerProcess(discovery.pid, homeFilesystemId, discovery.processStartIdentity) !== undefined) {
108
+ if (Date.now() >= deadline) {
109
+ throw new ControllerClientError("CONTROLLER_TIMEOUT", `Previous Controller did not stop within ${timeoutMs} ms.`);
110
+ }
111
+ await new Promise((resolve) => setTimeout(resolve, 20));
112
+ }
113
+ return Object.freeze({ pid: discovery.pid });
114
+ }
115
+ /**
116
+ * Explicit restart recovery for an exact Controller whose discovery record
117
+ * was lost. The signal is fenced by same UID, controller entrypoint, physical
118
+ * Home identity, PID, and process-start identity; an unprovable process is
119
+ * never touched.
120
+ */
121
+ export async function stopOrphanedFileTaskController(home, timeoutMs) {
122
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
123
+ throw new TypeError("Controller timeout must be a positive integer.");
124
+ }
125
+ const homeFilesystemId = readHomeFilesystemId(home);
126
+ const candidate = findLiveControllerProcessForHome(homeFilesystemId);
127
+ if (candidate === undefined)
128
+ return undefined;
129
+ if (inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity) === undefined)
130
+ return undefined;
131
+ try {
132
+ process.kill(candidate.pid, "SIGTERM");
133
+ }
134
+ catch (error) {
135
+ if (isNodeError(error) && error.code === "ESRCH")
136
+ return undefined;
137
+ throw error;
138
+ }
139
+ const deadline = Date.now() + timeoutMs;
140
+ while (inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity) !== undefined) {
141
+ if (Date.now() >= deadline) {
142
+ throw new ControllerClientError("CONTROLLER_TIMEOUT", `Orphaned Controller did not stop within ${timeoutMs} ms.`);
143
+ }
144
+ await new Promise((resolve) => setTimeout(resolve, 20));
145
+ }
146
+ return Object.freeze({ pid: candidate.pid });
147
+ }
148
+ async function readPreviousControllerDiscovery(home) {
149
+ const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
150
+ try {
151
+ const metadata = await lstat(discoveryPath);
152
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
153
+ if (!metadata.isFile()
154
+ || (metadata.mode & 0o077) !== 0
155
+ || metadata.size > 4_096
156
+ || (uid !== undefined && metadata.uid !== uid))
157
+ throw invalidDiscovery();
158
+ const value = JSON.parse(await readFile(discoveryPath, "utf8"));
159
+ if (typeof value !== "object"
160
+ || value === null
161
+ || Array.isArray(value)
162
+ || !hasExactStringKeys(value, [
163
+ "pid",
164
+ "processStartIdentity",
165
+ "socketPath",
166
+ "token"
167
+ ]))
168
+ throw invalidDiscovery();
169
+ const record = value;
170
+ if (!Number.isSafeInteger(record.pid)
171
+ || record.pid < 1
172
+ || typeof record.processStartIdentity !== "string"
173
+ || !/^[0-9]{1,32}$/u.test(record.processStartIdentity)
174
+ || typeof record.socketPath !== "string"
175
+ || !isPreviousControllerSocketPath(record.socketPath)
176
+ || typeof record.token !== "string"
177
+ || !/^[a-f0-9]{64}$/u.test(record.token))
178
+ throw invalidDiscovery();
179
+ return Object.freeze({
180
+ pid: record.pid,
181
+ processStartIdentity: record.processStartIdentity,
182
+ socketPath: record.socketPath,
183
+ token: record.token
184
+ });
185
+ }
186
+ catch (error) {
187
+ if (error instanceof ControllerClientError)
188
+ throw error;
189
+ if (isNodeError(error) && error.code === "ENOENT") {
190
+ throw new ControllerClientError("CONTROLLER_NOT_RUNNING", "Controller is not running.");
191
+ }
192
+ throw invalidDiscovery();
193
+ }
194
+ }
195
+ function hasExactStringKeys(value, expected) {
196
+ const keys = Reflect.ownKeys(value);
197
+ return keys.length === expected.length
198
+ && keys.every((key) => typeof key === "string")
199
+ && expected.every((key) => Object.hasOwn(value, key));
200
+ }
201
+ function isPreviousControllerSocketPath(candidate) {
202
+ if (!isAbsolute(candidate) || resolve(candidate) !== candidate)
203
+ return false;
204
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
205
+ return basename(dirname(candidate)) === `yui-${uid}`
206
+ && /^[a-f0-9]{24}\.sock$/u.test(basename(candidate));
207
+ }
74
208
  function exchange(socketPath, requestLine, expectedId, timeoutMs) {
75
209
  return new Promise((resolve, reject) => {
76
210
  const socket = createConnection(socketPath);
@@ -1,37 +1,25 @@
1
- import { createHash } from "node:crypto";
2
1
  import { tmpdir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
- const LINUX_UNIX_SOCKET_PATH_BUDGET = 100;
5
- export function controllerSocketPath(home) {
2
+ import { join } from "node:path";
3
+ import { validateHomeId } from "../repository/homeIdentity.js";
4
+ export function controllerSocketPath(homeId) {
5
+ const identity = validateHomeId(homeId);
6
6
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
7
- const socketName = `${controllerSocketIdentity(home)}.sock`;
8
- const isolatedPath = join(tmpdir(), `yui-${uid}`, socketName);
9
- if (process.platform !== "linux"
10
- || Buffer.byteLength(isolatedPath) < LINUX_UNIX_SOCKET_PATH_BUDGET) {
11
- return isolatedPath;
12
- }
13
- // Linux limits Unix-socket paths to a small fixed budget. Managed Task
14
- // runtimes intentionally use isolated TMPDIR roots, which may themselves
15
- // be nested deeply enough to exhaust that budget. Keep the Home/uid/name
16
- // fence while using the compact system temporary root only for this
17
- // exceptional path; clients follow the Home-owned discovery record.
18
- return join("/tmp", `yui-${uid}`, socketName);
7
+ const socketName = `${identity}.sock`;
8
+ // Linux Unix-socket paths have a small fixed budget. A stable per-uid system
9
+ // root is both short and independent of each caller's isolated TMPDIR.
10
+ const root = process.platform === "linux" ? "/tmp" : tmpdir();
11
+ return join(root, `yui-${uid}`, socketName);
19
12
  }
20
13
  /**
21
- * A protected Home discovery record owns the Controller endpoint. Clients may
22
- * run with an isolated TMPDIR, so validate the published Home/uid identity
23
- * without recomputing the Controller process's temporary root.
14
+ * A protected Home discovery record owns the Controller endpoint. Linux uses
15
+ * one fixed root, so every caller validates the exact Home/uid endpoint
16
+ * independently of its own TMPDIR.
24
17
  */
25
- export function isControllerSocketPathForHome(home, candidate) {
26
- if (!isAbsolute(candidate) || resolve(candidate) !== candidate)
18
+ export function isControllerSocketPathForHome(homeId, candidate) {
19
+ try {
20
+ return candidate === controllerSocketPath(validateHomeId(homeId));
21
+ }
22
+ catch {
27
23
  return false;
28
- const uid = typeof process.getuid === "function" ? process.getuid() : 0;
29
- return basename(dirname(candidate)) === `yui-${uid}`
30
- && basename(candidate) === `${controllerSocketIdentity(home)}.sock`;
31
- }
32
- function controllerSocketIdentity(home) {
33
- return createHash("sha256")
34
- .update(resolve(home))
35
- .digest("hex")
36
- .slice(0, 24);
24
+ }
37
25
  }
@@ -0,0 +1,56 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
3
+ import { readHomeFilesystemId, validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
4
+ /** Exact live-process fence used only at Controller startup and v3 handoff. */
5
+ export function inspectLiveControllerProcess(pid, homeFilesystemId, expectedProcessStartIdentity) {
6
+ if (!Number.isSafeInteger(pid) || pid < 1 || pid === process.pid)
7
+ return undefined;
8
+ const expectedFilesystemId = validateHomeFilesystemId(homeFilesystemId);
9
+ try {
10
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
11
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
12
+ const processUid = /^Uid:\s+([0-9]+)/mu.exec(status)?.[1];
13
+ if (processUid === undefined || Number(processUid) !== uid)
14
+ return undefined;
15
+ const args = readFileSync(`/proc/${pid}/cmdline`)
16
+ .toString("utf8")
17
+ .split("\0")
18
+ .filter((argument) => argument.length > 0);
19
+ if (!args.some((argument) => /(?:^|\/)controllerMain\.js$/u.test(argument))) {
20
+ return undefined;
21
+ }
22
+ const environment = readFileSync(`/proc/${pid}/environ`)
23
+ .toString("utf8")
24
+ .split("\0");
25
+ const yuiHome = environment
26
+ .find((entry) => entry.startsWith("YUI_HOME="))
27
+ ?.slice("YUI_HOME=".length);
28
+ if (yuiHome === undefined || readHomeFilesystemId(yuiHome) !== expectedFilesystemId) {
29
+ return undefined;
30
+ }
31
+ const processStartIdentity = readLinuxProcessStartIdentity(pid);
32
+ if (processStartIdentity === undefined
33
+ || (expectedProcessStartIdentity !== undefined
34
+ && processStartIdentity !== expectedProcessStartIdentity))
35
+ return undefined;
36
+ return Object.freeze({ pid, processStartIdentity });
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** Finds an older/current Controller whose discovery record was lost. */
43
+ export function findLiveControllerProcessForHome(homeFilesystemId) {
44
+ validateHomeFilesystemId(homeFilesystemId);
45
+ if (process.platform !== "linux")
46
+ return undefined;
47
+ const entries = readdirSync("/proc", { encoding: "utf8" });
48
+ for (const entry of entries) {
49
+ if (!/^[1-9][0-9]*$/u.test(entry))
50
+ continue;
51
+ const match = inspectLiveControllerProcess(Number(entry), homeFilesystemId);
52
+ if (match !== undefined)
53
+ return match;
54
+ }
55
+ return undefined;
56
+ }
@@ -6,6 +6,10 @@ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, controllerFailure, encodeControllerResponse, isEmptyParams, parseControllerRequest } from "./protocol.js";
8
8
  import { controllerSocketPath } from "./controllerEndpoint.js";
9
+ import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
10
+ import { findLiveControllerProcessForHome } from "./controllerProcessIdentity.js";
11
+ import { validateHomeId } from "../repository/homeIdentity.js";
12
+ import { readCompatibleHomeIdentity } from "../storage/compatibleTaskStore.js";
9
13
  import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
10
14
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
11
15
  import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
@@ -32,7 +36,14 @@ export async function startControllerServer(home, dispatcher, beforeDiscoveryRem
32
36
  }
33
37
  async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemoval, options = {}) {
34
38
  const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
35
- const socketPath = controllerSocketPath(home);
39
+ const homeId = validateHomeId(readCompatibleHomeIdentity(home).homeId);
40
+ const homeFilesystemId = readHomeFilesystemId(home);
41
+ await assertExistingDiscoveryReplaceable(discoveryPath);
42
+ if (findLiveControllerProcessForHome(homeFilesystemId) !== undefined) {
43
+ throw controllerAlreadyRunning();
44
+ }
45
+ const controllerInstanceId = randomBytes(16).toString("hex");
46
+ const socketPath = controllerSocketPath(homeId);
36
47
  const runtimeDirectory = dirname(discoveryPath);
37
48
  const socketDirectory = dirname(socketPath);
38
49
  await mkdir(runtimeDirectory, { recursive: true, mode: 0o700 });
@@ -66,7 +77,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
66
77
  // the live primary, so its startup identity is still truthful.
67
78
  let primaryIdentity = null;
68
79
  const netServer = createServer((socket) => {
69
- receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, () => primaryIdentity);
80
+ receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, () => primaryIdentity);
70
81
  });
71
82
  try {
72
83
  await listen(netServer, socketPath);
@@ -91,6 +102,11 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
91
102
  await chmod(socketPath, 0o600);
92
103
  const processStartIdentity = await readLinuxProcessStartIdentity(process.pid);
93
104
  const discovery = Object.freeze({
105
+ schemaVersion: 1,
106
+ protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
107
+ homeId,
108
+ homeFilesystemId,
109
+ controllerInstanceId,
94
110
  pid: process.pid,
95
111
  processStartIdentity,
96
112
  socketPath,
@@ -125,7 +141,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
125
141
  eventLoopDelay.stop();
126
142
  await beforeDiscoveryRemoval?.();
127
143
  await closeNetServer(netServer);
128
- await removeOwnedDiscovery(discoveryPath, token);
144
+ await removeOwnedDiscovery(discoveryPath, token, controllerInstanceId);
129
145
  if (options.domainIdentity !== undefined) {
130
146
  // Keep the exact target fence while detached Role panes survive a
131
147
  // Controller stop/restart. The owning test teardown or an expired
@@ -156,6 +172,54 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
156
172
  throw error;
157
173
  }
158
174
  }
175
+ /**
176
+ * Fence an older Controller whose socket name was derived by another protocol.
177
+ * A dead or PID-reused owner is stale and may be replaced; an unverifiable
178
+ * live owner fails closed so an endpoint migration can never create dual
179
+ * writers for one durable Home.
180
+ */
181
+ async function assertExistingDiscoveryReplaceable(discoveryPath) {
182
+ let value;
183
+ try {
184
+ value = JSON.parse(await readFile(discoveryPath, "utf8"));
185
+ }
186
+ catch (error) {
187
+ if (isNodeError(error) && error.code === "ENOENT")
188
+ return;
189
+ throw unsafeExistingDiscovery(discoveryPath, error);
190
+ }
191
+ if (typeof value !== "object"
192
+ || value === null
193
+ || Array.isArray(value)
194
+ || !("pid" in value)
195
+ || !Number.isSafeInteger(value.pid)
196
+ || value.pid < 1) {
197
+ throw unsafeExistingDiscovery(discoveryPath);
198
+ }
199
+ const pid = value.pid;
200
+ if (!isProcessAlive(pid))
201
+ return;
202
+ if (!("processStartIdentity" in value)
203
+ || typeof value.processStartIdentity !== "string"
204
+ || !/^[0-9]{1,32}$/u.test(value.processStartIdentity)) {
205
+ throw unsafeExistingDiscovery(discoveryPath);
206
+ }
207
+ let currentIdentity;
208
+ try {
209
+ currentIdentity = await readLinuxProcessStartIdentity(pid);
210
+ }
211
+ catch (error) {
212
+ if (!isProcessAlive(pid))
213
+ return;
214
+ throw unsafeExistingDiscovery(discoveryPath, error);
215
+ }
216
+ if (currentIdentity === value.processStartIdentity) {
217
+ throw controllerAlreadyRunning();
218
+ }
219
+ }
220
+ function unsafeExistingDiscovery(discoveryPath, cause) {
221
+ return new Error(`Cannot safely replace existing Controller discovery: ${discoveryPath}.`, cause === undefined ? undefined : { cause });
222
+ }
159
223
  async function writeDiscoveryAtomically(discoveryPath, discovery) {
160
224
  const temporaryPath = `${discoveryPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
161
225
  try {
@@ -171,7 +235,7 @@ async function writeDiscoveryAtomically(discoveryPath, discovery) {
171
235
  throw error;
172
236
  }
173
237
  }
174
- function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
238
+ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
175
239
  let buffer = Buffer.alloc(0);
176
240
  let complete = false;
177
241
  const fail = (code, message, id = "invalid") => {
@@ -200,7 +264,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
200
264
  return;
201
265
  }
202
266
  complete = true;
203
- void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity);
267
+ void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity);
204
268
  });
205
269
  socket.on("end", () => {
206
270
  if (!complete)
@@ -208,7 +272,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
208
272
  });
209
273
  socket.on("error", () => undefined);
210
274
  }
211
- async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
275
+ async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
212
276
  let request;
213
277
  try {
214
278
  request = parseControllerRequest(line);
@@ -224,6 +288,16 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
224
288
  sendResponse(socket, controllerFailure(request.id, "UNAUTHORIZED", "Controller authentication failed."));
225
289
  return;
226
290
  }
291
+ if (request.protocolVersion !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION) {
292
+ sendResponse(socket, controllerFailure(request.id, "CONTROLLER_PROTOCOL_MISMATCH", "Controller protocol version does not match the running Controller."));
293
+ return;
294
+ }
295
+ if (request.homeId !== homeId
296
+ || request.homeFilesystemId !== homeFilesystemId
297
+ || request.controllerInstanceId !== controllerInstanceId) {
298
+ sendResponse(socket, controllerFailure(request.id, "CONTROLLER_IDENTITY_MISMATCH", "Controller request identity does not match the running Controller."));
299
+ return;
300
+ }
227
301
  // Every authenticated request is observed once at the routing layer. The
228
302
  // observation completes when the response is handed to the socket, so a
229
303
  // later status snapshot sees prior built-in completions and the dispatcher
@@ -290,6 +364,9 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
290
364
  cliRealpath: controllerCliRealpath(),
291
365
  controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
292
366
  controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
367
+ homeId,
368
+ homeFilesystemId,
369
+ controllerInstanceId,
293
370
  storageBackend: resolveTaskStoreBackendForHome(home, process.env),
294
371
  workerEnabled: resolveStoreWorkerEnabledForHome(home, process.env),
295
372
  mode: "primary",
@@ -322,6 +399,9 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
322
399
  uptimeMs: Math.round(process.uptime() * 1000),
323
400
  rssBytes: process.memoryUsage().rss,
324
401
  protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
402
+ homeId,
403
+ homeFilesystemId,
404
+ controllerInstanceId,
325
405
  version: YUI_VERSION,
326
406
  storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
327
407
  aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
@@ -688,13 +768,15 @@ function closeNetServer(server) {
688
768
  server.close((error) => error === undefined ? resolve() : reject(error));
689
769
  });
690
770
  }
691
- async function removeOwnedDiscovery(path, token) {
771
+ async function removeOwnedDiscovery(path, token, controllerInstanceId) {
692
772
  try {
693
773
  const value = JSON.parse(await readFile(path, "utf8"));
694
774
  if (typeof value === "object"
695
775
  && value !== null
696
776
  && "token" in value
697
- && value.token === token) {
777
+ && value.token === token
778
+ && "controllerInstanceId" in value
779
+ && value.controllerInstanceId === controllerInstanceId) {
698
780
  await rm(path, { force: true });
699
781
  }
700
782
  }
@@ -763,7 +845,7 @@ function safeErrorMessage(message) {
763
845
  .slice(0, 512);
764
846
  return safe.length === 0 ? undefined : safe;
765
847
  }
766
- async function acquireHomeLifecycleLock(home) {
848
+ export async function acquireHomeLifecycleLock(home, options = {}) {
767
849
  const lockPath = homeLifecycleLockPath(home);
768
850
  await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
769
851
  const owner = Object.freeze({
@@ -796,6 +878,12 @@ async function acquireHomeLifecycleLock(home) {
796
878
  if (isProcessAlive(existing.pid)) {
797
879
  throw new Error(`Another Yui home lifecycle operation is already running (${ownerDescription}): ${lockPath}`);
798
880
  }
881
+ if (options.removeStaleOwner === true) {
882
+ // The token comparison in releaseHomeLifecycleLock is the CAS fence: a
883
+ // replacement owner that appeared after the read is never removed.
884
+ await releaseHomeLifecycleLock(lockPath, existing);
885
+ continue;
886
+ }
799
887
  throw new Error(`A previous Yui home lifecycle operation left a stale lock `
800
888
  + `(${ownerDescription}): ${lockPath}. `
801
889
  + "If no Controller startup or development reset is running, "
@@ -0,0 +1,24 @@
1
+ import { statSync } from "node:fs";
2
+ const HOME_FILESYSTEM_ID_PATTERN = /^[0-9]{1,32}:[0-9]{1,32}$/u;
3
+ /**
4
+ * Runtime-only identity of the physical Home directory.
5
+ *
6
+ * Durable `homeId` names the logical Home. Device + inode distinguishes an
7
+ * accidental filesystem copy while allowing symlink aliases of the same
8
+ * directory to share one Controller. It is deliberately not persisted in the
9
+ * Home aggregate because replacing or copying the directory must change this
10
+ * runtime fence.
11
+ */
12
+ export function readHomeFilesystemId(home) {
13
+ const metadata = statSync(home, { bigint: true });
14
+ if (!metadata.isDirectory()) {
15
+ throw new Error("YUI_HOME is not a directory.");
16
+ }
17
+ return `${metadata.dev}:${metadata.ino}`;
18
+ }
19
+ export function validateHomeFilesystemId(value) {
20
+ if (typeof value !== "string" || !HOME_FILESYSTEM_ID_PATTERN.test(value)) {
21
+ throw new Error("Home filesystem identity is invalid.");
22
+ }
23
+ return value;
24
+ }
@@ -1,7 +1,9 @@
1
+ import { validateHomeId } from "../repository/homeIdentity.js";
2
+ import { validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
1
3
  export const MAX_CONTROLLER_MESSAGE_BYTES = 1_048_576;
2
4
  export const CONTROLLER_DISCOVERY_PATH = "runtime/controller.json";
3
5
  /** Bump when a running Controller cannot safely share one YUI_HOME with this CLI. */
4
- export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION = 3;
6
+ export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION = 4;
5
7
  export class ControllerProtocolError extends Error {
6
8
  code;
7
9
  id;
@@ -18,12 +20,27 @@ export function parseControllerRequest(line) {
18
20
  }
19
21
  try {
20
22
  const value = JSON.parse(line);
21
- if (!isRecord(value) || !hasExactKeys(value, ["id", "token", "method", "params"])) {
23
+ if (!isRecord(value) || !hasExactKeys(value, [
24
+ "id",
25
+ "token",
26
+ "protocolVersion",
27
+ "homeId",
28
+ "homeFilesystemId",
29
+ "controllerInstanceId",
30
+ "method",
31
+ "params"
32
+ ])) {
22
33
  throw new Error("shape");
23
34
  }
24
35
  if (!isIdentifier(value.id)
25
36
  || typeof value.token !== "string"
26
37
  || !/^[a-f0-9]{64}$/u.test(value.token)
38
+ || !Number.isSafeInteger(value.protocolVersion)
39
+ || value.protocolVersion < 1
40
+ || !isHomeId(value.homeId)
41
+ || !isHomeFilesystemId(value.homeFilesystemId)
42
+ || typeof value.controllerInstanceId !== "string"
43
+ || !/^[a-f0-9]{32}$/u.test(value.controllerInstanceId)
27
44
  || typeof value.method !== "string"
28
45
  || value.method.length > 128
29
46
  || !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/u.test(value.method)
@@ -33,6 +50,10 @@ export function parseControllerRequest(line) {
33
50
  return Object.freeze({
34
51
  id: value.id,
35
52
  token: value.token,
53
+ protocolVersion: value.protocolVersion,
54
+ homeId: value.homeId,
55
+ homeFilesystemId: value.homeFilesystemId,
56
+ controllerInstanceId: value.controllerInstanceId,
36
57
  method: value.method,
37
58
  params: value.params
38
59
  });
@@ -96,19 +117,41 @@ export function controllerFailure(id, code, message) {
96
117
  error: Object.freeze({ code, message })
97
118
  });
98
119
  }
99
- export function parseControllerDiscovery(value, expectedSocketPath) {
120
+ export function parseControllerDiscovery(value, expected) {
100
121
  if (!isRecord(value)
101
- || !hasExactKeys(value, ["pid", "processStartIdentity", "socketPath", "token"])
122
+ || !hasExactKeys(value, [
123
+ "schemaVersion",
124
+ "protocolVersion",
125
+ "homeId",
126
+ "homeFilesystemId",
127
+ "controllerInstanceId",
128
+ "pid",
129
+ "processStartIdentity",
130
+ "socketPath",
131
+ "token"
132
+ ])
133
+ || value.schemaVersion !== 1
134
+ || value.protocolVersion !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION
135
+ || !isHomeId(value.homeId)
136
+ || value.homeFilesystemId !== expected.homeFilesystemId
137
+ || !isHomeFilesystemId(value.homeFilesystemId)
138
+ || typeof value.controllerInstanceId !== "string"
139
+ || !/^[a-f0-9]{32}$/u.test(value.controllerInstanceId)
102
140
  || !Number.isSafeInteger(value.pid)
103
141
  || value.pid < 1
104
142
  || typeof value.processStartIdentity !== "string"
105
143
  || !/^[0-9]{1,32}$/u.test(value.processStartIdentity)
106
- || value.socketPath !== expectedSocketPath
144
+ || value.socketPath !== expected.socketPath
107
145
  || typeof value.token !== "string"
108
146
  || !/^[a-f0-9]{64}$/u.test(value.token)) {
109
147
  throw new ControllerProtocolError("CONTROLLER_DISCOVERY_INVALID", "Controller discovery is invalid.");
110
148
  }
111
149
  return Object.freeze({
150
+ schemaVersion: 1,
151
+ protocolVersion: value.protocolVersion,
152
+ homeId: value.homeId,
153
+ homeFilesystemId: value.homeFilesystemId,
154
+ controllerInstanceId: value.controllerInstanceId,
112
155
  pid: value.pid,
113
156
  processStartIdentity: value.processStartIdentity,
114
157
  socketPath: value.socketPath,
@@ -131,6 +174,26 @@ function isIdentifier(value) {
131
174
  return typeof value === "string"
132
175
  && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
133
176
  }
177
+ function isHomeId(value) {
178
+ if (typeof value !== "string")
179
+ return false;
180
+ try {
181
+ validateHomeId(value);
182
+ return true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ function isHomeFilesystemId(value) {
189
+ try {
190
+ validateHomeFilesystemId(value);
191
+ return true;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
134
197
  function isRecord(value) {
135
198
  return typeof value === "object" && value !== null && !Array.isArray(value);
136
199
  }