@aron98/load-lock 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/opencode/tui.js +5 -1
- package/dist/cli/copies.d.ts +28 -0
- package/dist/cli/copies.js +207 -0
- package/dist/cli/main.js +264 -16
- package/dist/core/daemon.d.ts +18 -0
- package/dist/core/daemon.js +84 -3
- package/dist/core/persist.js +5 -3
- package/dist/core/state.d.ts +7 -4
- package/dist/core/state.js +64 -10
- package/dist/dashboard/frame.js +5 -1
- package/dist/dashboard/view.d.ts +4 -0
- package/dist/dashboard/view.js +5 -0
- package/dist/shared/client.js +4 -1
- package/dist/shared/protocol.d.ts +40 -2
- package/dist/shared/protocol.js +5 -0
- package/dist/shared/version.d.ts +10 -0
- package/dist/shared/version.js +57 -0
- package/dist/test/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/docs/ARCHITECTURE.md +10 -6
- package/docs/COMPATIBILITY.md +32 -0
- package/docs/OPERATIONS.md +52 -5
- package/package.json +1 -1
|
@@ -89,13 +89,17 @@ function dashboardLines(v, width, f) {
|
|
|
89
89
|
const syncAge = v.lastSyncAt ? fmtDuration(Math.max(0, v.now - v.lastSyncAt)) : "never";
|
|
90
90
|
push(`load-lock dashboard ${STATE_LABEL[v.state] ?? v.state} synced ${syncAge} ago`, stateColor(v.state, f));
|
|
91
91
|
if (v.daemon) {
|
|
92
|
-
push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}
|
|
92
|
+
push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}` +
|
|
93
|
+
(v.daemon.daemonVersion ? ` · daemon v${v.daemon.daemonVersion}` : "") +
|
|
94
|
+
` · events through #${v.latestEventId}`, f.dim);
|
|
93
95
|
}
|
|
94
96
|
else {
|
|
95
97
|
push(`daemon unreachable — it auto-spawns on the next load-lock command`, f.dim);
|
|
96
98
|
}
|
|
97
99
|
if (v.daemonRestartNote)
|
|
98
100
|
push(`note: ${v.daemonRestartNote}`, f.warn);
|
|
101
|
+
if (v.versionSkewNote)
|
|
102
|
+
push(`note: ${v.versionSkewNote}`, f.warn);
|
|
99
103
|
if (v.machine) {
|
|
100
104
|
const m = v.machine;
|
|
101
105
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface InstalledCopy {
|
|
2
|
+
/** Where this copy was found. */
|
|
3
|
+
source: "cli" | "path" | "opencode-cache";
|
|
4
|
+
/** Path of the copy's package root (its package.json's directory). */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Version string, or null when the package.json could not be read. */
|
|
7
|
+
version: string | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
11
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
12
|
+
* no enclosing package.json matches (or is readable).
|
|
13
|
+
*/
|
|
14
|
+
export declare function enclosingPackage(startDir: string, maxDepth?: number): {
|
|
15
|
+
path: string;
|
|
16
|
+
version: string | null;
|
|
17
|
+
} | null;
|
|
18
|
+
/** The copy of load-lock this process runs from (its own package root). */
|
|
19
|
+
export declare function thisCopy(): InstalledCopy | null;
|
|
20
|
+
/**
|
|
21
|
+
* All installed copies of load-lock detectable on this machine:
|
|
22
|
+
* - "cli": the copy this process runs from.
|
|
23
|
+
* - "path": any `load-lock` binary found on PATH (deduped by real path,
|
|
24
|
+
* so a symlink to the running copy is recognized and not double-listed
|
|
25
|
+
* — a copy is only listed under "path" when it differs from "cli").
|
|
26
|
+
* - "opencode-cache": copies under opencode's private npm cache.
|
|
27
|
+
*/
|
|
28
|
+
export declare function findInstalledCopies(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstalledCopy[];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installed-copy detection (issue #34): on a given machine several copies
|
|
3
|
+
* of load-lock commonly coexist — the globally-installed CLI on PATH, and
|
|
4
|
+
* the private copy opencode maintains under its npm cache (because
|
|
5
|
+
* `install --harness opencode` registers the bare package name, opencode
|
|
6
|
+
* installs and refreshes its own copy on its own schedule).
|
|
7
|
+
*
|
|
8
|
+
* All scanning is best-effort: missing paths, unreadable files, and
|
|
9
|
+
* unexpected layouts yield skipped entries or null versions — never a throw.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { isWindows } from "../shared/transport.js";
|
|
16
|
+
/** Package name of this package (used to find enclosing package roots). */
|
|
17
|
+
const PKG_NAME = "@aron98/load-lock";
|
|
18
|
+
/**
|
|
19
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
20
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
21
|
+
* no enclosing package.json matches (or is readable).
|
|
22
|
+
*/
|
|
23
|
+
export function enclosingPackage(startDir, maxDepth = 12) {
|
|
24
|
+
let dir = startDir;
|
|
25
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
26
|
+
const pkgPath = join(dir, "package.json");
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(pkgPath)) {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
30
|
+
if (pkg?.name === PKG_NAME) {
|
|
31
|
+
return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
|
|
32
|
+
}
|
|
33
|
+
// A foreign package.json terminates the climb (e.g. a PATH bin
|
|
34
|
+
// inside some other tool's package).
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const parent = dirname(dir);
|
|
42
|
+
if (parent === dir)
|
|
43
|
+
return null;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** The copy of load-lock this process runs from (its own package root). */
|
|
49
|
+
export function thisCopy() {
|
|
50
|
+
// dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
|
|
51
|
+
const startDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
52
|
+
const found = enclosingPackage(startDir);
|
|
53
|
+
if (!found)
|
|
54
|
+
return null;
|
|
55
|
+
return { source: "cli", path: found.path, version: found.version };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* All installed copies of load-lock detectable on this machine:
|
|
59
|
+
* - "cli": the copy this process runs from.
|
|
60
|
+
* - "path": any `load-lock` binary found on PATH (deduped by real path,
|
|
61
|
+
* so a symlink to the running copy is recognized and not double-listed
|
|
62
|
+
* — a copy is only listed under "path" when it differs from "cli").
|
|
63
|
+
* - "opencode-cache": copies under opencode's private npm cache.
|
|
64
|
+
*/
|
|
65
|
+
export function findInstalledCopies(platform = process.platform, env = process.env) {
|
|
66
|
+
const copies = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const add = (c) => {
|
|
69
|
+
if (!c)
|
|
70
|
+
return;
|
|
71
|
+
let real;
|
|
72
|
+
try {
|
|
73
|
+
real = realpathSync(c.path);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
real = c.path;
|
|
77
|
+
}
|
|
78
|
+
if (seen.has(real))
|
|
79
|
+
return;
|
|
80
|
+
seen.add(real);
|
|
81
|
+
copies.push(c);
|
|
82
|
+
};
|
|
83
|
+
add(thisCopy());
|
|
84
|
+
// --- PATH scan ------------------------------------------------------------
|
|
85
|
+
try {
|
|
86
|
+
const binNames = isWindows(platform)
|
|
87
|
+
? ["load-lock.cmd", "load-lock.exe", "load-lock"]
|
|
88
|
+
: ["load-lock"];
|
|
89
|
+
const dirs = (env.PATH ?? "").split(isWindows(platform) ? ";" : ":").filter(Boolean);
|
|
90
|
+
for (const dir of dirs) {
|
|
91
|
+
for (const bin of binNames) {
|
|
92
|
+
try {
|
|
93
|
+
const binPath = join(dir, bin);
|
|
94
|
+
if (!statSync(binPath).isFile())
|
|
95
|
+
continue;
|
|
96
|
+
// A shim/wrapper script is not itself the copy — resolve through
|
|
97
|
+
// its enclosing package (works for npm/bun global layouts where
|
|
98
|
+
// the bin sits in <pkg>/bin or node_modules/.bin via a symlink).
|
|
99
|
+
let realBin;
|
|
100
|
+
try {
|
|
101
|
+
realBin = realpathSync(binPath);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
realBin = binPath;
|
|
105
|
+
}
|
|
106
|
+
const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
|
|
107
|
+
if (found)
|
|
108
|
+
add({ source: "path", path: found.path, version: found.version });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* unreadable PATH entry — skip */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* PATH scan is best-effort */
|
|
118
|
+
}
|
|
119
|
+
// --- opencode npm-cache scan ----------------------------------------------
|
|
120
|
+
// Layout (verified live): <cacheRoot>/opencode/npm/@aron98/load-lock@latest/<ts>/
|
|
121
|
+
// where "@aron98" and "load-lock@latest" are two nested directories (a
|
|
122
|
+
// normal npm scoped layout); URL-escaped single-directory variants like
|
|
123
|
+
// "@aron98%2Fload-lock@latest" also occur. Under each <ts>: either the
|
|
124
|
+
// real package at node_modules/@aron98/load-lock, or a top-level
|
|
125
|
+
// package.json (npm-install root).
|
|
126
|
+
try {
|
|
127
|
+
const cacheRoot = opencodeCacheRoot(platform, env);
|
|
128
|
+
if (cacheRoot) {
|
|
129
|
+
const npmDir = join(cacheRoot, "opencode", "npm");
|
|
130
|
+
if (existsSync(npmDir)) {
|
|
131
|
+
const scanTimestampDirs = (pkgBase) => {
|
|
132
|
+
let tsDirs = [];
|
|
133
|
+
try {
|
|
134
|
+
tsDirs = readdirSync(pkgBase).filter((d) => statSync(join(pkgBase, d)).isDirectory());
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const ts of tsDirs) {
|
|
140
|
+
const nested = join(pkgBase, ts, "node_modules", "@aron98", "load-lock");
|
|
141
|
+
const topLevel = join(pkgBase, ts);
|
|
142
|
+
for (const cand of [nested, topLevel]) {
|
|
143
|
+
try {
|
|
144
|
+
const pkgPath = join(cand, "package.json");
|
|
145
|
+
if (!existsSync(pkgPath))
|
|
146
|
+
continue;
|
|
147
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
148
|
+
if (pkg?.name !== PKG_NAME)
|
|
149
|
+
continue;
|
|
150
|
+
add({
|
|
151
|
+
source: "opencode-cache",
|
|
152
|
+
path: cand,
|
|
153
|
+
version: typeof pkg.version === "string" ? pkg.version : null,
|
|
154
|
+
});
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* skip unreadable timestamp dir */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
let scopeDirs = [];
|
|
164
|
+
try {
|
|
165
|
+
scopeDirs = readdirSync(npmDir).filter((d) => statSync(join(npmDir, d)).isDirectory());
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
scopeDirs = [];
|
|
169
|
+
}
|
|
170
|
+
for (const scopeDir of scopeDirs) {
|
|
171
|
+
if (scopeDir.startsWith("@aron98%2Fload-lock")) {
|
|
172
|
+
// URL-escaped single-directory form.
|
|
173
|
+
scanTimestampDirs(join(npmDir, scopeDir));
|
|
174
|
+
}
|
|
175
|
+
else if (scopeDir === "@aron98") {
|
|
176
|
+
// Scoped layout: @aron98/<name>@<version-range>/<ts>/...
|
|
177
|
+
let inner = [];
|
|
178
|
+
try {
|
|
179
|
+
inner = readdirSync(join(npmDir, scopeDir)).filter((d) => statSync(join(npmDir, scopeDir, d)).isDirectory());
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
for (const nameDir of inner) {
|
|
185
|
+
if (nameDir.startsWith("load-lock"))
|
|
186
|
+
scanTimestampDirs(join(npmDir, scopeDir, nameDir));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* cache scan is best-effort */
|
|
195
|
+
}
|
|
196
|
+
return copies;
|
|
197
|
+
}
|
|
198
|
+
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
199
|
+
function opencodeCacheRoot(platform, env) {
|
|
200
|
+
if (env.XDG_CACHE_HOME)
|
|
201
|
+
return env.XDG_CACHE_HOME;
|
|
202
|
+
if (platform === "win32")
|
|
203
|
+
return env.LOCALAPPDATA ?? null;
|
|
204
|
+
if (platform === "darwin")
|
|
205
|
+
return join(env.HOME ?? homedir(), "Library", "Caches");
|
|
206
|
+
return join(env.HOME ?? homedir(), ".cache");
|
|
207
|
+
}
|
package/dist/cli/main.js
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
* (queued/registered); 69 offer/claim failed; 70 internal.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { connect } from "node:net";
|
|
12
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
11
13
|
import { LLClient, withClient } from "../shared/client.js";
|
|
12
14
|
import { ProtocolError } from "../shared/protocol.js";
|
|
13
15
|
import { waitForTurn } from "../shared/wait-turn.js";
|
|
14
16
|
import { stateDir, socketPath } from "../core/persist.js";
|
|
15
17
|
import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
|
|
18
|
+
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
19
|
+
import { findInstalledCopies } from "./copies.js";
|
|
16
20
|
import { installCommand, uninstallCommand } from "./install.js";
|
|
17
21
|
import { hookCommand } from "./hook.js";
|
|
18
22
|
import { topCommand } from "./top.js";
|
|
@@ -61,6 +65,87 @@ function defaultAgent() {
|
|
|
61
65
|
return (process.env.LOAD_LOCK_AGENT ||
|
|
62
66
|
"cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
|
|
63
67
|
}
|
|
68
|
+
// ------------------------------------------------- daemon service helpers
|
|
69
|
+
/** daemon.json contents, or null when missing/unreadable. */
|
|
70
|
+
function readDaemonInfo(dir) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Version-less hello over a raw socket connection: deliberately does NOT
|
|
80
|
+
* send clientVersion, so asking a daemon for its identity never orders it
|
|
81
|
+
* to self-restart (used by `service stop` and post-restart confirmation).
|
|
82
|
+
* Best-effort: resolves null on any failure.
|
|
83
|
+
*/
|
|
84
|
+
function rawHello(sockPath, timeoutMs = 2000) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
let settled = false;
|
|
87
|
+
let buf = "";
|
|
88
|
+
const done = (v) => {
|
|
89
|
+
if (settled)
|
|
90
|
+
return;
|
|
91
|
+
settled = true;
|
|
92
|
+
try {
|
|
93
|
+
sock.destroy();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
resolve(v);
|
|
99
|
+
};
|
|
100
|
+
const sock = connect(sockPath, () => {
|
|
101
|
+
sock.write(JSON.stringify({ id: 1, method: "hello", params: {} }) + "\n");
|
|
102
|
+
});
|
|
103
|
+
sock.on("data", (chunk) => {
|
|
104
|
+
buf += chunk.toString("utf8");
|
|
105
|
+
const idx = buf.indexOf("\n");
|
|
106
|
+
if (idx === -1)
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
const msg = JSON.parse(buf.slice(0, idx));
|
|
110
|
+
done(msg.ok ? msg.result : null);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
done(null);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
sock.on("error", () => done(null));
|
|
117
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
118
|
+
timer.unref?.();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Poll the socket until nothing answers there (bounded). */
|
|
122
|
+
async function waitSocketFree(sockPath, timeoutMs) {
|
|
123
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
124
|
+
for (;;) {
|
|
125
|
+
if (!(await probeTransport(sockPath)))
|
|
126
|
+
return true;
|
|
127
|
+
if (Date.now() >= deadline)
|
|
128
|
+
return false;
|
|
129
|
+
await delay(100);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* After ordering a self-restart, wait for the daemon to come back running
|
|
134
|
+
* at least `installed` (the version of this CLI copy). Resolves its hello
|
|
135
|
+
* result, or null if it did not come back (or an older/pre-handshake
|
|
136
|
+
* daemon answered again — e.g. the daemon's copy is not actually updated).
|
|
137
|
+
*/
|
|
138
|
+
async function awaitDaemonUpgraded(sockPath, timeoutMs, installed) {
|
|
139
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
140
|
+
for (;;) {
|
|
141
|
+
const h = await rawHello(sockPath, 1500);
|
|
142
|
+
if (h && typeof h.daemonVersion === "string" && compareVersions(h.daemonVersion, installed) >= 0)
|
|
143
|
+
return h;
|
|
144
|
+
if (Date.now() >= deadline)
|
|
145
|
+
return null;
|
|
146
|
+
await delay(250);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
64
149
|
const HELP_GROUPS = [
|
|
65
150
|
{
|
|
66
151
|
title: "permit operations",
|
|
@@ -96,8 +181,8 @@ const HELP_GROUPS = [
|
|
|
96
181
|
{
|
|
97
182
|
title: "service & diagnostics",
|
|
98
183
|
items: [
|
|
99
|
-
{ names: ["service"], desc: "daemon lifecycle: start | stop | status" },
|
|
100
|
-
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, machine" },
|
|
184
|
+
{ names: ["service"], desc: "daemon lifecycle: start | stop | status (stop/start take --timeout <ms>)" },
|
|
185
|
+
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, versions + copies, machine" },
|
|
101
186
|
{ names: ["recover"], desc: "list held permits with diagnostics; recover force-release --id <request-id>" },
|
|
102
187
|
],
|
|
103
188
|
},
|
|
@@ -157,6 +242,10 @@ async function main() {
|
|
|
157
242
|
lines.push(` offer: ${o.agentLabel || o.agentId} — ${o.reason} (expires in ${Math.max(0, Math.round(((o.offerExpiresAt ?? 0) - Date.now()) / 1000))}s)`);
|
|
158
243
|
p.queue.forEach((q, i) => lines.push(` queued #${i + 1}: ${q.agentLabel || q.agentId} — ${q.reason}`));
|
|
159
244
|
}
|
|
245
|
+
const dv = r.daemon?.daemonVersion;
|
|
246
|
+
if (typeof dv === "string" && compareVersions(dv, packageVersion()) < 0) {
|
|
247
|
+
lines.push(`note: daemon v${dv} is older than this CLI v${packageVersion()} — it self-restarts on contact; run again to confirm`);
|
|
248
|
+
}
|
|
160
249
|
return lines.join("\n");
|
|
161
250
|
});
|
|
162
251
|
return;
|
|
@@ -339,28 +428,149 @@ async function main() {
|
|
|
339
428
|
case "service": {
|
|
340
429
|
const sub = rest[0] ?? "status";
|
|
341
430
|
if (sub === "stop") {
|
|
342
|
-
//
|
|
431
|
+
// Honest stop: signal the daemon, wait bounded for its socket to go
|
|
432
|
+
// silent, escalate to SIGKILL, and only report success once the
|
|
433
|
+
// endpoint is verifiably free. A leftover silent socket file or
|
|
434
|
+
// daemon.json (crash leftovers) is cleaned up either way.
|
|
343
435
|
const dir = stateDir();
|
|
436
|
+
const sock = socketPath();
|
|
437
|
+
const timeoutMs = N("timeout", 5000);
|
|
438
|
+
const info = readDaemonInfo(dir);
|
|
439
|
+
const answering = await probeTransport(sock);
|
|
440
|
+
if (!answering) {
|
|
441
|
+
// Nothing is listening: no daemon to stop. Clean crash leftovers.
|
|
442
|
+
try {
|
|
443
|
+
if (!isWindows() && existsSync(sock))
|
|
444
|
+
unlinkSync(sock);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
/* ignore */
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
rmSync(join(dir, "daemon.json"), { force: true });
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
/* ignore */
|
|
454
|
+
}
|
|
455
|
+
process.stdout.write("load-lock: no running daemon found (stale files cleaned)\n");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Identify the daemon: prefer daemon.json, else ask it directly
|
|
459
|
+
// (version-less hello — stopping must not order a self-restart).
|
|
460
|
+
let pid = typeof info?.pid === "number" ? info.pid : undefined;
|
|
461
|
+
if (!pid) {
|
|
462
|
+
const hello = await rawHello(sock, 1500);
|
|
463
|
+
if (hello && typeof hello.pid === "number")
|
|
464
|
+
pid = hello.pid;
|
|
465
|
+
}
|
|
466
|
+
if (!pid) {
|
|
467
|
+
fail(`the socket ${sock} answers but no pid could be identified (daemon did not respond and daemon.json names none) ` +
|
|
468
|
+
`— cannot stop safely; run \`load-lock doctor\``, 70);
|
|
469
|
+
}
|
|
470
|
+
let how = "SIGTERM";
|
|
471
|
+
let stopped = false;
|
|
344
472
|
try {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
473
|
+
process.kill(pid, "SIGTERM");
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
if (e?.code === "ESRCH")
|
|
477
|
+
stopped = true; // already gone
|
|
478
|
+
else
|
|
479
|
+
fail(`cannot signal daemon pid ${pid}: ${e?.message ?? e}`, 70);
|
|
480
|
+
}
|
|
481
|
+
if (!stopped)
|
|
482
|
+
stopped = await waitSocketFree(sock, timeoutMs);
|
|
483
|
+
if (!stopped) {
|
|
484
|
+
how = "SIGKILL";
|
|
485
|
+
try {
|
|
486
|
+
process.kill(pid, "SIGKILL");
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
/* already dead */
|
|
490
|
+
}
|
|
491
|
+
stopped = await waitSocketFree(sock, timeoutMs);
|
|
492
|
+
}
|
|
493
|
+
if (!stopped) {
|
|
494
|
+
fail(`daemon pid ${pid} did not stop within ${timeoutMs}ms even after SIGKILL — run \`load-lock doctor\``, 70);
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
if (!isWindows() && existsSync(sock))
|
|
498
|
+
unlinkSync(sock);
|
|
348
499
|
}
|
|
349
500
|
catch {
|
|
350
|
-
|
|
501
|
+
/* ignore */
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
rmSync(join(dir, "daemon.json"), { force: true });
|
|
351
505
|
}
|
|
506
|
+
catch {
|
|
507
|
+
/* ignore */
|
|
508
|
+
}
|
|
509
|
+
process.stdout.write(`load-lock: daemon (pid ${pid}) stopped${how === "SIGKILL" ? " (required SIGKILL after SIGTERM)" : ""}\n`);
|
|
352
510
|
return;
|
|
353
511
|
}
|
|
354
512
|
if (sub === "start") {
|
|
355
513
|
// Connecting auto-spawns a detached daemon (see shared/client.ts);
|
|
356
|
-
// hello confirms it is up.
|
|
357
|
-
const
|
|
358
|
-
|
|
514
|
+
// hello confirms it is up. Refusals name the conflicting pid.
|
|
515
|
+
const timeoutMs = N("timeout", 10_000);
|
|
516
|
+
const sock = socketPath();
|
|
517
|
+
const installed = packageVersion();
|
|
518
|
+
try {
|
|
519
|
+
const r = (await withClient((c) => c.request("hello", {}, timeoutMs)));
|
|
520
|
+
const dv = typeof r.daemonVersion === "string" ? r.daemonVersion : undefined;
|
|
521
|
+
if (dv && compareVersions(dv, installed) < 0) {
|
|
522
|
+
// The handshake just ordered this old daemon to self-restart;
|
|
523
|
+
// confirm it comes back on updated code.
|
|
524
|
+
const up = await awaitDaemonUpgraded(sock, 8000, installed);
|
|
525
|
+
if (up) {
|
|
526
|
+
process.stdout.write(`load-lock: daemon self-restarted ${r.pid} -> ${up.pid} (v${dv} -> v${up.daemonVersion}), running on ${sock}\n`);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock}), daemon v${dv} — older than this CLI v${installed}; ` +
|
|
530
|
+
`self-restart ordered but not confirmed (its copy may not be updated in place — see docs/OPERATIONS.md)\n`);
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock})${dv ? `, daemon v${dv}` : ""}\n`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
if (await probeTransport(sock)) {
|
|
539
|
+
// Something owns the endpoint. Name it.
|
|
540
|
+
const hello = await rawHello(sock, 1500);
|
|
541
|
+
if (hello && typeof hello.pid === "number") {
|
|
542
|
+
fail(`start refused: socket answered from pid ${hello.pid} — another daemon owns ${sock}; ` +
|
|
543
|
+
`if it is stale, run \`load-lock service stop\` first`, 70);
|
|
544
|
+
}
|
|
545
|
+
const info = readDaemonInfo(stateDir());
|
|
546
|
+
if (info && typeof info.pid === "number") {
|
|
547
|
+
fail(`socket ${sock} answers but the daemon did not respond (daemon.json names pid ${info.pid}) — likely hung; ` +
|
|
548
|
+
`run \`load-lock service stop\``, 70);
|
|
549
|
+
}
|
|
550
|
+
fail(`start refused: ${sock} is in use but nothing responsive owns it — run \`load-lock doctor\``, 70);
|
|
551
|
+
}
|
|
552
|
+
throw e;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (sub === "status") {
|
|
556
|
+
const r = (await withClient((c) => c.request("hello", {})));
|
|
557
|
+
out(r, () => {
|
|
558
|
+
let line = `load-lock daemon: pid ${r.pid}, protocol v${r.protocolVersion}, config version ${r.configVersion}`;
|
|
559
|
+
if (typeof r.daemonVersion === "string") {
|
|
560
|
+
line += `, daemon v${r.daemonVersion}`;
|
|
561
|
+
const installed = packageVersion();
|
|
562
|
+
if (compareVersions(r.daemonVersion, installed) < 0) {
|
|
563
|
+
line += ` — older than this CLI v${installed}; self-restarting on contact`;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
line += ` — pre-handshake daemon (no version report); \`load-lock service stop\` then any command restarts it on current code`;
|
|
568
|
+
}
|
|
569
|
+
return line;
|
|
570
|
+
});
|
|
359
571
|
return;
|
|
360
572
|
}
|
|
361
|
-
|
|
362
|
-
const r = await withClient((c) => c.request("hello", {}));
|
|
363
|
-
out(r, () => `load-lock daemon: pid ${r.pid}, protocol v${r.protocolVersion}, config version ${r.configVersion}`);
|
|
573
|
+
fail(`service: unknown subcommand "${sub}" (use start | stop | status)`);
|
|
364
574
|
return;
|
|
365
575
|
}
|
|
366
576
|
case "doctor": {
|
|
@@ -376,16 +586,54 @@ async function main() {
|
|
|
376
586
|
report.socketExists = isWindows() ? await probeTransport(sock) : existsSync(sock);
|
|
377
587
|
report.configExists = existsSync(join(dir, "config.json"));
|
|
378
588
|
report.journalExists = existsSync(join(dir, "journal.json"));
|
|
589
|
+
report.installedVersion = packageVersion();
|
|
590
|
+
const warnings = [];
|
|
379
591
|
try {
|
|
380
|
-
|
|
381
|
-
|
|
592
|
+
// One connection for hello + inspect: if the handshake orders a
|
|
593
|
+
// self-restart (daemon older than this CLI), both requests must
|
|
594
|
+
// finish before the daemon drains and exits.
|
|
595
|
+
const { hello, insp } = await withClient(async (c) => ({
|
|
596
|
+
hello: await c.request("hello", {}),
|
|
597
|
+
insp: await c.request("inspect", {}),
|
|
598
|
+
}));
|
|
599
|
+
report.hello = hello;
|
|
382
600
|
report.effectiveCores = insp.cpu.effectiveCores;
|
|
383
601
|
report.pressure = insp.pressure.note;
|
|
384
602
|
report.procs = insp.cpu.cgroupVersion;
|
|
603
|
+
const dv = hello.daemonVersion;
|
|
604
|
+
if (typeof dv === "string") {
|
|
605
|
+
const installed = packageVersion();
|
|
606
|
+
if (compareVersions(dv, installed) < 0) {
|
|
607
|
+
warnings.push(`daemon v${dv} is older than this CLI v${installed}; the version handshake ordered it to self-restart`);
|
|
608
|
+
const up = await awaitDaemonUpgraded(sock, 5000, installed);
|
|
609
|
+
if (up) {
|
|
610
|
+
report.daemonUpgradedTo = up.daemonVersion;
|
|
611
|
+
warnings.push(`daemon self-restarted to v${up.daemonVersion} (pid ${up.pid}) — now up to date`);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
warnings.push(`daemon did not come back on v${installed} within 5s (its on-disk copy may not be updated in place — ` +
|
|
615
|
+
`see docs/OPERATIONS.md "Update lifecycle"); the next client contact retries the restart`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
warnings.push(`pre-handshake daemon (no version in hello): restart it onto current code — \`load-lock service stop\`, then any load-lock command`);
|
|
621
|
+
}
|
|
385
622
|
}
|
|
386
623
|
catch (e) {
|
|
387
624
|
report.daemon = `unreachable: ${e.message}`;
|
|
388
625
|
}
|
|
626
|
+
// Multi-copy reality (issue #34): list every detectable copy and warn
|
|
627
|
+
// when their versions drift apart.
|
|
628
|
+
const copies = findInstalledCopies();
|
|
629
|
+
report.copies = copies;
|
|
630
|
+
const versions = new Set(copies.map((c) => c.version).filter((v) => v !== null));
|
|
631
|
+
if (versions.size > 1) {
|
|
632
|
+
warnings.push(`multiple load-lock versions installed (${[...versions].sort().join(", ")}): ` +
|
|
633
|
+
copies.map((c) => `${c.source}=${c.version ?? "?"} (${c.path})`).join("; ") +
|
|
634
|
+
` — see docs/COMPATIBILITY.md "Multiple copies of load-lock" for which copy is authoritative for what`);
|
|
635
|
+
}
|
|
636
|
+
report.warnings = warnings;
|
|
389
637
|
out(report, () => JSON.stringify(report, null, 2));
|
|
390
638
|
return;
|
|
391
639
|
}
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare class Daemon {
|
|
|
32
32
|
private socketPath;
|
|
33
33
|
private sweepTimer;
|
|
34
34
|
private expiryTimer;
|
|
35
|
+
private restartTimer;
|
|
35
36
|
private stopped;
|
|
36
37
|
constructor(opts?: {
|
|
37
38
|
stateDir?: string;
|
|
@@ -50,6 +51,23 @@ export declare class Daemon {
|
|
|
50
51
|
private onConnection;
|
|
51
52
|
private send;
|
|
52
53
|
private handleLine;
|
|
54
|
+
/**
|
|
55
|
+
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
56
|
+
* package version than this daemon runs, schedule a respawn so the daemon
|
|
57
|
+
* picks up the updated on-disk code (e.g. after `npm i -g
|
|
58
|
+
* @aron98/load-lock` while the daemon kept running). Skew detection is
|
|
59
|
+
* entirely daemon-side and best-effort: pre-handshake clients that send
|
|
60
|
+
* no version never trigger a restart.
|
|
61
|
+
*/
|
|
62
|
+
private maybeScheduleSelfRestart;
|
|
63
|
+
/**
|
|
64
|
+
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
65
|
+
* journal FIRST, then stops (destroying all client connections — they
|
|
66
|
+
* reconnect to the fresh daemon), then spawns the replacement with the
|
|
67
|
+
* exact same argv so a daemon started via the old copy runs the new copy's
|
|
68
|
+
* code, and exits.
|
|
69
|
+
*/
|
|
70
|
+
private selfRestart;
|
|
53
71
|
/** Returns [result, agentIdForPendingAttachment]. */
|
|
54
72
|
private dispatch;
|
|
55
73
|
/**
|