@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 +2 -2
- package/src/client.ts +7 -2
- package/src/protocol.ts +37 -0
- package/src/server.test.ts +18 -0
- package/src/server.ts +10 -4
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.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
|
+
"@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.
|
|
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
|
+
}
|
package/src/server.test.ts
CHANGED
|
@@ -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
|
|
127
|
-
|
|
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();
|