@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.82",
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.82",
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 - so it is treated as stale and replaced
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 and pid, learned from the ping handshake. */
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
- if (!options.autostart) {
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
- * Tear down a daemon from an earlier build so a fresh one can bind: ask it to
257
- * shut down (owner-gated) or, failing that, signal its pid, then wait for it to
258
- * release the socket. Best-effort - the next daemon reclaims stale socket
259
- * and lock files after it takes ownership.
260
- */
261
- private async stopStaleDaemon(path: string): Promise<void> {
262
- const pid = this.daemonPid;
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
- try {
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
- while (Date.now() < deadline && existsSync(path)) await sleep(50);
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 and pid, so connect() can replace a stale daemon.
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 ? { role: "owner" } : { role: "owner", token };
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({ home, idleExitMs: 0, version });
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();
@@ -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((await raw.call("daemon.hello", { role: "owner", token })).error).toBeUndefined();
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),