@aron98/load-lock 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Reconnect + update-staleness tests (issue #39): "TUI plugin fails to
3
+ * reconnect after daemon/plugin update without an opencode restart".
4
+ *
5
+ * Diagnosis outcome (see the PR description): the reported incident is the
6
+ * harness loading plugin code ONCE at startup (opencode never hot-reloads
7
+ * plugins) — an update swaps the on-disk copy while the session keeps
8
+ * executing the old code in memory. The feed's reconnect machinery itself
9
+ * works. These tests pin down the surrounding behaviors that make that
10
+ * diagnosis visible and close the real gaps found along the way:
11
+ *
12
+ * - H3 (genuine bug, FIXED): a failed INITIAL connect wedged the feed in
13
+ * "connecting" forever — no socket was attached, so no close event could
14
+ * ever trigger the reconnect loop. Regression test: start a feed with
15
+ * spawnDaemon:false, fail, then bring a real daemon up late.
16
+ * - H1 repro: SIGKILL the daemon under a live feed; the client auto-spawns
17
+ * a replacement (same on-disk code) and the feed goes live again with a
18
+ * "daemon restarted" note — proving reconnect works when the plugin code
19
+ * in memory matches what is on disk.
20
+ * - "restarted onto vX" note: when the v1.3.0 handshake reports a different
21
+ * daemon version across a restart, the note says which version it
22
+ * restarted onto (mock FeedClient so we can force exact versions).
23
+ * - install drift: running copy older than the PATH CLI install is
24
+ * detected (fixture PATH) and surfaced as a surface-appropriate hint
25
+ * ("restart opencode to pick up vX" for the plugin).
26
+ * - H2 hardening: a vanished/unreadable daemon entry must fail the spawn
27
+ * probe ("did not come up") instead of crashing the host process with an
28
+ * uncaught ENOENT (subprocess test).
29
+ */
30
+ import test from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { spawn } from "node:child_process";
33
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
34
+ import { tmpdir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { DashboardFeed } from "../dashboard/feed.js";
37
+ import { LLClient } from "../shared/client.js";
38
+ import { buildDashboardView } from "../dashboard/view.js";
39
+ import { renderFrame } from "../dashboard/frame.js";
40
+ import { detectInstallDrift, pathInstall, resolveDaemonEntry, } from "../shared/installs.js";
41
+ import { packageVersion } from "../shared/version.js";
42
+ import { daemonMainPath, delay, startDaemon, until } from "./testutil.js";
43
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
44
+ /** Kill whatever daemon currently owns the state dir (respawned daemons are not our child). */
45
+ function killDaemonFromJson(dir) {
46
+ try {
47
+ const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
48
+ if (typeof info.pid === "number") {
49
+ try {
50
+ process.kill(info.pid, "SIGKILL");
51
+ }
52
+ catch {
53
+ /* already gone */
54
+ }
55
+ }
56
+ }
57
+ catch {
58
+ /* no daemon.json — nothing to kill */
59
+ }
60
+ }
61
+ // ---------------------------------------------------------------- H3 regression
62
+ test("feed: failed initial connect recovers once the daemon appears (issue #39 H3)", async () => {
63
+ const dir = mkdtempSync(join(TMP_BASE, "ll-h3-"));
64
+ const sock = join(dir, "daemon.sock");
65
+ // spawnDaemon:false so the FIRST connect fails hard (no daemon, no spawn).
66
+ const client = new LLClient({ socket: sock, stateDir: dir, spawnDaemon: false });
67
+ const feed = new DashboardFeed(client, {
68
+ reconnectBaseMs: 40,
69
+ refreshDebounceMs: 5,
70
+ slowRefreshMs: 60_000,
71
+ inspectEveryMs: 60_000,
72
+ driftCheckMs: 0,
73
+ });
74
+ await assert.rejects(feed.start(), /unavailable|not running/);
75
+ // The reconnect loop re-enters "connecting" on each attempt; wait for the
76
+ // failed attempt to land in "reconnecting" (old code: wedged in
77
+ // "connecting" forever, so this alone catches the regression).
78
+ await until("failed start enters the reconnect loop", () => feed.currentState() === "reconnecting");
79
+ // Daemon shows up LATE. Before the fix, the feed stayed wedged in
80
+ // "connecting" forever (no socket ever attached → no close event →
81
+ // handleDisconnect never fired → no reconnect loop).
82
+ const child = spawn(process.execPath, [daemonMainPath, "--state-dir", dir, "--socket", sock], {
83
+ stdio: "ignore",
84
+ });
85
+ try {
86
+ await until("feed goes live after late daemon spawn", () => feed.currentState() === "live");
87
+ const v = feed.view();
88
+ assert.ok(v.daemon, "daemon card present once live");
89
+ assert.ok(v.daemon.pid > 0);
90
+ }
91
+ finally {
92
+ await feed.stop();
93
+ child.kill("SIGKILL");
94
+ await delay(200);
95
+ rmSync(dir, { recursive: true, force: true });
96
+ }
97
+ });
98
+ // ---------------------------------------------------------------- H1 repro
99
+ test("feed: SIGKILL daemon under live feed → auto-respawn and live again (issue #39 H1 repro)", async () => {
100
+ const d = await startDaemon();
101
+ let feed = null;
102
+ try {
103
+ // spawnDaemon defaults to true: the client auto-spawns a replacement.
104
+ const client = new LLClient({ socket: d.sock, stateDir: d.dir });
105
+ feed = new DashboardFeed(client, {
106
+ reconnectBaseMs: 40,
107
+ refreshDebounceMs: 5,
108
+ slowRefreshMs: 60_000,
109
+ inspectEveryMs: 60_000,
110
+ driftCheckMs: 0,
111
+ eventHistory: 100,
112
+ });
113
+ await feed.start();
114
+ assert.equal(feed.currentState(), "live");
115
+ const oldPid = feed.view().daemon?.pid;
116
+ assert.ok(oldPid, "daemon pid visible before kill");
117
+ d.child.kill("SIGKILL");
118
+ await until("feed notices the daemon died", () => feed.currentState() !== "live");
119
+ // (State briefly reads "reconnecting" then "connecting" while the client
120
+ // auto-spawns a replacement; the outcome assertion below is what counts.)
121
+ // Reconnect machinery (existing code) + client auto-spawn bring it back.
122
+ await until("feed is live again on the respawned daemon", () => feed.currentState() === "live");
123
+ const v = feed.view();
124
+ assert.ok(v.daemon, "daemon card present after respawn");
125
+ assert.notEqual(v.daemon.pid, oldPid, "a NEW daemon process answered");
126
+ assert.match(v.daemonRestartNote ?? "", /daemon restarted/);
127
+ assert.equal(v.daemon.daemonVersion, packageVersion(), "respawned daemon runs the current code");
128
+ }
129
+ finally {
130
+ await feed?.stop();
131
+ // The respawned daemon is nobody's child — kill via its state dir.
132
+ killDaemonFromJson(d.dir);
133
+ await d.stop();
134
+ }
135
+ });
136
+ // ------------------------------------------------- restart-note version change
137
+ /** Switchable mock daemon for resync-level tests (no real processes). */
138
+ class MockClient {
139
+ daemon;
140
+ events;
141
+ latestEventId;
142
+ watcher = null;
143
+ constructor(version) {
144
+ this.daemon = { pid: 111, startedAt: 1_000, protocolVersion: 1, daemonVersion: version };
145
+ this.events = [{ id: 1, ts: 1_000, type: "daemon_started" }];
146
+ this.latestEventId = 1;
147
+ }
148
+ async connect() { }
149
+ async request(method, params = {}) {
150
+ if (method === "status") {
151
+ return {
152
+ daemon: this.daemon,
153
+ config: {
154
+ version: 1,
155
+ pools: {},
156
+ offerTimeoutMs: 30_000,
157
+ maxWaitMs: 600_000,
158
+ maxTurnWaitMs: 600_000,
159
+ startTimeoutMs: 600_000,
160
+ untrackedLeaseMs: 600_000,
161
+ },
162
+ pools: [],
163
+ events: { latestEventId: this.latestEventId },
164
+ };
165
+ }
166
+ if (method === "events") {
167
+ const since = params.sinceEventId ?? 0;
168
+ return {
169
+ events: this.events.filter((e) => e.id > since),
170
+ latestEventId: this.latestEventId,
171
+ };
172
+ }
173
+ if (method === "watch")
174
+ return {};
175
+ throw new Error(`mock: unsupported method ${method}`);
176
+ }
177
+ async watch(_agentId, onEvent) {
178
+ this.watcher = onEvent;
179
+ }
180
+ onDisconnect() {
181
+ return () => { };
182
+ }
183
+ close() { }
184
+ /** Simulate a daemon restart, optionally onto a different version. */
185
+ restart(newVersion) {
186
+ this.daemon = { pid: 222, startedAt: 2_000, protocolVersion: 1, daemonVersion: newVersion };
187
+ this.events = [{ id: 1, ts: 2_000, type: "daemon_recovered" }];
188
+ this.latestEventId = 1;
189
+ }
190
+ }
191
+ function mockFeed(c) {
192
+ return new DashboardFeed(c, {
193
+ slowRefreshMs: 0,
194
+ driftCheckMs: 0,
195
+ refreshDebounceMs: 60_000,
196
+ inspectEveryMs: 60_000,
197
+ });
198
+ }
199
+ test("resync: daemon restart onto a DIFFERENT version says 'restarted onto vX (was vY)'", async () => {
200
+ const c = new MockClient("1.4.0");
201
+ const feed = mockFeed(c);
202
+ await feed.start();
203
+ assert.equal(feed.currentState(), "live");
204
+ assert.equal(feed.view().daemonRestartNote, undefined, "no note before any restart");
205
+ c.restart("1.5.0");
206
+ await feed.resync();
207
+ const note = feed.view().daemonRestartNote ?? "";
208
+ assert.match(note, /daemon restarted onto v1\.5\.0/);
209
+ assert.match(note, /was v1\.4\.0/);
210
+ await feed.stop();
211
+ });
212
+ test("resync: same-version daemon restart keeps the plain pid-only note", async () => {
213
+ const c = new MockClient("1.4.0");
214
+ const feed = mockFeed(c);
215
+ await feed.start();
216
+ c.restart("1.4.0");
217
+ await feed.resync();
218
+ const note = feed.view().daemonRestartNote ?? "";
219
+ assert.match(note, /daemon restarted/);
220
+ assert.doesNotMatch(note, /onto/);
221
+ await feed.stop();
222
+ });
223
+ test("resync: pre-handshake daemon (no version) never gets an 'onto' note", async () => {
224
+ const c = new MockClient("1.4.0");
225
+ const feed = mockFeed(c);
226
+ await feed.start();
227
+ c.restart(undefined); // old daemon: daemonVersion absent
228
+ await feed.resync();
229
+ const note = feed.view().daemonRestartNote ?? "";
230
+ assert.match(note, /daemon restarted/);
231
+ assert.doesNotMatch(note, /onto/);
232
+ await feed.stop();
233
+ });
234
+ // ---------------------------------------------------------------- install drift
235
+ /** Fixture PATH dir containing a fake `load-lock` install of `version`. */
236
+ function pathFixture(version) {
237
+ const dir = mkdtempSync(join(TMP_BASE, "ll-pathfix-"));
238
+ if (version === null)
239
+ return dir; // empty dir: no bin, no package
240
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version }));
241
+ const bin = join(dir, "load-lock");
242
+ writeFileSync(bin, "#!/bin/sh\nexit 0\n");
243
+ chmodSync(bin, 0o755);
244
+ return dir;
245
+ }
246
+ test("detectInstallDrift: PATH CLI newer than this copy ⇒ stale", () => {
247
+ const dir = pathFixture("99.0.0");
248
+ try {
249
+ const drift = detectInstallDrift(process.platform, { PATH: dir });
250
+ assert.equal(drift.pathVersion, "99.0.0");
251
+ assert.ok(drift.stale, "99.0.0 > " + drift.ownVersion);
252
+ }
253
+ finally {
254
+ rmSync(dir, { recursive: true, force: true });
255
+ }
256
+ });
257
+ test("detectInstallDrift: same version ⇒ not stale; no PATH install ⇒ null", () => {
258
+ const same = pathFixture(packageVersion());
259
+ const empty = pathFixture(null);
260
+ try {
261
+ const drift = detectInstallDrift(process.platform, { PATH: same });
262
+ assert.equal(drift.pathVersion, packageVersion());
263
+ assert.ok(!drift.stale);
264
+ const none = detectInstallDrift(process.platform, { PATH: empty });
265
+ assert.equal(none.pathVersion, null);
266
+ assert.ok(!none.stale);
267
+ }
268
+ finally {
269
+ rmSync(same, { recursive: true, force: true });
270
+ rmSync(empty, { recursive: true, force: true });
271
+ }
272
+ });
273
+ test("pathInstall: resolves through the fixture PATH dir", () => {
274
+ const dir = pathFixture("99.0.0");
275
+ try {
276
+ const inst = pathInstall(process.platform, { PATH: dir });
277
+ assert.ok(inst, "fixture install found");
278
+ assert.equal(inst.path, dir);
279
+ assert.equal(inst.version, "99.0.0");
280
+ }
281
+ finally {
282
+ rmSync(dir, { recursive: true, force: true });
283
+ }
284
+ });
285
+ function viewInputs(drift, surface) {
286
+ return {
287
+ status: null,
288
+ inspect: null,
289
+ events: [],
290
+ latestEventId: 0,
291
+ state: "live",
292
+ lastSyncAt: Date.now(),
293
+ installDrift: drift,
294
+ driftSurface: surface,
295
+ now: 1_760_000_000_000,
296
+ };
297
+ }
298
+ const STALE_DRIFT = { ownVersion: "1.4.0", pathVersion: "99.0.0", stale: true };
299
+ test("view: drift note for the opencode surface says 'restart opencode to pick up vX'", () => {
300
+ const v = buildDashboardView(viewInputs(STALE_DRIFT, "opencode"));
301
+ assert.match(v.installDriftNote ?? "", /plugin updated — restart opencode to pick up v99\.0\.0/);
302
+ assert.match(v.installDriftNote ?? "", /this session still runs v1\.4\.0/);
303
+ });
304
+ test("view: default (cli) drift note calls out the stale install", () => {
305
+ const v = buildDashboardView(viewInputs(STALE_DRIFT));
306
+ assert.match(v.installDriftNote ?? "", /PATH CLI v99\.0\.0 is newer than this copy v1\.4\.0/);
307
+ assert.match(v.installDriftNote ?? "", /stale install/);
308
+ });
309
+ test("view: no drift note when not stale or unknown", () => {
310
+ assert.equal(buildDashboardView(viewInputs({ ownVersion: "1.4.0", pathVersion: "1.4.0", stale: false })).installDriftNote, undefined);
311
+ assert.equal(buildDashboardView(viewInputs({ ownVersion: "1.4.0", pathVersion: null, stale: false })).installDriftNote, undefined);
312
+ assert.equal(buildDashboardView(viewInputs(null)).installDriftNote, undefined);
313
+ });
314
+ test("frame: drift note renders as a `note:` line", () => {
315
+ const v = buildDashboardView(viewInputs(STALE_DRIFT, "opencode"));
316
+ const lines = renderFrame(v, 160).join("\n");
317
+ assert.match(lines, /note: plugin updated — restart opencode to pick up v99\.0\.0/);
318
+ });
319
+ // ------------------------------------------------------------ resolveDaemonEntry
320
+ test("resolveDaemonEntry: own entry wins when it exists", () => {
321
+ assert.equal(resolveDaemonEntry(daemonMainPath, process.platform, { PATH: "" }), daemonMainPath);
322
+ });
323
+ test("resolveDaemonEntry: vanished own entry falls back to the PATH install", () => {
324
+ const dir = pathFixture("99.0.0");
325
+ try {
326
+ const nested = join(dir, "dist", "core");
327
+ mkdirSync(nested, { recursive: true });
328
+ const entry = join(nested, "daemon-main.js");
329
+ writeFileSync(entry, "// fixture daemon entry\n");
330
+ const own = join(dir, "does-not-exist", "daemon-main.js");
331
+ assert.ok(!existsSync(own));
332
+ assert.equal(resolveDaemonEntry(own, process.platform, { PATH: dir }), entry);
333
+ }
334
+ finally {
335
+ rmSync(dir, { recursive: true, force: true });
336
+ }
337
+ });
338
+ test("resolveDaemonEntry: vanished own entry with no usable PATH install → own unchanged", () => {
339
+ const empty = pathFixture(null);
340
+ try {
341
+ const own = join(empty, "gone", "daemon-main.js");
342
+ assert.equal(resolveDaemonEntry(own, process.platform, { PATH: empty }), own);
343
+ }
344
+ finally {
345
+ rmSync(empty, { recursive: true, force: true });
346
+ }
347
+ });
348
+ // --------------------------------------------------------------------- H2
349
+ test("ensureDaemon: unreadable daemon entry fails the probe, does not crash the host (issue #39 H2)", async () => {
350
+ const dir = mkdtempSync(join(TMP_BASE, "ll-h2-"));
351
+ const sock = join(dir, "daemon.sock");
352
+ // The child imports the COMPILED client and calls ensureDaemon with a
353
+ // daemon entry that cannot spawn (ENOENT). Without the spawn error
354
+ // listener, the 'error' event on the detached child is an uncaught
355
+ // exception and the process dies before it can report anything.
356
+ const script = join(dir, "child.mjs");
357
+ writeFileSync(script, `import { ensureDaemon } from ${JSON.stringify(new URL("../shared/client.js", import.meta.url).href)};\ntry {\n await ensureDaemon(${JSON.stringify(dir)}, ${JSON.stringify(sock)});\n console.log("unexpected: spawned");\n} catch (e) {\n console.log("code=" + (e && e.code) + " msg=" + (e && e.message));\n}\n`);
358
+ const child = spawn(process.execPath, ["--no-warnings", script], {
359
+ env: {
360
+ ...process.env,
361
+ LOAD_LOCK_DAEMON_BIN: "/nonexistent/load-lock-daemon-main.js",
362
+ LOAD_LOCK_STATE_DIR: dir,
363
+ LOAD_LOCK_SOCKET: sock,
364
+ },
365
+ stdio: ["ignore", "pipe", "pipe"],
366
+ });
367
+ let out = "";
368
+ child.stdout.on("data", (b) => (out += b));
369
+ let err = "";
370
+ child.stderr.on("data", (b) => (err += b));
371
+ const code = await new Promise((resolve) => {
372
+ const timer = setTimeout(() => {
373
+ child.kill("SIGKILL");
374
+ resolve(-1);
375
+ }, 30_000);
376
+ child.once("exit", (c) => {
377
+ clearTimeout(timer);
378
+ resolve(c);
379
+ });
380
+ });
381
+ try {
382
+ assert.equal(code, 0, `child must exit cleanly (stderr: ${err})`);
383
+ assert.match(out, /unavailable/);
384
+ assert.match(out, /did not come up/);
385
+ }
386
+ finally {
387
+ rmSync(dir, { recursive: true, force: true });
388
+ }
389
+ });
@@ -0,0 +1 @@
1
+ export {};