@cueloop/daemon 0.1.0-alpha.12 → 0.1.0-alpha.14

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.12",
3
+ "version": "0.1.0-alpha.14",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,7 +9,7 @@
9
9
  "./review": "./src/review.ts"
10
10
  },
11
11
  "dependencies": {
12
- "@cueloop/schema": "0.1.0-alpha.12",
12
+ "@cueloop/schema": "0.1.0-alpha.14",
13
13
  "valibot": "^1.4.2"
14
14
  },
15
15
  "publishConfig": {
package/src/client.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, rmSync } from "node:fs";
8
8
  import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
9
- import { LineBuffer, type EventFrame, type Response } from "./protocol";
9
+ import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
10
10
  import { cueloopHome, socketPath } from "./paths";
11
11
 
12
12
  export interface ConnectOptions {
@@ -19,6 +19,7 @@ type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void };
19
19
 
20
20
  export class DaemonClient {
21
21
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
22
+ private writer: BackpressureWriter | null = null;
22
23
  private pending = new Map<number, Pending>();
23
24
  private nextId = 1;
24
25
  private eventListeners = new Set<(e: EventFrame) => void>();
@@ -62,6 +63,9 @@ export class DaemonClient {
62
63
  data(_socket, data) {
63
64
  buffer.push(data.toString(), (line) => self.handleFrame(line));
64
65
  },
66
+ drain() {
67
+ self.writer?.drain();
68
+ },
65
69
  close() {
66
70
  self.closed = true;
67
71
  for (const p of self.pending.values()) p.reject(new Error("daemon connection closed"));
@@ -70,6 +74,7 @@ export class DaemonClient {
70
74
  error() {},
71
75
  },
72
76
  });
77
+ this.writer = new BackpressureWriter(this.socket);
73
78
  // Verify liveness: a dead socket file accepts connects on some platforms
74
79
  // only to fail later, so a ping is the actual handshake.
75
80
  await this.request("daemon.ping", {}, 2_000);
@@ -116,7 +121,7 @@ export class DaemonClient {
116
121
  reject(e);
117
122
  },
118
123
  });
119
- this.socket!.write(JSON.stringify({ id, method, params }) + "\n");
124
+ this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
120
125
  });
121
126
  }
122
127
 
package/src/protocol.ts CHANGED
@@ -38,3 +38,40 @@ export class LineBuffer {
38
38
  }
39
39
  }
40
40
  }
41
+
42
+ /** The surface both Bun.listen and Bun.connect sockets satisfy. */
43
+ interface WritableSocket {
44
+ write(data: Buffer): number;
45
+ }
46
+
47
+ /**
48
+ * Outbound writes honoring socket backpressure. A frame larger than the
49
+ * kernel buffer is written partially by the socket; the unwritten tail must
50
+ * wait for the drain callback or the frame truncates mid-line and the NDJSON
51
+ * stream corrupts for good. Frames never reorder: while a tail is pending,
52
+ * new writes append behind it.
53
+ */
54
+ export class BackpressureWriter {
55
+ private pending: Buffer | null = null;
56
+
57
+ constructor(private socket: WritableSocket) {}
58
+
59
+ write(data: string): void {
60
+ const bytes = Buffer.from(data, "utf8");
61
+ if (this.pending) {
62
+ this.pending = Buffer.concat([this.pending, bytes]);
63
+ return;
64
+ }
65
+ const written = Math.max(0, this.socket.write(bytes));
66
+ if (written < bytes.length) this.pending = bytes.subarray(written);
67
+ }
68
+
69
+ /** Call from the socket's drain handler. */
70
+ drain(): void {
71
+ if (!this.pending) return;
72
+ const tail = this.pending;
73
+ this.pending = null;
74
+ const written = Math.max(0, this.socket.write(tail));
75
+ if (written < tail.length) this.pending = tail.subarray(written);
76
+ }
77
+ }
@@ -112,3 +112,21 @@ describe("socket round-trip", () => {
112
112
  second.close();
113
113
  });
114
114
  });
115
+
116
+ describe("large payloads survive socket backpressure", () => {
117
+ test("a session bigger than the kernel socket buffer round-trips", async () => {
118
+ // Regression: responses larger than one socket write used to truncate
119
+ // mid-line because the write return value was ignored, so the client
120
+ // never saw the frame's newline and every later request wedged too.
121
+ const bigContent = "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
122
+ const created = await client.sessionCreate(
123
+ { repoRoot: "/repo", branch: "main" },
124
+ { type: "plan", content: bigContent, meta: { title: "big" } },
125
+ );
126
+ const fetched = await client.sessionGet(created.id);
127
+ expect(fetched.artifact.content).toBe(bigContent);
128
+ // the connection must still be usable for the next frame
129
+ const listed = await client.sessionList();
130
+ expect(listed.some((session) => session.id === created.id)).toBe(true);
131
+ }, 15_000);
132
+ });
package/src/server.ts CHANGED
@@ -8,7 +8,7 @@ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rm
8
8
  import { DaemonCore, type DaemonEvent } from "./api";
9
9
  import { DaemonError } from "./errors";
10
10
  import { isKnownMethod, parseParams } from "./validate";
11
- import { LineBuffer, type Request } from "./protocol";
11
+ import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
12
12
  import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
13
13
 
14
14
  interface Conn {
@@ -119,12 +119,13 @@ export class DaemonServer {
119
119
  // socket file left behind is stale
120
120
  if (existsSync(path)) rmSync(path, { force: true });
121
121
  const self = this;
122
- this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn }>({
122
+ this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn; writer: BackpressureWriter }>({
123
123
  unix: path,
124
124
  socket: {
125
125
  open(socket) {
126
- const conn: Conn = { write: (d) => socket.write(d), subscribed: false };
127
- socket.data = { buffer: new LineBuffer(), conn };
126
+ const writer = new BackpressureWriter(socket);
127
+ const conn: Conn = { write: (data) => writer.write(data), subscribed: false };
128
+ socket.data = { buffer: new LineBuffer(), conn, writer };
128
129
  self.conns.add(conn);
129
130
  self.scheduleIdleCheck();
130
131
  },
@@ -133,6 +134,11 @@ export class DaemonServer {
133
134
  void self.handleLine(socket.data.conn, line);
134
135
  });
135
136
  },
137
+ drain(socket) {
138
+ // drain can fire before open has attached data on some platforms;
139
+ // a throw here would take the whole daemon down with the socket
140
+ socket.data?.writer.drain();
141
+ },
136
142
  close(socket) {
137
143
  self.conns.delete(socket.data.conn);
138
144
  self.scheduleIdleCheck();