@rynx-ai/runtime 0.1.11-beta.32 → 0.1.11-beta.34

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.
@@ -72,11 +72,16 @@ class ManagedTerminal {
72
72
  sendMsg;
73
73
  onClose;
74
74
  ready;
75
- dataListener;
76
- exitListener;
75
+ readerDone;
76
+ resizeListener;
77
+ dimensions;
78
+ role = "read-only";
79
+ seedBytes = 0;
77
80
  resolveReady;
78
81
  rejectReady;
82
+ resolveReaderDone;
79
83
  state = "opening";
84
+ pending = new Map();
80
85
  constructor(attachId, sendMsg, onClose) {
81
86
  this.attachId = attachId;
82
87
  this.sendMsg = sendMsg;
@@ -85,20 +90,75 @@ class ManagedTerminal {
85
90
  this.resolveReady = resolve;
86
91
  this.rejectReady = reject;
87
92
  });
93
+ this.readerDone = new Promise((resolve) => {
94
+ this.resolveReaderDone = resolve;
95
+ });
88
96
  // Never surface an unhandled rejection if no caller awaits `ready`.
89
97
  void this.ready.catch(() => undefined);
90
98
  }
91
- onData(listener) {
92
- this.dataListener = listener;
99
+ onResize(listener) {
100
+ this.resizeListener = listener;
101
+ if (this.dimensions)
102
+ queueMicrotask(() => listener(this.dimensions));
103
+ }
104
+ readSeed(offset, maxBytes) {
105
+ if (this.state !== "prepared")
106
+ return Promise.reject(new Error("terminal is not prepared"));
107
+ return this.requestRead("seed", {
108
+ t: "term.seed.read",
109
+ attachId: this.attachId,
110
+ reqId: randomUUID(),
111
+ offset,
112
+ maxBytes,
113
+ });
114
+ }
115
+ start() {
116
+ if (this.state !== "prepared")
117
+ return Promise.reject(new Error("terminal is not prepared"));
118
+ this.state = "starting";
119
+ const reqId = randomUUID();
120
+ return this.request("start", { t: "term.start", attachId: this.attachId, reqId })
121
+ .then(() => {
122
+ if (this.state === "starting")
123
+ this.state = "started";
124
+ });
93
125
  }
94
- onExit(listener) {
95
- this.exitListener = listener;
126
+ read(offset, maxBytes) {
127
+ if (this.state !== "started")
128
+ return Promise.reject(new Error("terminal has not started"));
129
+ return this.requestRead("read", {
130
+ t: "term.read",
131
+ attachId: this.attachId,
132
+ reqId: randomUUID(),
133
+ offset,
134
+ maxBytes,
135
+ });
96
136
  }
97
137
  write(data) {
98
- this.sendMsg({ t: "term.input", attachId: this.attachId, dataB64: Buffer.from(data, "utf8").toString("base64") });
138
+ if (this.state !== "started" || this.role !== "owner")
139
+ return Promise.resolve();
140
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
141
+ if (bytes.byteLength === 0)
142
+ return Promise.resolve();
143
+ const reqId = randomUUID();
144
+ return this.request("input", {
145
+ t: "term.input",
146
+ attachId: this.attachId,
147
+ reqId,
148
+ dataB64: bytes.toString("base64"),
149
+ }).then(() => undefined);
99
150
  }
100
151
  resize(cols, rows) {
101
- this.sendMsg({ t: "term.resize", attachId: this.attachId, cols, rows });
152
+ if (this.state !== "started")
153
+ return Promise.resolve();
154
+ const reqId = randomUUID();
155
+ return this.request("resize", {
156
+ t: "term.resize",
157
+ attachId: this.attachId,
158
+ reqId,
159
+ cols,
160
+ rows,
161
+ }).then(() => undefined);
102
162
  }
103
163
  close() {
104
164
  if (this.state === "closed")
@@ -107,43 +167,96 @@ class ManagedTerminal {
107
167
  this.state = "closed";
108
168
  this.sendMsg({ t: "term.close", attachId: this.attachId });
109
169
  this.onClose();
170
+ this.rejectPending(new Error("terminal attachment was closed"));
110
171
  if (wasOpening) {
111
172
  this.rejectReady(new TerminalOpenError("terminal attachment was closed while opening", "terminal_open_failed"));
112
173
  }
174
+ this.resolveReaderDone({ finalOffset: 0, reason: "client_closed", exitCode: 0 });
113
175
  }
114
176
  // ── internal (driven by onChildMessage) ──
115
- _opened(role) {
177
+ _prepared(role, seedBytes, cols, rows) {
116
178
  if (this.state !== "opening")
117
179
  return;
118
- this.state = "opened";
119
- this.resolveReady({ role });
120
- }
121
- _data(dataB64) {
122
- this.dataListener?.(Buffer.from(dataB64, "base64").toString("utf8"));
180
+ this.state = "prepared";
181
+ this.role = role;
182
+ this.seedBytes = seedBytes;
183
+ this.dimensions = cols !== undefined && rows !== undefined ? { cols, rows } : undefined;
184
+ this.resolveReady({
185
+ role,
186
+ seedBytes,
187
+ ...(this.dimensions ? { dimensions: this.dimensions } : {}),
188
+ });
123
189
  }
124
- _exit(exitCode) {
190
+ _dimensions(cols, rows) {
125
191
  if (this.state === "closed")
126
192
  return;
127
- const wasOpening = this.state === "opening";
128
- this.state = "closed";
129
- if (wasOpening) {
130
- this.rejectReady(new TerminalOpenError(`terminal exited before opening (code=${exitCode})`, "terminal_open_failed"));
131
- }
132
- else {
133
- this.exitListener?.({ exitCode });
134
- }
193
+ this.dimensions = { cols, rows };
194
+ this.resizeListener?.(this.dimensions);
195
+ }
196
+ _started(reqId) {
197
+ this.resolvePending(reqId, "start", undefined);
198
+ }
199
+ _chunk(reqId, operation, dataB64, nextOffset, done, finalOffset) {
200
+ this.resolvePending(reqId, operation, {
201
+ data: Uint8Array.from(Buffer.from(dataB64, "base64")),
202
+ nextOffset,
203
+ done,
204
+ ...(finalOffset === undefined ? {} : { finalOffset }),
205
+ });
206
+ }
207
+ _ack(reqId, operation) {
208
+ this.resolvePending(reqId, operation, undefined);
135
209
  }
136
- _fail(message, code = "terminal_open_failed") {
210
+ _readerDone(info) {
211
+ this.resolveReaderDone(info);
212
+ }
213
+ _fail(message, code = "terminal_open_failed", reqId) {
137
214
  if (this.state === "closed")
138
215
  return;
216
+ if (reqId) {
217
+ const pending = this.pending.get(reqId);
218
+ if (pending) {
219
+ this.pending.delete(reqId);
220
+ pending.reject(new TerminalOpenError(message, code));
221
+ return;
222
+ }
223
+ }
139
224
  const wasOpening = this.state === "opening";
140
225
  this.state = "closed";
226
+ this.rejectPending(new TerminalOpenError(message, code));
141
227
  if (wasOpening) {
142
228
  this.rejectReady(new TerminalOpenError(message, code));
143
229
  }
144
- else {
145
- this.exitListener?.({ exitCode: 1 });
230
+ this.resolveReaderDone({ finalOffset: 0, reason: "internal", exitCode: 1 });
231
+ }
232
+ requestRead(operation, message) {
233
+ return this.request(operation, message);
234
+ }
235
+ request(operation, message) {
236
+ return new Promise((resolve, reject) => {
237
+ this.pending.set(message.reqId, { operation, resolve, reject });
238
+ try {
239
+ this.sendMsg(message);
240
+ }
241
+ catch (error) {
242
+ this.pending.delete(message.reqId);
243
+ reject(error instanceof Error ? error : new Error(String(error)));
244
+ }
245
+ });
246
+ }
247
+ resolvePending(reqId, operation, value) {
248
+ const pending = this.pending.get(reqId);
249
+ if (!pending || pending.operation !== operation) {
250
+ this._fail(`terminal response ${reqId} is duplicate or out of order`);
251
+ return;
146
252
  }
253
+ this.pending.delete(reqId);
254
+ pending.resolve(value);
255
+ }
256
+ rejectPending(error) {
257
+ for (const pending of this.pending.values())
258
+ pending.reject(error);
259
+ this.pending.clear();
147
260
  }
148
261
  }
149
262
  export class RunnerManager {
@@ -227,9 +340,12 @@ export class RunnerManager {
227
340
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
228
341
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
229
342
  this.signalChild = opts.signalChild ?? signalRunnerChild;
230
- this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
231
- this.terminalWindowActivityAt = opts.terminalWindowActivityAt ?? tmuxWindowActivityAt;
232
- this.terminalHasAttachedClient = opts.terminalHasAttachedClient ?? tmuxHasAttachedClient;
343
+ this.terminateTerminalServer = opts.terminateTerminalServer ??
344
+ ((name, ownerPid) => terminateTmuxServer(name, undefined, ownerPid));
345
+ this.terminalWindowActivityAt = opts.terminalWindowActivityAt ??
346
+ ((name, ownerPid) => tmuxWindowActivityAt(name, undefined, ownerPid));
347
+ this.terminalHasAttachedClient = opts.terminalHasAttachedClient ??
348
+ ((name, ownerPid) => tmuxHasAttachedClient(name, undefined, ownerPid));
233
349
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
234
350
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
235
351
  this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
@@ -248,9 +364,8 @@ export class RunnerManager {
248
364
  /**
249
365
  * Open a live terminal on the session's runner child (spawning it if needed).
250
366
  * Phase C hosts one terminal ("main") per session; the returned handle is a
251
- * single attach client — `owner` (read-write) or a downgraded `read-only`
252
- * viewer per the child's ownership rule. Not part of `AgentExecutor`; the WS
253
- * bridge calls it directly.
367
+ * single attach client with the requested `owner` (read-write) or `read-only`
368
+ * role. Not part of `AgentExecutor`; the WS bridge calls it directly.
254
369
  */
255
370
  openTerminal(localThreadId, opts) {
256
371
  const handle = this.getOrSpawn(localThreadId);
@@ -1279,8 +1394,14 @@ export class RunnerManager {
1279
1394
  }
1280
1395
  });
1281
1396
  }
1282
- child.on("error", (error) => this.failHandle(handle, error.message));
1283
- child.on("exit", (code, signal) => this.failHandle(handle, `runner exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`));
1397
+ const failAndReap = (reason) => {
1398
+ this.failHandle(handle, reason);
1399
+ void this.terminateHandle(handle, reason).catch((error) => {
1400
+ logTerminationFailure(handle, error);
1401
+ });
1402
+ };
1403
+ child.on("error", (error) => failAndReap(error.message));
1404
+ child.on("exit", (code, signal) => failAndReap(`runner exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`));
1284
1405
  return handle;
1285
1406
  }
1286
1407
  onChildMessage(handle, msg) {
@@ -1301,22 +1422,36 @@ export class RunnerManager {
1301
1422
  }
1302
1423
  return;
1303
1424
  }
1304
- case "term.opened":
1305
- handle.terminals.get(msg.attachId)?._opened(msg.role);
1425
+ case "term.prepared":
1426
+ handle.terminals.get(msg.attachId)?._prepared(msg.role, msg.seedBytes, msg.cols, msg.rows);
1306
1427
  return;
1307
- case "term.data":
1308
- handle.terminals.get(msg.attachId)?._data(msg.dataB64);
1428
+ case "term.seed.chunk":
1429
+ handle.terminals.get(msg.attachId)?._chunk(msg.reqId, "seed", msg.dataB64, msg.nextOffset, msg.done, msg.finalOffset);
1309
1430
  return;
1310
- case "term.exit": {
1311
- const terminal = handle.terminals.get(msg.attachId);
1312
- handle.terminals.delete(msg.attachId);
1313
- terminal?._exit(msg.exitCode);
1431
+ case "term.started":
1432
+ handle.terminals.get(msg.attachId)?._started(msg.reqId);
1433
+ return;
1434
+ case "term.chunk":
1435
+ handle.terminals.get(msg.attachId)?._chunk(msg.reqId, "read", msg.dataB64, msg.nextOffset, msg.done, msg.finalOffset);
1436
+ return;
1437
+ case "term.dimensions":
1438
+ handle.terminals.get(msg.attachId)?._dimensions(msg.cols, msg.rows);
1439
+ return;
1440
+ case "term.reader.done":
1441
+ handle.terminals.get(msg.attachId)?._readerDone({
1442
+ finalOffset: msg.finalOffset,
1443
+ reason: msg.reason,
1444
+ exitCode: msg.exitCode,
1445
+ });
1446
+ return;
1447
+ case "term.ack":
1448
+ handle.terminals.get(msg.attachId)?._ack(msg.reqId, msg.operation);
1314
1449
  return;
1315
- }
1316
1450
  case "term.error": {
1317
1451
  const terminal = handle.terminals.get(msg.attachId);
1318
- handle.terminals.delete(msg.attachId);
1319
- terminal?._fail(msg.message, msg.code);
1452
+ if (!msg.reqId)
1453
+ handle.terminals.delete(msg.attachId);
1454
+ terminal?._fail(msg.message, msg.code, msg.reqId);
1320
1455
  return;
1321
1456
  }
1322
1457
  case "mirror":
@@ -1409,6 +1544,18 @@ export class RunnerManager {
1409
1544
  this.deliverRotateMessage(handle, msg);
1410
1545
  return;
1411
1546
  }
1547
+ case "terminal.lifecycle.ended": {
1548
+ if (msg.lifecycle === "required") {
1549
+ void this.terminateHandle(handle, `required Terminal exited with status ${msg.status}`).catch((error) => logTerminationFailure(handle, error));
1550
+ }
1551
+ return;
1552
+ }
1553
+ case "terminal.reaped": {
1554
+ const resolve = handle.live.get(msg.reqId);
1555
+ handle.live.delete(msg.reqId);
1556
+ resolve?.({ ok: msg.ok, error: msg.error });
1557
+ return;
1558
+ }
1412
1559
  case "live.ready":
1413
1560
  case "interrupted":
1414
1561
  case "injected": {
@@ -1499,7 +1646,7 @@ export class RunnerManager {
1499
1646
  childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1500
1647
  }
1501
1648
  if (handle.key !== CAP_KEY) {
1502
- await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch((error) => {
1649
+ await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`, handle.child.pid)).catch((error) => {
1503
1650
  console.warn(`[runner-manager] best-effort terminal close failed for ${handle.key}-main: ${error instanceof Error ? error.message : String(error)}`);
1504
1651
  });
1505
1652
  }
@@ -1584,7 +1731,40 @@ export class RunnerManager {
1584
1731
  // A failed teardown must re-arm on the next scan instead of retrying on
1585
1732
  // every sweep forever.
1586
1733
  delete handle.nativePaneLastBusyAt;
1587
- await this.terminateHandle(handle, "idle native pane reaped").catch((error) => logTerminationFailure(handle, error));
1734
+ await this.reapNativeTerminal(handle).catch((error) => {
1735
+ logTerminationFailure(handle, error);
1736
+ });
1737
+ }
1738
+ reapNativeTerminal(handle) {
1739
+ const localThreadId = this.currentTerminalSessionId(handle, handle.key);
1740
+ const terminalId = `${localThreadId}-main`;
1741
+ const reqId = randomUUID();
1742
+ return new Promise((resolve, reject) => {
1743
+ const timeout = setTimeout(() => {
1744
+ if (!handle.live.delete(reqId))
1745
+ return;
1746
+ reject(new Error(`runner did not acknowledge idle terminal reap within ${this.liveReadyTimeoutMs}ms`));
1747
+ }, this.liveReadyTimeoutMs);
1748
+ timeout.unref?.();
1749
+ handle.live.set(reqId, (reply) => {
1750
+ clearTimeout(timeout);
1751
+ if (reply.ok) {
1752
+ resolve();
1753
+ return;
1754
+ }
1755
+ reject(fromWireError(reply.error ?? {
1756
+ message: "idle terminal reap failed",
1757
+ code: "terminal_reap_failed",
1758
+ statusCode: 500,
1759
+ }));
1760
+ });
1761
+ handle.transport.send({
1762
+ t: "terminal.reap",
1763
+ reqId,
1764
+ localThreadId,
1765
+ terminalId,
1766
+ });
1767
+ });
1588
1768
  }
1589
1769
  async isNativePaneBusy(handle) {
1590
1770
  if (handle.activeResponseIds.size > 0 ||
@@ -1595,7 +1775,7 @@ export class RunnerManager {
1595
1775
  }
1596
1776
  const terminalName = `${handle.key}-main`;
1597
1777
  try {
1598
- if (await this.terminalHasAttachedClient(terminalName))
1778
+ if (await this.terminalHasAttachedClient(terminalName, handle.child.pid))
1599
1779
  return true;
1600
1780
  }
1601
1781
  catch {
@@ -1603,7 +1783,7 @@ export class RunnerManager {
1603
1783
  }
1604
1784
  let activityAt = null;
1605
1785
  try {
1606
- activityAt = await this.terminalWindowActivityAt(terminalName);
1786
+ activityAt = await this.terminalWindowActivityAt(terminalName, handle.child.pid);
1607
1787
  }
1608
1788
  catch {
1609
1789
  // A failed probe contributes no busy evidence.
@@ -23,7 +23,7 @@ export type TerminalRole = "owner" | "read-only";
23
23
  /** Stable reason for a terminal attach failure. `terminal_not_live` is an
24
24
  * expected, retryable absence; `terminal_open_failed` is an infrastructure error. */
25
25
  export type TerminalOpenErrorCode = "terminal_not_live" | "terminal_open_failed";
26
- /** Parent → child. Terminal PTY bytes ride `term.input` base64-encoded so the
26
+ /** Parent → child. Raw terminal bytes ride `term.input` base64-encoded so the
27
27
  * NDJSON line framing stays intact (raw bytes contain newlines). */
28
28
  export type ToChild = {
29
29
  t: "mirror.image.ack";
@@ -51,13 +51,31 @@ export type ToChild = {
51
51
  rows: number;
52
52
  command?: string;
53
53
  args?: string[];
54
+ } | {
55
+ t: "term.seed.read";
56
+ attachId: string;
57
+ reqId: string;
58
+ offset: number;
59
+ maxBytes: number;
60
+ } | {
61
+ t: "term.start";
62
+ attachId: string;
63
+ reqId: string;
64
+ } | {
65
+ t: "term.read";
66
+ attachId: string;
67
+ reqId: string;
68
+ offset: number;
69
+ maxBytes: number;
54
70
  } | {
55
71
  t: "term.input";
56
72
  attachId: string;
73
+ reqId: string;
57
74
  dataB64: string;
58
75
  } | {
59
76
  t: "term.resize";
60
77
  attachId: string;
78
+ reqId: string;
61
79
  cols: number;
62
80
  rows: number;
63
81
  } | {
@@ -119,10 +137,18 @@ export type ToChild = {
119
137
  t: "live.interrupt";
120
138
  reqId: string;
121
139
  localThreadId: string;
140
+ }
141
+ /** Pane-scoped idle cleanup. The child removes the terminal resource and
142
+ * performs the lifecycle adapter's explicit cleanup before acknowledging. */
143
+ | {
144
+ t: "terminal.reap";
145
+ reqId: string;
146
+ localThreadId: string;
147
+ terminalId: string;
122
148
  } | {
123
149
  t: "shutdown";
124
150
  };
125
- /** Child → parent. `term.data` carries base64 PTY output (line-safe NDJSON). */
151
+ /** Child → parent. `term.data` carries raw pane output as base64 (line-safe NDJSON). */
126
152
  export type FromChild = {
127
153
  t: "ready";
128
154
  } | {
@@ -136,20 +162,52 @@ export type FromChild = {
136
162
  ok: false;
137
163
  error: WireError;
138
164
  } | {
139
- t: "term.opened";
165
+ t: "term.prepared";
140
166
  attachId: string;
141
167
  role: TerminalRole;
168
+ seedBytes: number;
169
+ cols?: number;
170
+ rows?: number;
171
+ } | {
172
+ t: "term.seed.chunk";
173
+ attachId: string;
174
+ reqId: string;
175
+ dataB64: string;
176
+ nextOffset: number;
177
+ done: boolean;
178
+ finalOffset?: number;
179
+ } | {
180
+ t: "term.started";
181
+ attachId: string;
182
+ reqId: string;
142
183
  } | {
143
- t: "term.data";
184
+ t: "term.chunk";
144
185
  attachId: string;
186
+ reqId: string;
145
187
  dataB64: string;
188
+ nextOffset: number;
189
+ done: boolean;
190
+ finalOffset?: number;
146
191
  } | {
147
- t: "term.exit";
192
+ t: "term.reader.done";
148
193
  attachId: string;
194
+ finalOffset: number;
195
+ reason: "terminal_exited" | "client_closed" | "backpressure" | "internal";
149
196
  exitCode: number;
197
+ } | {
198
+ t: "term.ack";
199
+ attachId: string;
200
+ reqId: string;
201
+ operation: "input" | "resize";
202
+ } | {
203
+ t: "term.dimensions";
204
+ attachId: string;
205
+ cols: number;
206
+ rows: number;
150
207
  } | {
151
208
  t: "term.error";
152
209
  attachId: string;
210
+ reqId?: string;
153
211
  code: TerminalOpenErrorCode;
154
212
  message: string;
155
213
  }
@@ -177,6 +235,19 @@ export type FromChild = {
177
235
  t: "mirror.image.commit";
178
236
  transferId: string;
179
237
  seq: number;
238
+ } | {
239
+ t: "terminal.lifecycle.ended";
240
+ localThreadId: string;
241
+ terminalId: string;
242
+ lifecycle: "required" | "auxiliary";
243
+ status: "idle" | "running" | "failed" | "unknown";
244
+ } | {
245
+ t: "terminal.reaped";
246
+ reqId: string;
247
+ localThreadId: string;
248
+ terminalId: string;
249
+ ok: boolean;
250
+ error?: WireError;
180
251
  }
181
252
  /** The session rotated to a fresh machine-session (claude `/clear`·`/fork`): the
182
253
  * child re-pointed its mirror to `to` and asks the daemon to alias the runner
@@ -202,8 +273,9 @@ export type FromChild = {
202
273
  error?: WireError;
203
274
  }
204
275
  /** Result of an `inject`: `result.outcome` distinguishes a new turn, an
205
- * active-turn steer, and failures. Successful results also carry the
206
- * runtime-owned canonical Response identity. Echoes the request `reqId`. */
276
+ * active-turn steer, and failures. RPC-backed success carries the canonical
277
+ * Response identity immediately; transcript-backed success may publish it on
278
+ * the mirrored user record shortly afterward. Echoes the request `reqId`. */
207
279
  | {
208
280
  t: "injected";
209
281
  reqId: string;
@@ -3,9 +3,13 @@ import { type FromChild, type ToChild } from "./protocol.js";
3
3
  export interface RunnerTransport<TSend, TRecv> {
4
4
  /** Send one message. Best-effort: a dead peer is surfaced via {@link onClose}. */
5
5
  send(msg: TSend): void;
6
+ /** Send one bounded message and resolve only after the writable accepts and
7
+ * drains it. Terminal pull responses use this to keep stdio end-to-end
8
+ * demand-driven. */
9
+ sendAndDrain(msg: TSend): Promise<void>;
6
10
  /** Register the single message handler. */
7
11
  onMessage(cb: (msg: TRecv) => void): void;
8
- /** Register a close/error handler (peer stream ended). */
12
+ /** Register a close/error observer (peer stream ended). */
9
13
  onClose(cb: (error?: Error) => void): void;
10
14
  /** Stop reading and release the readline interface. */
11
15
  close(): void;
@@ -25,12 +29,15 @@ export declare class StdioRunnerTransport<TSend, TRecv> implements RunnerTranspo
25
29
  private readonly output;
26
30
  private readonly rl;
27
31
  private messageHandler;
28
- private closeHandler;
32
+ private readonly closeHandlers;
29
33
  private closed;
34
+ private writeTail;
30
35
  constructor(input: Readable, output: Writable);
31
36
  send(msg: TSend): void;
37
+ sendAndDrain(msg: TSend): Promise<void>;
32
38
  onMessage(cb: (msg: TRecv) => void): void;
33
39
  onClose(cb: (error?: Error) => void): void;
34
40
  close(): void;
35
41
  private handleClose;
42
+ private writeMessage;
36
43
  }
@@ -22,8 +22,9 @@ export class StdioRunnerTransport {
22
22
  output;
23
23
  rl;
24
24
  messageHandler = null;
25
- closeHandler = null;
25
+ closeHandlers = new Set();
26
26
  closed = false;
27
+ writeTail = Promise.resolve();
27
28
  constructor(input, output) {
28
29
  this.input = input;
29
30
  this.output = output;
@@ -52,11 +53,16 @@ export class StdioRunnerTransport {
52
53
  }
53
54
  this.output.write(encodeMessage(msg));
54
55
  }
56
+ sendAndDrain(msg) {
57
+ const operation = this.writeTail.then(() => this.writeMessage(msg));
58
+ this.writeTail = operation.catch(() => undefined);
59
+ return operation;
60
+ }
55
61
  onMessage(cb) {
56
62
  this.messageHandler = cb;
57
63
  }
58
64
  onClose(cb) {
59
- this.closeHandler = cb;
65
+ this.closeHandlers.add(cb);
60
66
  }
61
67
  close() {
62
68
  this.closed = true;
@@ -67,6 +73,52 @@ export class StdioRunnerTransport {
67
73
  return;
68
74
  }
69
75
  this.closed = true;
70
- this.closeHandler?.(error);
76
+ for (const handler of this.closeHandlers)
77
+ handler(error);
78
+ }
79
+ writeMessage(msg) {
80
+ if (this.closed)
81
+ return Promise.reject(new Error("runner transport is closed"));
82
+ const encoded = encodeMessage(msg);
83
+ return new Promise((resolve, reject) => {
84
+ let callbackDone = false;
85
+ let drainDone = false;
86
+ let settled = false;
87
+ const cleanup = () => {
88
+ this.output.off("drain", onDrain);
89
+ this.output.off("error", onError);
90
+ };
91
+ const finish = () => {
92
+ if (settled || !callbackDone || !drainDone)
93
+ return;
94
+ settled = true;
95
+ cleanup();
96
+ resolve();
97
+ };
98
+ const onDrain = () => {
99
+ drainDone = true;
100
+ finish();
101
+ };
102
+ const onError = (error) => {
103
+ if (settled)
104
+ return;
105
+ settled = true;
106
+ cleanup();
107
+ reject(error);
108
+ };
109
+ this.output.once("error", onError);
110
+ const accepted = this.output.write(encoded, (error) => {
111
+ if (error) {
112
+ onError(error);
113
+ return;
114
+ }
115
+ callbackDone = true;
116
+ finish();
117
+ });
118
+ drainDone = accepted;
119
+ if (!accepted)
120
+ this.output.once("drain", onDrain);
121
+ finish();
122
+ });
71
123
  }
72
124
  }