relay-companion 0.1.552 → 0.1.554
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/bootstrap/application-handoff.cjs +1 -0
- package/bootstrap/application-install.cjs +1 -0
- package/bootstrap/lifecycle-ownership.cjs +69 -0
- package/bootstrap/mac-service-recovery.cjs +4 -2
- package/bootstrap/node-contract.cjs +41 -0
- package/bootstrap/owned-node-runtime.cjs +5 -1
- package/bootstrap/recovery-client.cjs +74 -0
- package/bootstrap/recovery-install.cjs +15 -3
- package/bootstrap/recovery-intent.cjs +11 -0
- package/bootstrap/recovery-launcher.cjs +45 -6
- package/bootstrap/recovery-runner.cjs +31 -19
- package/bootstrap/recovery-schedule-handover.cjs +52 -0
- package/bootstrap/recovery-transaction.cjs +2 -2
- package/bootstrap/relay-setup.cjs +4 -0
- package/bootstrap/runtime-health.cjs +16 -4
- package/bootstrap/service-recovery.cjs +58 -0
- package/bootstrap/update-watchdog.cjs +1 -0
- package/package.json +1 -1
|
@@ -24,6 +24,7 @@ function read(file) {
|
|
|
24
24
|
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
25
25
|
}
|
|
26
26
|
function updateConsent({ homeDir, env = process.env }) {
|
|
27
|
+
if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: false, reason: "intentionally-stopped" };
|
|
27
28
|
if (/^(0|false|off|no)$/i.test(String(env.RELAY_AUTO_UPDATE || ""))
|
|
28
29
|
|| read(path.join(homeDir, ".relay", "recovery", "policy.json"))?.autoUpdate === false) return { ok: false, reason: "updates-disabled" };
|
|
29
30
|
const root = path.join(homeDir, ".relay");
|
|
@@ -136,6 +136,7 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
|
|
|
136
136
|
throw new Error("Legacy installation requires the separately planned repair route");
|
|
137
137
|
}
|
|
138
138
|
if (current && versionCompare(bundle.receipt.version, current.version) < 0) throw new Error("An older installer cannot downgrade Relay");
|
|
139
|
+
require("./recovery-intent.cjs").setStopped(false, homeDir);
|
|
139
140
|
// Live services, not files, decide whether the existing Relay is healthy.
|
|
140
141
|
// A runtime whose daemon and pill are not running gets one repair through
|
|
141
142
|
// its own CLI before it is judged; only a runtime that stays down blocks.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// All application-facing repairs use the same ownership record as activation.
|
|
3
|
+
// A child of an existing installer joins that exact generation; it never takes
|
|
4
|
+
// an unrelated lock or trusts a caller-supplied boolean saying "updating".
|
|
5
|
+
const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
|
|
6
|
+
const locks = require("./recovery-launcher.cjs");
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
const heldLeases = new Map();
|
|
9
|
+
const read = file => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
|
|
10
|
+
|
|
11
|
+
function lifecycleOwnership({ homeDir = os.homedir(), env = process.env,
|
|
12
|
+
acquire = locks.acquireCanonicalLock, pid = process.pid, parentPid = process.ppid,
|
|
13
|
+
alive = locks.processAlive, identity = locks.nativeProcessIdentity } = {}) {
|
|
14
|
+
const file = path.join(homeDir, ".relay", "runtime", "transaction.lock", "owner.json");
|
|
15
|
+
const observed = read(file);
|
|
16
|
+
let lock;
|
|
17
|
+
const delegated = observed && (observed.pid === parentPid
|
|
18
|
+
|| (env.RELAY_LIFECYCLE_OWNER === `${observed.pid}:${observed.nonce}`));
|
|
19
|
+
const held = heldLeases.get(file);
|
|
20
|
+
if (delegated && held?.owner.nonce === observed.nonce) {
|
|
21
|
+
held.assert();
|
|
22
|
+
held.references++;
|
|
23
|
+
let released = false;
|
|
24
|
+
return { ...held, release: () => { if (released) return; released = true; if (--held.references === 0) { heldLeases.delete(file); held.dispose(); } } };
|
|
25
|
+
}
|
|
26
|
+
if (!delegated) lock = acquire(path.dirname(file));
|
|
27
|
+
const owner = read(file);
|
|
28
|
+
const lockDir = path.dirname(file);
|
|
29
|
+
let participant;
|
|
30
|
+
// Only join while the granting process still exists. A stale environment
|
|
31
|
+
// capability cannot resurrect a dead transaction.
|
|
32
|
+
if (delegated && owner?.pid !== pid) {
|
|
33
|
+
if (!alive(owner?.pid) || (owner.processIdentity && identity(owner.pid) !== owner.processIdentity)) throw Error("lifecycle-owner-ended");
|
|
34
|
+
participant = path.join(lockDir, `participant-${pid}-${crypto.randomBytes(16).toString("hex")}.json`);
|
|
35
|
+
fs.writeFileSync(participant, JSON.stringify({ nonce: owner.nonce, pid, createdAt: Date.now(), processIdentity: identity(pid) }), { flag: "wx", mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
const assert = () => {
|
|
38
|
+
const current = read(file);
|
|
39
|
+
if (!owner?.nonce || current?.nonce !== owner.nonce || current.pid !== owner.pid
|
|
40
|
+
|| fs.existsSync(path.join(lockDir, "reclaim.json"))
|
|
41
|
+
|| (!participant && (!alive(owner.pid) || (owner.processIdentity && identity(owner.pid) !== owner.processIdentity)))) {
|
|
42
|
+
throw Error("lifecycle-owner-changed");
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const dispose = () => {
|
|
46
|
+
if (participant && read(file)?.nonce === owner.nonce) fs.rmSync(participant, { force: true });
|
|
47
|
+
lock?.release();
|
|
48
|
+
};
|
|
49
|
+
try { assert(); } catch (error) { dispose(); throw error; }
|
|
50
|
+
const lease = {
|
|
51
|
+
owner, assert, delegated: !lock,
|
|
52
|
+
env: { ...env, RELAY_LIFECYCLE_OWNER: `${owner.pid}:${owner.nonce}` },
|
|
53
|
+
references: 1, dispose,
|
|
54
|
+
};
|
|
55
|
+
heldLeases.set(file, lease);
|
|
56
|
+
let released = false;
|
|
57
|
+
return { ...lease, release: () => {
|
|
58
|
+
if (released) return;
|
|
59
|
+
released = true;
|
|
60
|
+
if (--lease.references === 0) { heldLeases.delete(file); dispose(); }
|
|
61
|
+
} };
|
|
62
|
+
}
|
|
63
|
+
function ownerEnvironment({ homeDir = os.homedir(), env = process.env } = {}) {
|
|
64
|
+
const owner = read(path.join(homeDir, ".relay", "runtime", "transaction.lock", "owner.json"));
|
|
65
|
+
// Only the actual owner may hand its generation to a worker.
|
|
66
|
+
return owner?.pid === process.pid && owner.nonce
|
|
67
|
+
? { ...env, RELAY_LIFECYCLE_OWNER: `${owner.pid}:${owner.nonce}` } : { ...env };
|
|
68
|
+
}
|
|
69
|
+
module.exports = { lifecycleOwnership, ownerEnvironment };
|
|
@@ -26,6 +26,7 @@ function readRegistration(label, { homeDir = os.homedir(), run = defaultRun, fsI
|
|
|
26
26
|
const plist = JSON.parse(decoded.stdout);
|
|
27
27
|
const args = plist.ProgramArguments;
|
|
28
28
|
if (plist.Label !== label || !Array.isArray(args) || !args.every(x => typeof x === "string") || plist.Program) throw Error(`invalid-service-plist: ${label}`);
|
|
29
|
+
if (label === LABELS[0] && require("./node-contract.cjs").isElectronExecutable(args[0])) throw Error(`invalid-service-interpreter: ${label}`);
|
|
29
30
|
const suffix = label === LABELS[0] ? /[\\/]bin[\\/]relay\.js$/ : /[\\/]overlay[\\/]main\.cjs$/;
|
|
30
31
|
const script = args.find(x => /[\\/]node_modules[\\/]relay-companion[\\/]/.test(x) && suffix.test(x));
|
|
31
32
|
if (!script || !path.isAbsolute(args[0]) || !path.isAbsolute(script) || (label === LABELS[0] && !args.includes("daemon"))) throw Error(`invalid-service-target: ${label}`);
|
|
@@ -84,7 +85,7 @@ async function restartMacRegisteredServices(target, {
|
|
|
84
85
|
healthCheck = require("./runtime-health.cjs").exactRuntimeHealth,
|
|
85
86
|
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
86
87
|
} = {}) {
|
|
87
|
-
let lock;
|
|
88
|
+
let lock, releaseDrain;
|
|
88
89
|
try { lock = acquireLock(path.join(homeDir, ".relay", "runtime", "transaction.lock")); }
|
|
89
90
|
catch { return { ok: false, reason: "deferred-update-owner" }; }
|
|
90
91
|
try {
|
|
@@ -92,6 +93,7 @@ async function restartMacRegisteredServices(target, {
|
|
|
92
93
|
if (!current?.active || current.packageRoot !== target.packageRoot) return { ok: false, reason: "runtime-changed" };
|
|
93
94
|
const records = LABELS.map(label => readRegistration(label, { homeDir, run }));
|
|
94
95
|
if (records.some(r => r.packageRoot !== target.packageRoot)) return { ok: false, reason: "runtime-changed" };
|
|
96
|
+
releaseDrain = await require("./update-activity.cjs").drainCalls({ homeDir, sleep });
|
|
95
97
|
for (const record of records) {
|
|
96
98
|
const observed = registration(record.label, { run, userId });
|
|
97
99
|
if (!observed.known) return { ok: false, reason: "service-registration-query-failed" };
|
|
@@ -105,7 +107,7 @@ async function restartMacRegisteredServices(target, {
|
|
|
105
107
|
}
|
|
106
108
|
return { ok: false, reason: "restarted-runtime-not-healthy" };
|
|
107
109
|
} catch (error) { return { ok: false, reason: error.message }; }
|
|
108
|
-
finally { lock.release(); }
|
|
110
|
+
finally { releaseDrain?.(); lock.release(); }
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
module.exports = { LABELS, registration, readRegistration, repairMacServiceRegistrations, restartMacRegisteredServices };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Shared by setup, application workers and the independent recovery bundle.
|
|
3
|
+
// An interpreter is an executable contract, never the identity of its caller.
|
|
4
|
+
const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
|
|
7
|
+
function isElectronExecutable(executable, { realpath = fs.realpathSync } = {}) {
|
|
8
|
+
const electron = value => /(?:^|[\\/])(?:electron|relay)(?:\.exe|\.app(?:[\\/]|$)|$)|\.app[\\/]Contents[\\/]MacOS[\\/]/i.test(String(value || ""));
|
|
9
|
+
if (electron(executable) || (executable === process.execPath && process.versions.electron)) return true;
|
|
10
|
+
try { return electron(realpath(executable)); } catch { return false; }
|
|
11
|
+
}
|
|
12
|
+
function nodeEnvironment(env = process.env) {
|
|
13
|
+
const clean = { ...env };
|
|
14
|
+
for (const key of Object.keys(clean)) {
|
|
15
|
+
if (/^(NODE_OPTIONS|NODE_PATH|ELECTRON_RUN_AS_NODE)$/i.test(key)) delete clean[key];
|
|
16
|
+
}
|
|
17
|
+
return clean;
|
|
18
|
+
}
|
|
19
|
+
function verifyNode(executable, { run = spawnSync, env = process.env, realpath = fs.realpathSync } = {}) {
|
|
20
|
+
if (!executable || isElectronExecutable(executable, { realpath })) return { ok: false, reason: "node-is-electron" };
|
|
21
|
+
try {
|
|
22
|
+
const result = run(executable, ["-p", "process.versions.electron ? '' : process.versions.node"],
|
|
23
|
+
{ encoding: "utf8", windowsHide: true, timeout: 5000, env: nodeEnvironment(env) });
|
|
24
|
+
const version = String(result?.stdout ?? result?.out ?? "").trim();
|
|
25
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
26
|
+
const ok = !result?.error && (result?.status === 0 || result?.ok === true)
|
|
27
|
+
&& match && (Number(match[1]) > 22 || (Number(match[1]) === 22 && Number(match[2]) >= 12));
|
|
28
|
+
return { ok: Boolean(ok), version, reason: ok ? null : "node-contract-failed" };
|
|
29
|
+
} catch (error) { return { ok: false, reason: "node-contract-failed", detail: error.message }; }
|
|
30
|
+
}
|
|
31
|
+
function resolveManagedNode({ homeDir = os.homedir(), node = process.execPath, run = spawnSync,
|
|
32
|
+
env = process.env, read = file => JSON.parse(fs.readFileSync(file, "utf8")) } = {}) {
|
|
33
|
+
const candidates = [];
|
|
34
|
+
for (const file of ["recovery/current.json", "recovery/known-good.json", "recovery/launcher-node.json", "runtime/current.json"]) {
|
|
35
|
+
try { const value = read(path.join(homeDir, ".relay", file)); if (value?.node) candidates.push(value.node); } catch {}
|
|
36
|
+
}
|
|
37
|
+
candidates.push(node);
|
|
38
|
+
for (const candidate of new Set(candidates)) if (verifyNode(candidate, { run, env }).ok) return candidate;
|
|
39
|
+
throw Error("No verified Relay Node runtime is available; open the Relay installer to restore it.");
|
|
40
|
+
}
|
|
41
|
+
module.exports = { isElectronExecutable, nodeEnvironment, verifyNode, resolveManagedNode };
|
|
@@ -5,6 +5,7 @@ const fs = require("node:fs");
|
|
|
5
5
|
const os = require("node:os");
|
|
6
6
|
const path = require("node:path");
|
|
7
7
|
const { spawnSync } = require("node:child_process");
|
|
8
|
+
const { isElectronExecutable, nodeEnvironment } = require("./node-contract.cjs");
|
|
8
9
|
|
|
9
10
|
function nodeVersionSupported(version) {
|
|
10
11
|
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version || "").trim());
|
|
@@ -19,11 +20,13 @@ function commandOutput(result) {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
function verifiedNodeVersion(executable, { runCommand = spawnSync, timeout = 5_000 } = {}) {
|
|
23
|
+
if (isElectronExecutable(executable)) return { ok: false, version: "", detail: "Electron is not a service Node runtime" };
|
|
22
24
|
try {
|
|
23
|
-
const result = runCommand(executable, ["-p", "process.versions.node"], {
|
|
25
|
+
const result = runCommand(executable, ["-p", "process.versions.electron ? '' : process.versions.node"], {
|
|
24
26
|
encoding: "utf8",
|
|
25
27
|
timeout,
|
|
26
28
|
windowsHide: true,
|
|
29
|
+
env: nodeEnvironment(),
|
|
27
30
|
});
|
|
28
31
|
const ok = result?.ok === true || (!result?.error && result?.status === 0);
|
|
29
32
|
const version = commandOutput(result);
|
|
@@ -71,6 +74,7 @@ function relayOwnedNodePath(executable, {
|
|
|
71
74
|
isTemporary = isTemporaryNodePath,
|
|
72
75
|
randomBytes = crypto.randomBytes,
|
|
73
76
|
} = {}) {
|
|
77
|
+
if (isElectronExecutable(executable, { realpath: realpathSync })) throw new Error("Electron is not a service Node runtime");
|
|
74
78
|
if (!executable || !isTemporary(executable, { platform, realpathSync })) return executable;
|
|
75
79
|
|
|
76
80
|
let source;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Observation is not permission to edit service registrations. Wake the
|
|
3
|
+
// independently installed controller and let it judge the current generation.
|
|
4
|
+
const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
const { atomicFile } = require("./mac-registration-transaction.cjs");
|
|
7
|
+
const { resolveManagedNode, nodeEnvironment } = require("./node-contract.cjs");
|
|
8
|
+
const { lifecycleOwnership } = require("./lifecycle-ownership.cjs");
|
|
9
|
+
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
|
|
10
|
+
const ok = r => !r?.error && (r?.status === 0 || r?.ok === true);
|
|
11
|
+
|
|
12
|
+
function requestRecovery({ homeDir = os.homedir(), platform = process.platform, reason = "health-check",
|
|
13
|
+
packageRoot, node, env = process.env, now = Date.now,
|
|
14
|
+
run = (file, args, options) => spawnSync(file, args, { encoding: "utf8", windowsHide: true, timeout: 15_000, ...options }),
|
|
15
|
+
install = options => require("./recovery-install.cjs").installRecovery(options),
|
|
16
|
+
own = lifecycleOwnership, userId = process.getuid?.() ?? 0 } = {}) {
|
|
17
|
+
const root = path.join(homeDir, ".relay", "recovery");
|
|
18
|
+
if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: false, reason: "intentionally-stopped" };
|
|
19
|
+
const label = "work.relay.companion.recovery", task = "Relay Companion Recovery";
|
|
20
|
+
const query = platform === "win32" ? ["schtasks.exe", ["/Query", "/TN", task]]
|
|
21
|
+
: platform === "darwin" ? ["/bin/launchctl", ["print", `gui/${userId}/${label}`]]
|
|
22
|
+
: ["systemctl", ["--user", "show", "--property=LoadState", "--value", `${label}.service`]];
|
|
23
|
+
const observed = run(...query);
|
|
24
|
+
const text = String(observed?.stdout ?? observed?.out ?? "");
|
|
25
|
+
const present = ok(observed) && (platform !== "linux" || text.trim() === "loaded");
|
|
26
|
+
if (!present) {
|
|
27
|
+
// A failed query is not proof of absence. In particular, do not rewrite a
|
|
28
|
+
// launch agent because its GUI domain is temporarily unavailable at login.
|
|
29
|
+
const detail = String(observed?.stderr ?? observed?.out ?? "");
|
|
30
|
+
const missing = !observed?.error && (platform === "darwin"
|
|
31
|
+
? observed.status === 113 || /Could not find service/i.test(detail)
|
|
32
|
+
: platform === "linux" ? text.trim() === "not-found"
|
|
33
|
+
: /cannot find|does not exist|not found/i.test(detail));
|
|
34
|
+
if (!missing || !packageRoot) return { ok: false, reason: "recovery-registration-unavailable" };
|
|
35
|
+
let lease;
|
|
36
|
+
try {
|
|
37
|
+
lease = own({ homeDir, env });
|
|
38
|
+
const interpreter = resolveManagedNode({ homeDir, node, run, env });
|
|
39
|
+
lease.assert();
|
|
40
|
+
const result = install({ packageRoot, node: interpreter, homeDir, platform, reload: true, runCommand: run });
|
|
41
|
+
if (!result.ok) return { ok: false, reason: result.reason, detail: result.detail };
|
|
42
|
+
} catch (error) { return { ok: false, reason: "recovery-bootstrap-deferred", detail: error.message }; }
|
|
43
|
+
finally { lease?.release(); }
|
|
44
|
+
}
|
|
45
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
46
|
+
atomicFile(path.join(root, "request.json"), JSON.stringify({ schema: 1, reason, at: now() }));
|
|
47
|
+
const command = platform === "win32" ? ["schtasks.exe", ["/Run", "/TN", task]]
|
|
48
|
+
: platform === "darwin" ? ["/bin/launchctl", ["kickstart", `gui/${userId}/${label}`]]
|
|
49
|
+
: ["systemctl", ["--user", "start", "--no-block", `${label}.service`]];
|
|
50
|
+
const submitted = run(...command, { env: nodeEnvironment(env) });
|
|
51
|
+
return { ok: ok(submitted), reason: ok(submitted) ? "recovery-requested" : "recovery-start-failed",
|
|
52
|
+
detail: ok(submitted) ? "" : String(submitted?.error?.message || submitted?.stderr || submitted?.out || "") };
|
|
53
|
+
}
|
|
54
|
+
// launchctl, Task Scheduler and interpreter verification must never block the
|
|
55
|
+
// Electron event loop whose responsiveness we are trying to preserve.
|
|
56
|
+
function requestRecoveryAsync(options = {}) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const worker = new Worker(__filename, { workerData: { relayRecoveryRequest: options } });
|
|
59
|
+
let settled = false;
|
|
60
|
+
const finish = result => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } };
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
void worker.terminate();
|
|
63
|
+
finish({ ok: false, reason: "recovery-request-deadline" });
|
|
64
|
+
}, 120_000);
|
|
65
|
+
worker.once("message", finish);
|
|
66
|
+
worker.once("error", error => finish({ ok: false, reason: "recovery-request-failed", detail: error.message }));
|
|
67
|
+
worker.once("exit", () => finish({ ok: false, reason: "recovery-request-exited" }));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (!isMainThread && workerData?.relayRecoveryRequest) {
|
|
71
|
+
try { parentPort.postMessage(requestRecovery(workerData.relayRecoveryRequest)); }
|
|
72
|
+
catch (error) { parentPort.postMessage({ ok: false, reason: "recovery-request-failed", detail: error.message }); }
|
|
73
|
+
}
|
|
74
|
+
module.exports = { requestRecovery, requestRecoveryAsync };
|
|
@@ -52,7 +52,7 @@ function windowsRecoveryTaskXml(script, startAt = new Date()) {
|
|
|
52
52
|
<Triggers>
|
|
53
53
|
<TimeTrigger>
|
|
54
54
|
<StartBoundary>${boundary}</StartBoundary>
|
|
55
|
-
<Repetition><Interval>
|
|
55
|
+
<Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition>
|
|
56
56
|
<Enabled>true</Enabled>
|
|
57
57
|
</TimeTrigger>
|
|
58
58
|
</Triggers>
|
|
@@ -183,7 +183,8 @@ function installRecovery({ packageRoot, node = process.execPath, homeDir = os.ho
|
|
|
183
183
|
} else if (platform === "darwin") {
|
|
184
184
|
const plist = path.join(homeDir, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
185
185
|
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
186
|
-
|
|
186
|
+
const priorSchedule = fs.existsSync(plist) ? fs.readFileSync(plist) : null;
|
|
187
|
+
atomicFile(plist, `<?xml version="1.0"?><plist version="1.0"><dict><key>Label</key><string>${LABEL}</string><key>ProgramArguments</key><array><string>${xml(launcherNode)}</string><string>${xml(launcher)}</string></array><key>EnvironmentVariables</key><dict><key>HOME</key><string>${xml(homeDir)}</string></dict><key>StartInterval</key><integer>60</integer><key>RunAtLoad</key><true/><key>ProcessType</key><string>Background</string><key>StandardOutPath</key><string>${xml(log)}</string><key>StandardErrorPath</key><string>${xml(log)}</string></dict></plist>`);
|
|
187
188
|
// The job dispatches through the stable host. Do not unload it on update.
|
|
188
189
|
const observed = runCommand("launchctl", ["print", `gui/${userId}/${LABEL}`]);
|
|
189
190
|
if (!ok(observed)) {
|
|
@@ -191,12 +192,23 @@ function installRecovery({ packageRoot, node = process.execPath, homeDir = os.ho
|
|
|
191
192
|
if (!missing) throw Error("recovery-registration-query-failed");
|
|
192
193
|
results.push(runCommand("launchctl", ["bootstrap", `gui/${userId}`, plist]));
|
|
193
194
|
results.push(runCommand("launchctl", ["print", `gui/${userId}/${LABEL}`]));
|
|
195
|
+
write(path.join(root, "scheduler.json"), { schema: 1, intervalSeconds: 60, at: Date.now() });
|
|
196
|
+
}
|
|
197
|
+
if (ok(observed) && read(path.join(root, "scheduler.json"))?.intervalSeconds !== 60) {
|
|
198
|
+
const job = "work.relay.recovery.schedule-handover";
|
|
199
|
+
const pending = runCommand("launchctl", ["list", job]);
|
|
200
|
+
if (!(ok(pending) && /"PID"\s*=\s*[1-9]\d*/.test(String(pending.stdout || pending.out || "")))) {
|
|
201
|
+
if (ok(pending)) runCommand("launchctl", ["remove", job]);
|
|
202
|
+
if (priorSchedule) atomicFile(path.join(root, "scheduler-previous.plist"), priorSchedule);
|
|
203
|
+
results.push(runCommand("launchctl", ["submit", "-l", job, "-o", log, "-e", log, "--", runtimeNode,
|
|
204
|
+
path.join(bundle, "bootstrap", "recovery-schedule-handover.cjs"), homeDir, String(userId)]));
|
|
205
|
+
}
|
|
194
206
|
}
|
|
195
207
|
} else {
|
|
196
208
|
const dir = path.join(homeDir, ".config", "systemd", "user");
|
|
197
209
|
fs.mkdirSync(dir, { recursive: true });
|
|
198
210
|
atomicFile(path.join(dir, `${LABEL}.service`), `[Unit]\nDescription=Relay update recovery\n[Service]\nType=oneshot\nExecStart=${unit(launcherNode)} ${unit(launcher)}\nTimeoutStartSec=1800\nKillMode=control-group\n`);
|
|
199
|
-
atomicFile(path.join(dir, `${LABEL}.timer`), `[Unit]\nDescription=Check Relay update health\n[Timer]\nOnBootSec=
|
|
211
|
+
atomicFile(path.join(dir, `${LABEL}.timer`), `[Unit]\nDescription=Check Relay update health\n[Timer]\nOnBootSec=15s\nOnUnitInactiveSec=30s\nPersistent=true\n[Install]\nWantedBy=timers.target\n`);
|
|
200
212
|
// The user manager can retain a different HOME from this installer.
|
|
201
213
|
// Registration is required even when thin setup defers starting services.
|
|
202
214
|
for (const ext of ["service", "timer"]) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
|
|
3
|
+
const file = homeDir => path.join(homeDir, ".relay", "recovery", "intent.json");
|
|
4
|
+
function stopped(homeDir = os.homedir()) {
|
|
5
|
+
try { return JSON.parse(fs.readFileSync(file(homeDir), "utf8")).stopped === true; }
|
|
6
|
+
catch (error) { return error.code !== "ENOENT"; }
|
|
7
|
+
}
|
|
8
|
+
function setStopped(value, homeDir = os.homedir()) {
|
|
9
|
+
require("./recovery-launcher.cjs").write(file(homeDir), { schema: 1, stopped: value, at: Date.now() });
|
|
10
|
+
}
|
|
11
|
+
module.exports = { stopped, setStopped };
|
|
@@ -6,8 +6,26 @@ function read(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
|
6
6
|
function write(file, data) {
|
|
7
7
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
8
8
|
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
let fd;
|
|
10
|
+
try {
|
|
11
|
+
fd = fs.openSync(temp, "wx", 0o600);
|
|
12
|
+
fs.writeFileSync(fd, JSON.stringify(data) + "\n");
|
|
13
|
+
fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
|
|
14
|
+
const start = Date.now();
|
|
15
|
+
for (;;) {
|
|
16
|
+
try { fs.renameSync(temp, file); break; }
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (!["EPERM", "EBUSY", "EACCES"].includes(error.code) || Date.now() - start > 3000) throw error;
|
|
19
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (process.platform !== "win32") {
|
|
23
|
+
fd = fs.openSync(path.dirname(file), "r"); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
|
|
24
|
+
}
|
|
25
|
+
} finally {
|
|
26
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
27
|
+
try { fs.unlinkSync(temp); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
28
|
+
}
|
|
11
29
|
}
|
|
12
30
|
function validPointer(p, root) {
|
|
13
31
|
return p?.schema === 1 && /^\d+\.\d+\.\d+$/.test(p.version || "")
|
|
@@ -198,6 +216,27 @@ function acquireCanonicalReclaimClaim(reclaimPath, {
|
|
|
198
216
|
return false;
|
|
199
217
|
}
|
|
200
218
|
|
|
219
|
+
// Delegated mutation workers retain the generation after their parent dies.
|
|
220
|
+
// Unknown records fail closed. Reclaimers check again after winning the claim;
|
|
221
|
+
// workers publish before checking that no reclaim is underway.
|
|
222
|
+
function liveLockParticipants(lockPath, nonce, {
|
|
223
|
+
readdirSync = fs.readdirSync, readFileSync = fs.readFileSync,
|
|
224
|
+
isProcessAlive = processAlive, processIdentity = nativeProcessIdentity,
|
|
225
|
+
} = {}) {
|
|
226
|
+
let names;
|
|
227
|
+
try { names = readdirSync(lockPath); } catch (error) { return error.code !== "ENOENT"; }
|
|
228
|
+
for (const name of names) {
|
|
229
|
+
const match = /^participant-([1-9][0-9]*)-[0-9a-f]+\.json$/.exec(name);
|
|
230
|
+
if (!match) continue;
|
|
231
|
+
let member;
|
|
232
|
+
try { member = JSON.parse(readFileSync(path.join(lockPath, name), "utf8")); }
|
|
233
|
+
catch (error) { if (error.code === "ENOENT" || !isProcessAlive(Number(match[1]))) continue; return true; }
|
|
234
|
+
if (member.nonce !== nonce) continue;
|
|
235
|
+
if (canonicalLockOwnerState(member, { isProcessAlive, processIdentity }) !== "dead") return true;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
201
240
|
function acquireCanonicalLock(lockPath, {
|
|
202
241
|
now = Date.now,
|
|
203
242
|
// Preserve the shipped bootstrap's two-hour grace for incomplete records.
|
|
@@ -285,7 +324,7 @@ function acquireCanonicalLock(lockPath, {
|
|
|
285
324
|
release() {
|
|
286
325
|
let owner = null;
|
|
287
326
|
try { owner = JSON.parse(readFileSync(ownerPath, "utf8")); } catch {}
|
|
288
|
-
if (owner?.nonce === nonce) rmSync(lockPath, { recursive: true, force: true });
|
|
327
|
+
if (owner?.nonce === nonce && !liveLockParticipants(lockPath, nonce)) rmSync(lockPath, { recursive: true, force: true });
|
|
289
328
|
},
|
|
290
329
|
};
|
|
291
330
|
} catch (error) {
|
|
@@ -314,7 +353,7 @@ function acquireCanonicalLock(lockPath, {
|
|
|
314
353
|
// have observed the directory between mkdir and owner.json being written.
|
|
315
354
|
const stale = ownerState === "dead"
|
|
316
355
|
|| (ownerState === "unknown" && observedAt > 0 && now() - observedAt > staleAfterMs);
|
|
317
|
-
if (!stale || attempt > 0) lockFail("Another verified Relay install or update is already in progress.");
|
|
356
|
+
if (!stale || attempt > 0 || liveLockParticipants(lockPath, owner?.nonce, { readdirSync, readFileSync, isProcessAlive, processIdentity })) lockFail("Another verified Relay install or update is already in progress.");
|
|
318
357
|
|
|
319
358
|
// Serialize reclaimers inside this exact lock generation, then re-read the
|
|
320
359
|
// owner after winning. This closes the race where one retry replaced the
|
|
@@ -356,7 +395,7 @@ function acquireCanonicalLock(lockPath, {
|
|
|
356
395
|
const confirmedState = canonicalLockOwnerState(confirmedOwner, { isProcessAlive, processIdentity });
|
|
357
396
|
const confirmedStale = confirmedState === "dead"
|
|
358
397
|
|| (confirmedState === "unknown" && confirmedAt > 0 && now() - confirmedAt > staleAfterMs);
|
|
359
|
-
if (!confirmedStale) lockFail("Another verified Relay install or update is already in progress.");
|
|
398
|
+
if (!confirmedStale || liveLockParticipants(lockPath, confirmedOwner?.nonce, { readdirSync, readFileSync, isProcessAlive, processIdentity })) lockFail("Another verified Relay install or update is already in progress.");
|
|
360
399
|
let confirmedClaim = null;
|
|
361
400
|
try { confirmedClaim = JSON.parse(readFileSync(reclaimPath, "utf8")); } catch {}
|
|
362
401
|
if (confirmedClaim?.nonce !== nonce) lockFail("Another verified Relay install or update is already in progress.");
|
|
@@ -525,5 +564,5 @@ async function launch({ root = __dirname, run = runChild, now = Date.now, env =
|
|
|
525
564
|
return { ok: false, status: "failed" };
|
|
526
565
|
} finally { release(); }
|
|
527
566
|
}
|
|
528
|
-
module.exports = { launch, validPointer, read, write, acquireLauncherLock, appendRecoveryLog, acquireCanonicalLock, processAlive, nativeProcessIdentity, nativeIdentityBirth };
|
|
567
|
+
module.exports = { launch, validPointer, read, write, acquireLauncherLock, appendRecoveryLog, acquireCanonicalLock, processAlive, nativeProcessIdentity, nativeIdentityBirth, liveLockParticipants };
|
|
529
568
|
if (require.main === module) launch().then(result => { process.exitCode = result.ok ? 0 : 1; }).catch(e => { console.error(e.message); process.exitCode = 1; });
|
|
@@ -11,7 +11,7 @@ const { stageVerifiedRuntime, releasePlatform } = require("./relay-setup.cjs");
|
|
|
11
11
|
const { verifyReleaseEnvelope } = require("./release-signature.cjs");
|
|
12
12
|
const { inFlightTransaction, workerLostLock } = require("./recovery-transaction.cjs");
|
|
13
13
|
const trust = require("./trust.json");
|
|
14
|
-
const CHECK_MS =
|
|
14
|
+
const CHECK_MS = 60_000;
|
|
15
15
|
const DEADLINE_MS = 25 * 60_000;
|
|
16
16
|
const HEARTBEAT_MS = 60_000;
|
|
17
17
|
const BUSY_GRACE_MS = 15 * 60_000;
|
|
@@ -22,10 +22,7 @@ const STARTUP_GRACE_MS = 3 * 60_000;
|
|
|
22
22
|
|
|
23
23
|
function read(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
|
|
24
24
|
function write(file, value) {
|
|
25
|
-
|
|
26
|
-
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
27
|
-
fs.writeFileSync(tmp, JSON.stringify(value) + "\n", { mode: 0o600 });
|
|
28
|
-
fs.renameSync(tmp, file);
|
|
25
|
+
require("./recovery-launcher.cjs").write(file, value);
|
|
29
26
|
}
|
|
30
27
|
function compare(a, b) {
|
|
31
28
|
if (!/^\d+\.\d+\.\d+$/.test(a || "") || !/^\d+\.\d+\.\d+$/.test(b || "")) return null;
|
|
@@ -118,10 +115,9 @@ function execute(node, entry, args, { timeoutMs = DEADLINE_MS, spawnImpl = spawn
|
|
|
118
115
|
// daemon, or a machine so starved that a healthy daemon missed a few ticks.
|
|
119
116
|
// Only a heartbeat that stays stale across two scheduled checks proves the
|
|
120
117
|
// former while the process is still alive. A missing process needs no second look.
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
const STALE_CONFIRM_MS = 10 * 60_000;
|
|
118
|
+
// Confirm across consecutive awake observations. A gap caused by sleep or a
|
|
119
|
+
// stalled scheduler resets this window rather than counting as a live hang.
|
|
120
|
+
const STALE_CONFIRM_MS = 60_000;
|
|
125
121
|
const MAX_IN_PLACE_RESTARTS = 2;
|
|
126
122
|
|
|
127
123
|
// A runner killed mid-extraction by the launcher's deadline leaves its staged
|
|
@@ -163,8 +159,13 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
|
|
|
163
159
|
policyFactory = require("./recovery-policy.cjs").recoveryPolicy,
|
|
164
160
|
validateLocal = require("./recovery-local.cjs").validateLocalRuntime,
|
|
165
161
|
transactions = inFlightTransaction,
|
|
166
|
-
repairServices = require("./
|
|
162
|
+
repairServices = require("./service-recovery.cjs").repairServiceRegistrations } = {}) {
|
|
167
163
|
const root = path.join(homeDir, ".relay");
|
|
164
|
+
if (require("./recovery-intent.cjs").stopped(homeDir)) {
|
|
165
|
+
const result = { ok: true, status: "intentionally-stopped", runtimeHealthy: false };
|
|
166
|
+
write(path.join(root, "recovery", "status.json"), { schema: 1, checkedAt: now(), runId: env.RELAY_RECOVERY_RUN_ID || null, launcherVersion: require("../package.json").version, ...result });
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
168
169
|
// Local availability is independent of update eligibility, network, memory,
|
|
169
170
|
// and the journal's active flag. This helper shares the activation lock.
|
|
170
171
|
let progress, reservedService;
|
|
@@ -177,9 +178,10 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
|
|
|
177
178
|
return { ...repaired, ok: false, status: "repair-progress-unavailable", runtimeHealthy: false, lastError: error.message };
|
|
178
179
|
}
|
|
179
180
|
let services;
|
|
180
|
-
try { services = await repairServices({ homeDir, platform }); }
|
|
181
|
+
try { services = reservedService ? await repairServices({ homeDir, platform }) : { ok: true, changed: false, status: "service-budget-exhausted" }; }
|
|
181
182
|
catch (error) { services = { ok: false, status: "service-repair-failed", lastError: error.message }; }
|
|
182
|
-
|
|
183
|
+
const serviceQueryUncertain = /^service-registration-query-failed/.test(services.lastError || "");
|
|
184
|
+
if (reservedService && (serviceQueryUncertain || services.status === "deferred-update-owner" || (services.ok && !services.changed))) progress.refund("services");
|
|
183
185
|
const ready = (target = null, after = 0) => verifyReady({ homeDir, platform, target, after, now, sleep, health });
|
|
184
186
|
const rememberReady = observed => {
|
|
185
187
|
const active = observed?.current || read(path.join(root, "runtime", "current.json"));
|
|
@@ -225,6 +227,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
|
|
|
225
227
|
transaction: { pid: transaction.pid, requestId: transaction.requestId, version: transaction.version } });
|
|
226
228
|
};
|
|
227
229
|
if (services.status === "deferred-update-owner") return status({ ...services, runtimeHealthy: false });
|
|
230
|
+
if (serviceQueryUncertain) return status({ ...services, runtimeHealthy: false });
|
|
228
231
|
if (services.changed || !services.ok) {
|
|
229
232
|
const observed = await ready();
|
|
230
233
|
if (observed.ok) return proven({ ok: true, status: "current", version: observed.current?.version, repair: "services", lastSuccessAt: now() }, observed);
|
|
@@ -235,7 +238,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
|
|
|
235
238
|
// Accepted commands are not recovery. Repeated failure advances even when
|
|
236
239
|
// an inactive journal or unusable registration snapshot is still present.
|
|
237
240
|
}
|
|
238
|
-
|
|
241
|
+
const updatesEnabled = !/^(0|false|off|no)$/i.test(String(env.RELAY_AUTO_UPDATE || "")) && read(path.join(root, "recovery", "policy.json"))?.autoUpdate !== false;
|
|
239
242
|
const downloads = path.join(root, "recovery", "downloads");
|
|
240
243
|
let staged = null;
|
|
241
244
|
let discoveryError = null;
|
|
@@ -243,18 +246,23 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
|
|
|
243
246
|
let current = null;
|
|
244
247
|
let runtimeResponsive = false;
|
|
245
248
|
let runtimeVerified = false;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// not a network connection.
|
|
249
|
+
let discoveryAttempted = false;
|
|
250
|
+
const discoverDesired = async () => {
|
|
251
|
+
if (!updatesEnabled || discoveryAttempted) return;
|
|
252
|
+
discoveryAttempted = true;
|
|
251
253
|
try { desiredVersion = await discoverImpl(channel); log(`discovered ${desiredVersion} on ${channel}`); }
|
|
252
254
|
catch (error) { discoveryError = error; log(`discovery failed: ${error.message}`); }
|
|
255
|
+
};
|
|
256
|
+
try {
|
|
257
|
+
sweepAbandonedDownloads(downloads, log);
|
|
253
258
|
current = read(path.join(root, "runtime", "current.json"));
|
|
254
259
|
const heartbeat = read(path.join(root, "recovery", "daemon.json"));
|
|
255
260
|
const heartbeatFresh = heartbeat?.at <= now() && now() - heartbeat.at < HEARTBEAT_MS;
|
|
256
261
|
const installedIsDesired = current?.active === true;
|
|
257
262
|
const live = installedIsDesired ? await health(current, { platform }) : null;
|
|
263
|
+
// Local repairs never wait behind registry timeouts. Healthy installations
|
|
264
|
+
// still check for new releases; unavailable ones exhaust retained repairs first.
|
|
265
|
+
if (!installedIsDesired || (live?.ok && heartbeatFresh)) await discoverDesired();
|
|
258
266
|
if (live && platform === "darwin" && !services.ok) live.ok = false;
|
|
259
267
|
let readiness = null;
|
|
260
268
|
if (installedIsDesired && heartbeatFresh && heartbeat.version === current.version && live.ok) {
|
|
@@ -277,7 +285,9 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
|
|
|
277
285
|
repairState.version = current.version;
|
|
278
286
|
const sameTarget = previous?.version === current.version;
|
|
279
287
|
repairState.restarts = progress.count(`restart:${current.packageRoot || current.version}`);
|
|
280
|
-
|
|
288
|
+
const cadence = platform === "darwin" && read(path.join(root, "recovery", "scheduler.json"))?.intervalSeconds !== 60 ? 5 * 60_000 : CHECK_MS;
|
|
289
|
+
const consecutive = previous?.checkedAt <= now() && now() - previous.checkedAt <= cadence * 2;
|
|
290
|
+
repairState.staleSince = sameTarget && consecutive && Number.isFinite(previous.staleSince) && previous.staleSince <= now() ? previous.staleSince : now();
|
|
281
291
|
}
|
|
282
292
|
if (installedIsDesired && !(live.ok && heartbeatFresh && heartbeat.version === current.version)) {
|
|
283
293
|
// The code on disk is the code we want; the problem is liveness. Repair in
|
|
@@ -377,6 +387,8 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
|
|
|
377
387
|
progress.fail(error.message); log('local recovery failed: ' + error.message);
|
|
378
388
|
}
|
|
379
389
|
}
|
|
390
|
+
if (!updatesEnabled) return status({ ok: true, status: "disabled", runtimeHealthy: runtimeVerified, runtimeAvailable: runtimeResponsive });
|
|
391
|
+
await discoverDesired();
|
|
380
392
|
if (discoveryError) throw discoveryError;
|
|
381
393
|
let quarantine = policy.decision(channel, desiredVersion);
|
|
382
394
|
if (quarantine.blocked && !runtimeResponsive) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// A launchd job cannot reload itself without killing its installer. This small
|
|
3
|
+
// independent worker waits for the old controller, owns its exclusion locks,
|
|
4
|
+
// and restores the previous plist if the new schedule cannot be loaded.
|
|
5
|
+
const fs = require("node:fs"), path = require("node:path");
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
const io = require("./recovery-launcher.cjs");
|
|
8
|
+
async function handover({ homeDir, userId, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
9
|
+
run = (file, args) => spawnSync(file, args, { encoding: "utf8", timeout: 15_000, windowsHide: true }), attempts = 360 } = {}) {
|
|
10
|
+
const root = path.join(homeDir, ".relay", "recovery");
|
|
11
|
+
const label = "work.relay.companion.recovery", domain = `gui/${userId}`;
|
|
12
|
+
const plist = path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`);
|
|
13
|
+
const previous = path.join(root, "scheduler-previous.plist");
|
|
14
|
+
const ok = r => !r?.error && r?.status === 0;
|
|
15
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
16
|
+
if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: true, reason: "intentionally-stopped" };
|
|
17
|
+
let release, runner, transaction;
|
|
18
|
+
try {
|
|
19
|
+
release = io.acquireLauncherLock(root);
|
|
20
|
+
if (!release) continue;
|
|
21
|
+
runner = io.acquireCanonicalLock(path.join(root, "run.lock"));
|
|
22
|
+
transaction = io.acquireCanonicalLock(path.join(homeDir, ".relay", "runtime", "transaction.lock"));
|
|
23
|
+
if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: true, reason: "intentionally-stopped" };
|
|
24
|
+
// The old job is no longer running. Keep the old file until bootstrap is
|
|
25
|
+
// proven; an interrupted handover remains repairable by the app/installer.
|
|
26
|
+
const removed = run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
|
|
27
|
+
if (!ok(removed)) throw Error("recovery-schedule-bootout-failed");
|
|
28
|
+
const started = run("/bin/launchctl", ["bootstrap", domain, plist]);
|
|
29
|
+
if (!ok(started)) {
|
|
30
|
+
if (fs.existsSync(previous)) {
|
|
31
|
+
require("./mac-registration-transaction.cjs").atomicFile(plist, fs.readFileSync(previous));
|
|
32
|
+
run("/bin/launchctl", ["bootstrap", domain, plist]);
|
|
33
|
+
}
|
|
34
|
+
throw Error("recovery-schedule-bootstrap-failed");
|
|
35
|
+
}
|
|
36
|
+
io.write(path.join(root, "scheduler.json"), { schema: 1, intervalSeconds: 60, at: Date.now() });
|
|
37
|
+
return { ok: true };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (transaction) { io.write(path.join(root, "scheduler-error.json"), { at: Date.now(), error: error.message }); return { ok: false, reason: error.message }; }
|
|
40
|
+
} finally {
|
|
41
|
+
transaction?.release(); runner?.release(); release?.();
|
|
42
|
+
// Sleep also on contention, without retaining any mutation authority.
|
|
43
|
+
if (!transaction) await sleep(5000);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { ok: false, reason: "recovery-schedule-handover-deadline" };
|
|
47
|
+
}
|
|
48
|
+
module.exports = { handover };
|
|
49
|
+
if (require.main === module) handover({ homeDir: process.argv[2], userId: Number(process.argv[3]) }).then(result => {
|
|
50
|
+
console.log(JSON.stringify(result));
|
|
51
|
+
// launchctl submit restarts nonzero exits; a failed migration must be bounded.
|
|
52
|
+
}).catch(error => { console.error(error.message); });
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// is a canonical runtime transaction currently owned by a live process?
|
|
12
12
|
const fs = require("node:fs");
|
|
13
13
|
const path = require("node:path");
|
|
14
|
-
const { processAlive, nativeProcessIdentity } = require("./recovery-launcher.cjs");
|
|
14
|
+
const { processAlive, nativeProcessIdentity, liveLockParticipants } = require("./recovery-launcher.cjs");
|
|
15
15
|
|
|
16
16
|
// An admitted update request older than this is debris from a worker that died
|
|
17
17
|
// without writing its terminal state; the lock engine reclaims after owner death.
|
|
@@ -53,7 +53,7 @@ function inFlightTransaction({ homeDir, now = Date.now(), alive = processAlive,
|
|
|
53
53
|
break;
|
|
54
54
|
}
|
|
55
55
|
if (request) return request;
|
|
56
|
-
if (owner && liveOwner(owner, { alive, identity })) {
|
|
56
|
+
if (owner && (liveOwner(owner, { alive, identity }) || liveLockParticipants(path.join(root, "transaction.lock"), owner.nonce, { isProcessAlive: alive, processIdentity: identity }))) {
|
|
57
57
|
return { source: "lock", pid: Number(owner.pid), requestId: owner.requestId || null, version: null, admittedAt: Number(owner.createdAt) || null };
|
|
58
58
|
}
|
|
59
59
|
return null;
|
|
@@ -77,6 +77,10 @@ function stableNodePath(execPath = process.execPath, {
|
|
|
77
77
|
spawnImpl = spawnSync,
|
|
78
78
|
} = {}) {
|
|
79
79
|
if (!execPath) return execPath;
|
|
80
|
+
const contract = require("./node-contract.cjs");
|
|
81
|
+
if (contract.isElectronExecutable(execPath, { realpath: realpathSync })) {
|
|
82
|
+
return contract.resolveManagedNode({ node: null, run: spawnImpl, env });
|
|
83
|
+
}
|
|
80
84
|
let realExec = execPath;
|
|
81
85
|
try { realExec = realpathSync(execPath); } catch {}
|
|
82
86
|
const volatile = VERSION_MANAGED_NODE_RE.test(execPath)
|
|
@@ -407,11 +407,23 @@ async function activateWindowsRuntimeServices(target, {
|
|
|
407
407
|
}
|
|
408
408
|
|
|
409
409
|
/** One entry point for "restart what is already installed" on every platform. */
|
|
410
|
-
function restartInstalledRuntimeServices(target, { platform = process.platform, ...options } = {}) {
|
|
410
|
+
async function restartInstalledRuntimeServices(target, { platform = process.platform, ...options } = {}) {
|
|
411
|
+
if (!["darwin", "linux", "win32"].includes(platform)) return { ok: false, reason: "activation-platform-unsupported" };
|
|
411
412
|
if (platform === "darwin") return require("./mac-service-recovery.cjs").restartMacRegisteredServices(target, options);
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
413
|
+
let lease, releaseDrain;
|
|
414
|
+
const homeDir = options.homeDir || require("node:os").homedir();
|
|
415
|
+
try {
|
|
416
|
+
try { lease = require("./lifecycle-ownership.cjs").lifecycleOwnership({ homeDir }); }
|
|
417
|
+
catch { return { ok: false, reason: "deferred-update-owner" }; }
|
|
418
|
+
const current = JSON.parse(fs.readFileSync(path.join(homeDir, ".relay", "runtime", "current.json"), "utf8"));
|
|
419
|
+
if (!current?.active || current.packageRoot !== target.packageRoot) return { ok: false, reason: "runtime-changed" };
|
|
420
|
+
releaseDrain = await require("./update-activity.cjs").drainCalls({ homeDir });
|
|
421
|
+
lease.assert();
|
|
422
|
+
if (platform === "linux") return await activateLinuxRuntimeServices(target, { platform, ...options });
|
|
423
|
+
if (platform === "win32") return await activateWindowsRuntimeServices(target, { platform, ...options });
|
|
424
|
+
return { ok: false, reason: "activation-platform-unsupported" };
|
|
425
|
+
} catch (error) { return { ok: false, reason: error.message }; }
|
|
426
|
+
finally { releaseDrain?.(); lease?.release(); }
|
|
415
427
|
}
|
|
416
428
|
|
|
417
429
|
module.exports = {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const os = require("node:os"), path = require("node:path");
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const io = require("./recovery-launcher.cjs");
|
|
5
|
+
const nodeContract = require("./node-contract.cjs");
|
|
6
|
+
|
|
7
|
+
// Registration restoration is part of the controller's canonical transaction.
|
|
8
|
+
// The UI may bootstrap the controller, but never constructs daemon/pill jobs.
|
|
9
|
+
async function repairServiceRegistrations({ homeDir = os.homedir(), platform = process.platform,
|
|
10
|
+
run = (file, args) => spawnSync(file, args, { encoding: "utf8", windowsHide: true, timeout: 10_000 }),
|
|
11
|
+
validate = require("./recovery-local.cjs").validateLocalRuntime,
|
|
12
|
+
execute = (...args) => require("./recovery-runner.cjs").execute(...args),
|
|
13
|
+
own = require("./lifecycle-ownership.cjs").lifecycleOwnership,
|
|
14
|
+
drain = require("./update-activity.cjs").drainCalls,
|
|
15
|
+
activate,
|
|
16
|
+
} = {}) {
|
|
17
|
+
if (platform === "darwin") {
|
|
18
|
+
const observed = await require("./mac-service-recovery.cjs").repairMacServiceRegistrations({ homeDir, platform, run });
|
|
19
|
+
if (observed.ok || observed.blocked) return observed;
|
|
20
|
+
// Invalid/missing plist bytes cannot be bootstrapped. A verified retained
|
|
21
|
+
// runtime can rebuild them under the same ownership contract below.
|
|
22
|
+
} else if (["win32", "linux"].includes(platform)) {
|
|
23
|
+
let missing = false;
|
|
24
|
+
for (const role of ["Daemon", "Pill"]) {
|
|
25
|
+
const result = platform === "win32"
|
|
26
|
+
? run("schtasks.exe", ["/Query", "/TN", `Relay Companion ${role}`])
|
|
27
|
+
: run("systemctl", ["--user", "show", "--property=LoadState", "--value", `work.relay.companion${role === "Pill" ? ".pill" : ""}.service`]);
|
|
28
|
+
const text = String(result?.stdout || "").trim();
|
|
29
|
+
const present = !result?.error && result.status === 0 && (platform === "win32" || text === "loaded");
|
|
30
|
+
if (present) continue;
|
|
31
|
+
const absent = !result?.error && (platform === "linux" ? text === "not-found" : /cannot find|does not exist|not found/i.test(String(result?.stderr || result?.stdout || "")));
|
|
32
|
+
if (!absent) return { ok: false, changed: false, blocked: true, status: "service-repair-failed", lastError: "service-registration-query-failed" };
|
|
33
|
+
missing = true;
|
|
34
|
+
}
|
|
35
|
+
if (!missing) return { ok: true, changed: false, status: "services-present" };
|
|
36
|
+
} else return { ok: true, changed: false, status: "unsupported" };
|
|
37
|
+
|
|
38
|
+
let lease, releaseDrain;
|
|
39
|
+
try {
|
|
40
|
+
try { lease = own({ homeDir }); }
|
|
41
|
+
catch { return { ok: true, changed: false, status: "deferred-update-owner" }; }
|
|
42
|
+
const target = io.read(path.join(homeDir, ".relay", "runtime", "current.json"));
|
|
43
|
+
if (!target?.active || !validate(target, { platform })) return { ok: false, changed: false, status: "service-repair-failed", lastError: "no-verified-registration-source" };
|
|
44
|
+
const node = nodeContract.resolveManagedNode({ homeDir, node: target.node, run });
|
|
45
|
+
releaseDrain = await drain({ homeDir });
|
|
46
|
+
lease.assert();
|
|
47
|
+
await execute(node, path.join(target.packageRoot, "bin", "relay.js"), ["repair-runtime", "--no-restart"],
|
|
48
|
+
{ env: nodeContract.nodeEnvironment(lease.env), timeoutMs: 90_000 });
|
|
49
|
+
lease.assert();
|
|
50
|
+
const health = require("./runtime-health.cjs");
|
|
51
|
+
const activateRuntime = activate || (platform === "darwin" ? health.activateMacRuntimeServices
|
|
52
|
+
: platform === "win32" ? health.activateWindowsRuntimeServices : health.activateLinuxRuntimeServices);
|
|
53
|
+
const result = await activateRuntime(target, { homeDir, platform });
|
|
54
|
+
return { ok: result.ok, changed: true, status: result.ok ? "services-restored" : "service-repair-failed", lastError: result.reason };
|
|
55
|
+
} catch (error) { return { ok: false, changed: false, status: "service-repair-failed", lastError: error.message }; }
|
|
56
|
+
finally { releaseDrain?.(); lease?.release(); }
|
|
57
|
+
}
|
|
58
|
+
module.exports = { repairServiceRegistrations };
|
|
@@ -22,6 +22,7 @@ function updateLogStdio(platform = process.platform, homeDir = os.homedir()) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
if (require.main === module) {
|
|
25
|
+
if (process.versions.electron) throw Error("Relay update workers require the verified Node runtime, not Electron");
|
|
25
26
|
const [entry, mode, payload] = process.argv.slice(2);
|
|
26
27
|
if (!entry || mode !== "--worker" || !payload) throw Error("Missing update worker arguments");
|
|
27
28
|
const { stdio, logPath } = updateLogStdio();
|
package/package.json
CHANGED