@myagentroam/marctl 0.9.120 → 0.9.121
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 +1 -1
- package/src/scp.mjs +29 -3
- package/src/terminal-relay-sender.d.mts +17 -0
- package/src/terminal-relay-sender.mjs +129 -0
- package/src/terminal.mjs +18 -3
package/package.json
CHANGED
package/src/scp.mjs
CHANGED
|
@@ -61,13 +61,29 @@ function workerCommand(mode, file, size, metadata, preserve, sourceBasename) {
|
|
|
61
61
|
class ByteChannel {
|
|
62
62
|
#buffer = Buffer.alloc(0);
|
|
63
63
|
#waiting = [];
|
|
64
|
+
#sending = new Map();
|
|
64
65
|
#ended;
|
|
65
66
|
#failure;
|
|
66
67
|
|
|
67
68
|
constructor(send, signal) {
|
|
68
69
|
this.send = (bytes) => {
|
|
69
70
|
if (this.#failure) throw this.#failure;
|
|
70
|
-
|
|
71
|
+
if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
this.#sending.set(reject, resolve);
|
|
74
|
+
const settle = (complete, value) => {
|
|
75
|
+
this.#sending.delete(reject);
|
|
76
|
+
complete(value);
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
Promise.resolve(send(bytes)).then(
|
|
80
|
+
(value) => settle(resolve, value),
|
|
81
|
+
(error) => settle(reject, error)
|
|
82
|
+
);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
settle(reject, error);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
71
87
|
};
|
|
72
88
|
if (signal) {
|
|
73
89
|
if (signal.aborted) this.fail(new MarctlError('Copy was interrupted.'));
|
|
@@ -92,11 +108,21 @@ class ByteChannel {
|
|
|
92
108
|
|
|
93
109
|
finish(code) {
|
|
94
110
|
this.#ended = code;
|
|
111
|
+
if (code !== 0) return this.fail(new MarctlError('Remote copy command failed.'));
|
|
112
|
+
// A successful exit confirms the worker consumed its final protocol input.
|
|
113
|
+
for (const resolve of this.#sending.values()) resolve();
|
|
114
|
+
this.#sending.clear();
|
|
95
115
|
this.#wake();
|
|
96
116
|
}
|
|
97
117
|
|
|
118
|
+
connectionEnded(error) {
|
|
119
|
+
if (this.#ended === undefined) this.fail(error);
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
fail(error) {
|
|
99
123
|
this.#failure ??= error;
|
|
124
|
+
for (const reject of this.#sending.keys()) reject(this.#failure);
|
|
125
|
+
this.#sending.clear();
|
|
100
126
|
this.#wake();
|
|
101
127
|
}
|
|
102
128
|
|
|
@@ -231,9 +257,9 @@ async function withRemoteWorker(
|
|
|
231
257
|
}
|
|
232
258
|
};
|
|
233
259
|
const onError = () =>
|
|
234
|
-
channel.
|
|
260
|
+
channel.connectionEnded(new MarctlError(translate('errors.terminalConnectionFailed')));
|
|
235
261
|
const onClose = () =>
|
|
236
|
-
channel.
|
|
262
|
+
channel.connectionEnded(new MarctlError(translate('errors.terminalConnectionLost')));
|
|
237
263
|
transport.on('message', onMessage);
|
|
238
264
|
transport.on('error', onError);
|
|
239
265
|
transport.on('close', onClose);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface RelaySocket {
|
|
2
|
+
readonly readyState: number;
|
|
3
|
+
readonly bufferedAmount?: number;
|
|
4
|
+
send(value: string, callback: (error?: Error) => void): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class TerminalRelaySender {
|
|
8
|
+
constructor(
|
|
9
|
+
socket: RelaySocket,
|
|
10
|
+
options?: {
|
|
11
|
+
readonly onPendingChange?: (keys: ReadonlySet<string>) => void;
|
|
12
|
+
}
|
|
13
|
+
);
|
|
14
|
+
send(value: string, key?: string): Promise<void>;
|
|
15
|
+
cancel(key: string): void;
|
|
16
|
+
close(error?: Error): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const MAX_PENDING_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
const MAX_SOCKET_BYTES = 256 * 1024;
|
|
3
|
+
|
|
4
|
+
/** Shared sender for CLI input and the Node's aggregate Terminal relay output. */
|
|
5
|
+
export class TerminalRelaySender {
|
|
6
|
+
#socket;
|
|
7
|
+
#onPendingChange;
|
|
8
|
+
#pending = [];
|
|
9
|
+
#pendingBytes = 0;
|
|
10
|
+
#inFlight;
|
|
11
|
+
#timer;
|
|
12
|
+
#immediate;
|
|
13
|
+
#closed = false;
|
|
14
|
+
|
|
15
|
+
constructor(socket, { onPendingChange = () => {} } = {}) {
|
|
16
|
+
this.#socket = socket;
|
|
17
|
+
this.#onPendingChange = onPendingChange;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
send(value, key = '') {
|
|
21
|
+
if (this.#closed || this.#socket.readyState !== 1)
|
|
22
|
+
return Promise.reject(new Error('TERMINAL_CONNECTION_LOST'));
|
|
23
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
24
|
+
if (this.#pendingBytes + bytes > MAX_PENDING_BYTES)
|
|
25
|
+
return Promise.reject(
|
|
26
|
+
Object.assign(new Error('TERMINAL_RELAY_BUFFER_EXCEEDED'), {
|
|
27
|
+
code: 'TERMINAL_RELAY_BUFFER_EXCEEDED'
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
const result = new Promise((resolve, reject) => {
|
|
31
|
+
this.#pending.push({ value, key, bytes, resolve, reject });
|
|
32
|
+
this.#pendingBytes += bytes;
|
|
33
|
+
});
|
|
34
|
+
this.#pump();
|
|
35
|
+
this.#notify();
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
cancel(key) {
|
|
40
|
+
if (this.#inFlight?.key === key) {
|
|
41
|
+
this.#inFlight.cancelled = true;
|
|
42
|
+
this.#inFlight.reject(new Error('TERMINAL_ATTACH_INVALID'));
|
|
43
|
+
}
|
|
44
|
+
const kept = [];
|
|
45
|
+
for (const item of this.#pending) {
|
|
46
|
+
if (item.key !== key) kept.push(item);
|
|
47
|
+
else {
|
|
48
|
+
this.#pendingBytes -= item.bytes;
|
|
49
|
+
item.reject(new Error('TERMINAL_ATTACH_INVALID'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
this.#pending = kept;
|
|
53
|
+
if (kept.length === 0) this.#clearScheduled();
|
|
54
|
+
this.#notify();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
close(error = new Error('TERMINAL_CONNECTION_LOST')) {
|
|
58
|
+
if (this.#closed) return;
|
|
59
|
+
this.#closed = true;
|
|
60
|
+
this.#clearScheduled();
|
|
61
|
+
this.#inFlight?.reject(error);
|
|
62
|
+
this.#inFlight = undefined;
|
|
63
|
+
for (const item of this.#pending) item.reject(error);
|
|
64
|
+
this.#pending = [];
|
|
65
|
+
this.#pendingBytes = 0;
|
|
66
|
+
this.#notify();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#pump() {
|
|
70
|
+
if (
|
|
71
|
+
this.#closed ||
|
|
72
|
+
this.#inFlight !== undefined ||
|
|
73
|
+
this.#timer !== undefined ||
|
|
74
|
+
this.#immediate !== undefined ||
|
|
75
|
+
this.#pending.length === 0
|
|
76
|
+
)
|
|
77
|
+
return;
|
|
78
|
+
if (this.#socket.readyState !== 1) {
|
|
79
|
+
this.close();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const buffered = this.#socket.bufferedAmount ?? 0;
|
|
83
|
+
if (buffered > 0 && buffered + this.#pending[0].bytes > MAX_SOCKET_BYTES) {
|
|
84
|
+
// Poll only while the transport is congested; there is no throughput timer.
|
|
85
|
+
this.#timer = setTimeout(() => {
|
|
86
|
+
this.#timer = undefined;
|
|
87
|
+
this.#pump();
|
|
88
|
+
this.#notify();
|
|
89
|
+
}, 20);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const item = this.#pending.shift();
|
|
93
|
+
this.#inFlight = item;
|
|
94
|
+
try {
|
|
95
|
+
this.#socket.send(item.value, (error) => {
|
|
96
|
+
if (this.#inFlight !== item) return;
|
|
97
|
+
if (error) {
|
|
98
|
+
this.close(error);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.#inFlight = undefined;
|
|
102
|
+
this.#pendingBytes -= item.bytes;
|
|
103
|
+
// Even a synchronous write callback must yield before the next frame.
|
|
104
|
+
this.#immediate = setImmediate(() => {
|
|
105
|
+
this.#immediate = undefined;
|
|
106
|
+
this.#pump();
|
|
107
|
+
this.#notify();
|
|
108
|
+
});
|
|
109
|
+
item.resolve();
|
|
110
|
+
this.#notify();
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this.close(error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#notify() {
|
|
118
|
+
const keys = new Set(this.#pending.map((item) => item.key));
|
|
119
|
+
if (this.#inFlight !== undefined && !this.#inFlight.cancelled) keys.add(this.#inFlight.key);
|
|
120
|
+
this.#onPendingChange(keys);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#clearScheduled() {
|
|
124
|
+
clearTimeout(this.#timer);
|
|
125
|
+
this.#timer = undefined;
|
|
126
|
+
clearImmediate(this.#immediate);
|
|
127
|
+
this.#immediate = undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/terminal.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { MarctlError } from './config.mjs';
|
|
3
3
|
import { createTranslator } from './i18n/index.mjs';
|
|
4
|
+
import { TerminalRelaySender } from './terminal-relay-sender.mjs';
|
|
4
5
|
|
|
5
6
|
const MAX_INPUT_BYTES = 65536;
|
|
6
7
|
const DEFAULT_ROWS = 24;
|
|
@@ -328,6 +329,7 @@ async function prepareRelayTransport({
|
|
|
328
329
|
|
|
329
330
|
class BufferedRelayTransport extends EventEmitter {
|
|
330
331
|
#socket;
|
|
332
|
+
#sender;
|
|
331
333
|
#terminalId;
|
|
332
334
|
#translate;
|
|
333
335
|
#attached = false;
|
|
@@ -346,6 +348,7 @@ class BufferedRelayTransport extends EventEmitter {
|
|
|
346
348
|
constructor({ socket, terminalId, translate, connectionTimeoutMs }) {
|
|
347
349
|
super();
|
|
348
350
|
this.#socket = socket;
|
|
351
|
+
this.#sender = new TerminalRelaySender(socket);
|
|
349
352
|
this.#terminalId = terminalId;
|
|
350
353
|
this.#translate = translate;
|
|
351
354
|
this.#onOpen = () => {
|
|
@@ -388,6 +391,7 @@ class BufferedRelayTransport extends EventEmitter {
|
|
|
388
391
|
else this.#fail(error);
|
|
389
392
|
};
|
|
390
393
|
this.#onClose = () => {
|
|
394
|
+
this.#sender.close();
|
|
391
395
|
const error = new MarctlError(
|
|
392
396
|
this.#translate(
|
|
393
397
|
this.#attached ? 'errors.terminalConnectionLost' : 'errors.terminalAttachLost'
|
|
@@ -439,12 +443,14 @@ class BufferedRelayTransport extends EventEmitter {
|
|
|
439
443
|
|
|
440
444
|
send(value) {
|
|
441
445
|
if (this.#closed) throw new Error('TERMINAL_CONNECTION_LOST');
|
|
446
|
+
if (JSON.parse(value).type === 'input') return this.#sender.send(value);
|
|
442
447
|
this.#socket.send(value);
|
|
443
448
|
}
|
|
444
449
|
|
|
445
450
|
close() {
|
|
446
451
|
if (this.#closed) return;
|
|
447
452
|
this.#closed = true;
|
|
453
|
+
this.#sender.close();
|
|
448
454
|
clearTimeout(this.#openTimer);
|
|
449
455
|
clearTimeout(this.#attachTimer);
|
|
450
456
|
this.#socket.removeListener?.('open', this.#onOpen);
|
|
@@ -499,6 +505,7 @@ function runAttachedTerminal({
|
|
|
499
505
|
let settled = false;
|
|
500
506
|
let inputEnabled = false;
|
|
501
507
|
let rawModeEnabled = false;
|
|
508
|
+
let inputTasks = 0;
|
|
502
509
|
|
|
503
510
|
const cleanup = () => {
|
|
504
511
|
transport.removeListener?.('message', onMessage);
|
|
@@ -529,13 +536,21 @@ function runAttachedTerminal({
|
|
|
529
536
|
};
|
|
530
537
|
const fail = (key) => finish(new MarctlError(translate(key)));
|
|
531
538
|
const send = (frame) => {
|
|
532
|
-
transport.send(JSON.stringify(frame));
|
|
539
|
+
return transport.send(JSON.stringify(frame));
|
|
533
540
|
};
|
|
534
|
-
const onInput = (data) => {
|
|
541
|
+
const onInput = async (data) => {
|
|
542
|
+
inputTasks += 1;
|
|
543
|
+
stdin.pause?.();
|
|
535
544
|
try {
|
|
536
|
-
for (const chunk of splitInput(data))
|
|
545
|
+
for (const chunk of splitInput(data)) {
|
|
546
|
+
if (settled) return;
|
|
547
|
+
await send({ type: 'input', terminalId, data: chunk });
|
|
548
|
+
}
|
|
537
549
|
} catch {
|
|
538
550
|
fail('errors.terminalInputFailed');
|
|
551
|
+
} finally {
|
|
552
|
+
inputTasks -= 1;
|
|
553
|
+
if (!settled && inputTasks === 0) stdin.resume?.();
|
|
539
554
|
}
|
|
540
555
|
};
|
|
541
556
|
const onResize = () => {
|