@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.
- package/README.md +8 -4
- package/dist/cli/commandCatalog.js +6 -1
- package/dist/cli/invocationRouter.js +2 -1
- package/dist/cli/updatePorts.js +63 -9
- package/dist/cli.js +23 -0
- package/dist/controller/clientRuntime.js +26 -3
- package/dist/controller/resourceCleanupLinux.js +2 -1
- package/dist/controller/resourceInventory.js +4 -2
- package/dist/controller/resourceInventoryLinux.js +62 -5
- package/dist/controller/runtime.js +2 -1
- package/dist/controller/updateReconciliation.js +113 -0
- package/dist/core/controllerClient.js +141 -7
- package/dist/core/controllerEndpoint.js +18 -30
- package/dist/core/controllerProcessIdentity.js +56 -0
- package/dist/core/controllerServer.js +97 -9
- package/dist/core/homeFilesystemIdentity.js +24 -0
- package/dist/core/protocol.js +68 -5
- package/dist/integration/gitIntegrationService.js +3 -3
- package/dist/repository/homeIdentity.js +7 -3
- package/dist/resources/liveReferences.js +35 -2
- package/dist/storage/compatibleTaskStore.js +36 -1
- package/dist/storage/sqliteStore.js +25 -1
- package/dist/storage/taskStore.js +48 -3
- package/package.json +1 -1
|
@@ -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(
|
|
28
|
-
return parseControllerDiscovery(value,
|
|
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(
|
|
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
|
|
45
|
-
||
|
|
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 {
|
|
4
|
-
|
|
5
|
-
export function controllerSocketPath(
|
|
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 = `${
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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(
|
|
26
|
-
|
|
18
|
+
export function isControllerSocketPathForHome(homeId, candidate) {
|
|
19
|
+
try {
|
|
20
|
+
return candidate === controllerSocketPath(validateHomeId(homeId));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
27
23
|
return false;
|
|
28
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/core/protocol.js
CHANGED
|
@@ -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 =
|
|
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, [
|
|
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,
|
|
120
|
+
export function parseControllerDiscovery(value, expected) {
|
|
100
121
|
if (!isRecord(value)
|
|
101
|
-
|| !hasExactKeys(value, [
|
|
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 !==
|
|
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
|
}
|