@cueloop/daemon 0.1.0-alpha.82 → 0.1.0-alpha.83
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/package.json +2 -2
- package/src/client.ts +22 -48
- package/src/daemon-version.test.ts +51 -2
- package/src/server.test.ts +22 -1
- package/src/server.ts +7 -0
- package/src/validate.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.83",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
41
|
+
"@cueloop/schema": "0.1.0-alpha.83",
|
|
42
42
|
"@pierre/diffs": "^1.4.1",
|
|
43
43
|
"effect": "^4.0.0-rc.115",
|
|
44
44
|
"smol-toml": "^1.8.0",
|
package/src/client.ts
CHANGED
|
@@ -77,7 +77,7 @@ type PendingRequest = {
|
|
|
77
77
|
|
|
78
78
|
const EmptyResultSchema = v.object({});
|
|
79
79
|
// version is optional: a daemon from before the handshake carried one reads as
|
|
80
|
-
// undefined, which never equals this build
|
|
80
|
+
// undefined, which never equals this build.
|
|
81
81
|
const PingResultSchema = v.object({ pid: v.number(), version: v.optional(v.string()) });
|
|
82
82
|
const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
|
|
83
83
|
const HerdrThreadSurfaceResultSchema = v.nullable(
|
|
@@ -180,6 +180,7 @@ export interface ThreadClient {
|
|
|
180
180
|
/** What a connection says about itself: its role, the owner token when it claims ownership, the author it acts as otherwise. */
|
|
181
181
|
interface HelloParams {
|
|
182
182
|
role: DaemonRole;
|
|
183
|
+
clientVersion: string;
|
|
183
184
|
token?: string;
|
|
184
185
|
author?: string;
|
|
185
186
|
}
|
|
@@ -209,9 +210,8 @@ export class DaemonClient implements ThreadClient {
|
|
|
209
210
|
private writer: BackpressureWriter | null = null;
|
|
210
211
|
private connectionEpoch = 0;
|
|
211
212
|
private pending = new Map<number, PendingRequest>();
|
|
212
|
-
/** The connected daemon's build version
|
|
213
|
+
/** The connected daemon's build version, learned from the ping handshake. */
|
|
213
214
|
private daemonVersion: string | undefined;
|
|
214
|
-
private daemonPid: number | undefined;
|
|
215
215
|
private nextId = 1;
|
|
216
216
|
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
217
217
|
private disconnectListeners = new Set<() => void>();
|
|
@@ -230,52 +230,25 @@ export class DaemonClient implements ThreadClient {
|
|
|
230
230
|
client.home = home;
|
|
231
231
|
try {
|
|
232
232
|
await client.dial(path);
|
|
233
|
-
// A daemon from an earlier build lingers after an upgrade; talking to it
|
|
234
|
-
// means new client, old behaviour. The owner replaces it so an upgrade
|
|
235
|
-
// never needs a manual restart; without autostart there is nothing to
|
|
236
|
-
// replace it with, so the caller hears exactly why.
|
|
237
233
|
if (client.daemonVersion === DAEMON_VERSION) return client;
|
|
238
|
-
|
|
239
|
-
client.close();
|
|
240
|
-
throw new DaemonClientError(
|
|
241
|
-
"version_mismatch",
|
|
242
|
-
`daemon is version ${client.daemonVersion ?? "unknown"}, but this client is ${DAEMON_VERSION}; restart the daemon`,
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
await client.stopStaleDaemon(path);
|
|
246
|
-
} catch (err) {
|
|
247
|
-
// a live daemon that refused the handshake is not a dead socket: the
|
|
248
|
-
// caller hears why instead of the client replacing a running daemon
|
|
249
|
-
if (!options.autostart || err instanceof DaemonClientError) throw err;
|
|
250
|
-
}
|
|
251
|
-
// Socket dead, absent, or just-replaced: let the new daemon own socket cleanup.
|
|
252
|
-
return client.attachFreshDaemon(home, path);
|
|
253
|
-
}
|
|
234
|
+
const daemonVersion = client.daemonVersion ?? "unknown";
|
|
254
235
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
236
|
+
client.close();
|
|
237
|
+
throw new DaemonClientError(
|
|
238
|
+
"version_mismatch",
|
|
239
|
+
`daemon is version ${daemonVersion}, but this client is ${DAEMON_VERSION}; align cueloop versions before restarting the daemon`,
|
|
240
|
+
);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
// Autostart repairs an unavailable socket, never a live incompatible daemon.
|
|
243
|
+
if (!options.autostart || err instanceof DaemonClientError) {
|
|
244
|
+
client.close();
|
|
263
245
|
|
|
264
|
-
|
|
265
|
-
await this.request("daemon.shutdown", {}, EmptyResultSchema, 2_000);
|
|
266
|
-
} catch {
|
|
267
|
-
if (pid !== undefined) {
|
|
268
|
-
try {
|
|
269
|
-
process.kill(pid);
|
|
270
|
-
} catch {}
|
|
246
|
+
throw err;
|
|
271
247
|
}
|
|
272
248
|
}
|
|
273
|
-
this.socket?.end();
|
|
274
|
-
this.resetConnection();
|
|
275
|
-
// the old daemon removes its socket in stop(); wait so the new one binds cleanly
|
|
276
|
-
const deadline = Date.now() + 5_000;
|
|
277
249
|
|
|
278
|
-
|
|
250
|
+
// Socket dead or absent: let the new daemon own stale socket cleanup.
|
|
251
|
+
return client.attachFreshDaemon(home, path);
|
|
279
252
|
}
|
|
280
253
|
|
|
281
254
|
/** Reset per-connection state so a fresh dial() can reuse this client instance. */
|
|
@@ -383,11 +356,10 @@ export class DaemonClient implements ThreadClient {
|
|
|
383
356
|
}
|
|
384
357
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
385
358
|
// only to fail later, so a ping is the actual handshake. It also carries the
|
|
386
|
-
// daemon's build version
|
|
359
|
+
// daemon's build version, so connect() can reject incompatible clients.
|
|
387
360
|
const pong = await this.request("daemon.ping", {}, PingResultSchema, 2_000);
|
|
388
361
|
|
|
389
362
|
this.daemonVersion = pong.version;
|
|
390
|
-
this.daemonPid = pong.pid;
|
|
391
363
|
// Every connection starts as a collaborator; the owner proves itself with
|
|
392
364
|
// the token the daemon wrote into the home it serves, which only the home's
|
|
393
365
|
// user can read. A capped role just names itself.
|
|
@@ -397,13 +369,15 @@ export class DaemonClient implements ThreadClient {
|
|
|
397
369
|
private helloParams(): HelloParams {
|
|
398
370
|
if (this.role !== "owner") {
|
|
399
371
|
return this.author === undefined
|
|
400
|
-
? { role: this.role }
|
|
401
|
-
: { role: this.role, author: this.author };
|
|
372
|
+
? { role: this.role, clientVersion: DAEMON_VERSION }
|
|
373
|
+
: { role: this.role, clientVersion: DAEMON_VERSION, author: this.author };
|
|
402
374
|
}
|
|
403
375
|
const token = readOwnerToken(this.home);
|
|
404
376
|
|
|
405
377
|
// a daemon from before owner tokens has no file; it still knows the bare hello
|
|
406
|
-
return token === undefined
|
|
378
|
+
return token === undefined
|
|
379
|
+
? { role: "owner", clientVersion: DAEMON_VERSION }
|
|
380
|
+
: { role: "owner", clientVersion: DAEMON_VERSION, token };
|
|
407
381
|
}
|
|
408
382
|
|
|
409
383
|
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { DaemonServer } from "./server";
|
|
13
13
|
import { DaemonClient, DaemonClientError, daemonSpawnCommand } from "./client";
|
|
14
14
|
import { DAEMON_VERSION } from "./version";
|
|
15
|
+
import { pidPath } from "./paths";
|
|
15
16
|
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
16
17
|
|
|
17
18
|
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
@@ -29,7 +30,12 @@ afterEach(() => {
|
|
|
29
30
|
});
|
|
30
31
|
|
|
31
32
|
function server(version?: string): DaemonServer {
|
|
32
|
-
const daemonServer = new DaemonServer({
|
|
33
|
+
const daemonServer = new DaemonServer({
|
|
34
|
+
home,
|
|
35
|
+
idleExitMs: 0,
|
|
36
|
+
version,
|
|
37
|
+
onIdleExit: () => {},
|
|
38
|
+
});
|
|
33
39
|
|
|
34
40
|
servers.push(daemonServer);
|
|
35
41
|
|
|
@@ -50,6 +56,49 @@ describe("daemon version handshake", () => {
|
|
|
50
56
|
);
|
|
51
57
|
});
|
|
52
58
|
|
|
59
|
+
test("autostart never replaces a live daemon from another version", async () => {
|
|
60
|
+
const liveServer = server("0.1.0-alpha.81");
|
|
61
|
+
|
|
62
|
+
liveServer.start();
|
|
63
|
+
const session = liveServer.core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
64
|
+
let replacement: DaemonClient | undefined;
|
|
65
|
+
let connectionError: unknown;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
replacement = await DaemonClient.connect({ home, autostart: true });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
connectionError = error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
replacement?.close();
|
|
74
|
+
expect(connectionError).toBeInstanceOf(DaemonClientError);
|
|
75
|
+
expect(connectionError).toMatchObject({ code: "version_mismatch" });
|
|
76
|
+
expect(readFileSync(pidPath(home), "utf8")).toBe(String(process.pid));
|
|
77
|
+
expect(liveServer.core.sessionGet(session.id).id).toBe(session.id);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a rejected client releases its connection so the daemon can become idle", async () => {
|
|
81
|
+
let idleExited = false;
|
|
82
|
+
const liveServer = new DaemonServer({
|
|
83
|
+
home,
|
|
84
|
+
idleExitMs: 50,
|
|
85
|
+
version: "0.1.0-alpha.81",
|
|
86
|
+
onIdleExit: () => {
|
|
87
|
+
idleExited = true;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
servers.push(liveServer);
|
|
92
|
+
liveServer.start();
|
|
93
|
+
|
|
94
|
+
await expect(DaemonClient.connect({ home, autostart: true })).rejects.toMatchObject({
|
|
95
|
+
code: "version_mismatch",
|
|
96
|
+
});
|
|
97
|
+
await Bun.sleep(100);
|
|
98
|
+
|
|
99
|
+
expect(idleExited).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
53
102
|
test("a current daemon connects and serves normally", async () => {
|
|
54
103
|
// Arrange - a daemon on this build
|
|
55
104
|
server(DAEMON_VERSION).start();
|
package/src/server.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import * as v from "valibot";
|
|
6
6
|
import { DaemonServer } from "./server";
|
|
7
7
|
import { DaemonClient, DaemonClientError } from "./client";
|
|
8
|
+
import { DAEMON_VERSION } from "./version";
|
|
8
9
|
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
9
10
|
|
|
10
11
|
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
@@ -413,7 +414,15 @@ describe("ownership is proven, never declared", () => {
|
|
|
413
414
|
// Act: the token the daemon wrote into its home
|
|
414
415
|
const token = readFileSync(join(home, "owner.token"), "utf8").trim();
|
|
415
416
|
|
|
416
|
-
expect(
|
|
417
|
+
expect(
|
|
418
|
+
(
|
|
419
|
+
await raw.call("daemon.hello", {
|
|
420
|
+
role: "owner",
|
|
421
|
+
token,
|
|
422
|
+
clientVersion: DAEMON_VERSION,
|
|
423
|
+
})
|
|
424
|
+
).error,
|
|
425
|
+
).toBeUndefined();
|
|
417
426
|
|
|
418
427
|
// Assert: owner primitives open up
|
|
419
428
|
const created = await raw.call("session.create", { workspace: WS, artifact: PLAN });
|
|
@@ -422,6 +431,18 @@ describe("ownership is proven, never declared", () => {
|
|
|
422
431
|
raw.close();
|
|
423
432
|
});
|
|
424
433
|
|
|
434
|
+
test("an older owner cannot shut down a newer daemon", async () => {
|
|
435
|
+
const raw = await rawConnection();
|
|
436
|
+
const token = readFileSync(join(home, "owner.token"), "utf8").trim();
|
|
437
|
+
|
|
438
|
+
expect((await raw.call("daemon.hello", { role: "owner", token })).error?.code).toBe(
|
|
439
|
+
"version_mismatch",
|
|
440
|
+
);
|
|
441
|
+
expect((await raw.call("daemon.shutdown", {})).error?.code).toBe("forbidden");
|
|
442
|
+
expect((await raw.call("daemon.ping", {})).error).toBeUndefined();
|
|
443
|
+
raw.close();
|
|
444
|
+
});
|
|
445
|
+
|
|
425
446
|
test("a refused handshake reaches the caller and leaves the live daemon's socket alone", async () => {
|
|
426
447
|
// Arrange: a token file that is not what the daemon minted
|
|
427
448
|
const tokenPath = join(home, "owner.token");
|
package/src/server.ts
CHANGED
|
@@ -297,6 +297,13 @@ export class DaemonServer {
|
|
|
297
297
|
if (params.role === "owner" && params.token !== this.ownerToken) {
|
|
298
298
|
throw new DaemonError("forbidden", "owner token required");
|
|
299
299
|
}
|
|
300
|
+
if (params.clientVersion !== this.version) {
|
|
301
|
+
throw new DaemonError(
|
|
302
|
+
"version_mismatch",
|
|
303
|
+
`daemon is version ${this.version}, but this client is ${params.clientVersion ?? "unknown"}; align cueloop versions before restarting the daemon`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
300
307
|
connection.role = params.role;
|
|
301
308
|
// identity is bound once, here; a non-owner never names it per call
|
|
302
309
|
if (params.role !== "owner" && params.author !== undefined) connection.author = params.author;
|
package/src/validate.ts
CHANGED
|
@@ -282,6 +282,7 @@ export const Params = {
|
|
|
282
282
|
// the owner token proves ownership; without it a request for owner stays a collaborator
|
|
283
283
|
"daemon.hello": v.object({
|
|
284
284
|
role: v.picklist(["owner", "collaborator", "agent"]),
|
|
285
|
+
clientVersion: v.optional(v.string()),
|
|
285
286
|
token: v.optional(v.string()),
|
|
286
287
|
// the author a non-owner acts as, bound once for the connection
|
|
287
288
|
author: v.optional(NonEmpty),
|