@myagentroam/marctl 0.9.121 → 0.9.123
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/direct.mjs +21 -2
- package/src/scp-worker.cjs +13 -2
- package/src/scp.mjs +55 -20
- package/src/terminal.mjs +40 -13
package/package.json
CHANGED
package/src/direct.mjs
CHANGED
|
@@ -27,14 +27,17 @@ export async function openDirectTerminal({
|
|
|
27
27
|
WebSocketClass,
|
|
28
28
|
nodeId,
|
|
29
29
|
terminalId,
|
|
30
|
+
manifest,
|
|
30
31
|
afterSequence = 0,
|
|
31
32
|
timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
|
|
32
33
|
heartbeatMs = DIRECT_HEARTBEAT_MS,
|
|
33
34
|
peerConnectionFactory,
|
|
35
|
+
onPrepared,
|
|
36
|
+
onIceConnected,
|
|
34
37
|
onTransportReady,
|
|
35
38
|
signal
|
|
36
39
|
}) {
|
|
37
|
-
if (typeof client.getTerminalManifest !== 'function')
|
|
40
|
+
if (manifest === undefined && typeof client.getTerminalManifest !== 'function')
|
|
38
41
|
throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
|
|
39
42
|
|
|
40
43
|
let live;
|
|
@@ -49,7 +52,7 @@ export async function openDirectTerminal({
|
|
|
49
52
|
let transportNegotiationActive = true;
|
|
50
53
|
let cleanupNative;
|
|
51
54
|
try {
|
|
52
|
-
|
|
55
|
+
manifest ??= await client.getTerminalManifest(nodeId);
|
|
53
56
|
if (
|
|
54
57
|
typeof manifest?.nodeGeneration !== 'string' ||
|
|
55
58
|
manifest.nodeGeneration.length < 8 ||
|
|
@@ -84,7 +87,12 @@ export async function openDirectTerminal({
|
|
|
84
87
|
peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
|
|
85
88
|
peer = createPeer(`marctl-${transportSessionId}`, { iceServers: [] });
|
|
86
89
|
|
|
90
|
+
// Start negotiation only after both paths finish their control-plane preparation.
|
|
91
|
+
if (onPrepared) await onPrepared();
|
|
92
|
+
if (signal?.aborted) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
|
|
93
|
+
|
|
87
94
|
const transportReady = new Promise((resolve, reject) => {
|
|
95
|
+
let iceConnected = false;
|
|
88
96
|
let connected = false;
|
|
89
97
|
let serverOpened = false;
|
|
90
98
|
let settled = false;
|
|
@@ -162,6 +170,17 @@ export async function openDirectTerminal({
|
|
|
162
170
|
)
|
|
163
171
|
finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
|
|
164
172
|
});
|
|
173
|
+
peer.onIceStateChange((state) => {
|
|
174
|
+
if (!transportNegotiationActive || settled || iceConnected) return;
|
|
175
|
+
const normalizedState = String(state).toLowerCase();
|
|
176
|
+
if (normalizedState !== 'connected' && normalizedState !== 'completed') return;
|
|
177
|
+
iceConnected = true;
|
|
178
|
+
try {
|
|
179
|
+
onIceConnected?.();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
finish(error);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
165
184
|
peer.onStateChange((state) => {
|
|
166
185
|
if (!transportNegotiationActive || settled) return;
|
|
167
186
|
const normalizedState = String(state).toLowerCase();
|
package/src/scp-worker.cjs
CHANGED
|
@@ -4,6 +4,8 @@ const { link, lstat, open, rename, unlink } = require('node:fs/promises');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
6
|
const CHUNK = 32 * 1024;
|
|
7
|
+
const WINDOW = 8;
|
|
8
|
+
const MAX_BUFFERED_BYTES = WINDOW * (CHUNK + 4) + 64;
|
|
7
9
|
const [mode, encodedPath, sizeArgument, encodedMetadata, preserveArgument, encodedSourceBasename] =
|
|
8
10
|
process.argv.slice(1);
|
|
9
11
|
const target = Buffer.from(encodedPath ?? '', 'base64url').toString('utf8');
|
|
@@ -16,7 +18,7 @@ async function readExact(length) {
|
|
|
16
18
|
const next = await input.next();
|
|
17
19
|
if (next.done) throw new Error('SCP_STREAM_TRUNCATED');
|
|
18
20
|
pending = Buffer.concat([pending, next.value]);
|
|
19
|
-
if (pending.length >
|
|
21
|
+
if (pending.length > MAX_BUFFERED_BYTES) throw new Error('SCP_INPUT_OVERFLOW');
|
|
20
22
|
}
|
|
21
23
|
const data = pending.subarray(0, length);
|
|
22
24
|
pending = pending.subarray(length);
|
|
@@ -111,6 +113,8 @@ async function source() {
|
|
|
111
113
|
const hash = createHash('sha256');
|
|
112
114
|
const buffer = Buffer.alloc(CHUNK);
|
|
113
115
|
let offset = 0;
|
|
116
|
+
let acknowledged = Promise.resolve();
|
|
117
|
+
const pendingAcks = [];
|
|
114
118
|
while (offset < current.size) {
|
|
115
119
|
const { bytesRead } = await handle.read(
|
|
116
120
|
buffer,
|
|
@@ -121,9 +125,16 @@ async function source() {
|
|
|
121
125
|
if (bytesRead === 0) throw new Error('SCP_SOURCE_CHANGED');
|
|
122
126
|
hash.update(buffer.subarray(0, bytesRead));
|
|
123
127
|
await send(Buffer.concat([frameLength(bytesRead), buffer.subarray(0, bytesRead)]));
|
|
124
|
-
|
|
128
|
+
// Serialize ACK reads without making each file read wait for a round trip.
|
|
129
|
+
acknowledged = acknowledged.then(async () => {
|
|
130
|
+
if ((await readExact(1))[0] !== 1) throw new Error('SCP_ACK_INVALID');
|
|
131
|
+
});
|
|
132
|
+
acknowledged.catch(() => undefined);
|
|
133
|
+
pendingAcks.push(acknowledged);
|
|
125
134
|
offset += bytesRead;
|
|
135
|
+
if (pendingAcks.length >= WINDOW) await pendingAcks.shift();
|
|
126
136
|
}
|
|
137
|
+
await acknowledged;
|
|
127
138
|
const after = await handle.stat();
|
|
128
139
|
if (
|
|
129
140
|
after.size !== current.size ||
|
package/src/scp.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { MarctlError } from './config.mjs';
|
|
|
6
6
|
import { parseFrame, runTerminalSession } from './terminal.mjs';
|
|
7
7
|
|
|
8
8
|
const CHUNK = 32 * 1024;
|
|
9
|
+
const WINDOW = 8;
|
|
10
|
+
const MAX_BUFFERED_BYTES = WINDOW * (CHUNK + 4) + 64;
|
|
9
11
|
const script = gzipSync(readFileSync(new URL('./scp-worker.cjs', import.meta.url))).toString(
|
|
10
12
|
'base64'
|
|
11
13
|
);
|
|
@@ -64,8 +66,11 @@ class ByteChannel {
|
|
|
64
66
|
#sending = new Map();
|
|
65
67
|
#ended;
|
|
66
68
|
#failure;
|
|
69
|
+
#controller;
|
|
67
70
|
|
|
68
|
-
constructor(send,
|
|
71
|
+
constructor(send, controller) {
|
|
72
|
+
this.#controller = controller;
|
|
73
|
+
const signal = controller.signal;
|
|
69
74
|
this.send = (bytes) => {
|
|
70
75
|
if (this.#failure) throw this.#failure;
|
|
71
76
|
if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
|
|
@@ -86,22 +91,18 @@ class ByteChannel {
|
|
|
86
91
|
});
|
|
87
92
|
};
|
|
88
93
|
if (signal) {
|
|
89
|
-
if (signal.aborted) this.fail(
|
|
94
|
+
if (signal.aborted) this.fail(signal.reason);
|
|
90
95
|
else
|
|
91
|
-
signal.addEventListener(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
{
|
|
95
|
-
once: true
|
|
96
|
-
}
|
|
97
|
-
);
|
|
96
|
+
signal.addEventListener('abort', () => this.fail(signal.reason), {
|
|
97
|
+
once: true
|
|
98
|
+
});
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
feed(bytes) {
|
|
102
103
|
if (this.#failure) return;
|
|
103
104
|
this.#buffer = Buffer.concat([this.#buffer, bytes]);
|
|
104
|
-
if (this.#buffer.length >
|
|
105
|
+
if (this.#buffer.length > MAX_BUFFERED_BYTES)
|
|
105
106
|
return this.fail(new MarctlError('Copy output exceeded the bounded window.'));
|
|
106
107
|
this.#wake();
|
|
107
108
|
}
|
|
@@ -120,10 +121,13 @@ class ByteChannel {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
fail(error) {
|
|
123
|
-
this.#failure
|
|
124
|
+
if (this.#failure !== undefined) return;
|
|
125
|
+
this.#failure = error;
|
|
124
126
|
for (const reject of this.#sending.keys()) reject(this.#failure);
|
|
125
127
|
this.#sending.clear();
|
|
126
128
|
this.#wake();
|
|
129
|
+
// Any endpoint failure must wake reads and writes across the entire copy.
|
|
130
|
+
this.#controller.abort(error);
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
#wake() {
|
|
@@ -160,6 +164,8 @@ async function copyBytes(source, destination) {
|
|
|
160
164
|
if ((await target.read(1))[0] !== 1)
|
|
161
165
|
throw new MarctlError('Copy target did not accept the file.');
|
|
162
166
|
let remaining = Number(size);
|
|
167
|
+
let acknowledged = Promise.resolve();
|
|
168
|
+
const pending = [];
|
|
163
169
|
for (;;) {
|
|
164
170
|
const header = await source.read(4);
|
|
165
171
|
const length = header.readUInt32BE();
|
|
@@ -167,22 +173,42 @@ async function copyBytes(source, destination) {
|
|
|
167
173
|
throw new MarctlError('Copy source sent an invalid chunk.');
|
|
168
174
|
await target.send(header);
|
|
169
175
|
if (length === 0) {
|
|
176
|
+
await acknowledged;
|
|
170
177
|
await target.send(await source.read(32));
|
|
171
178
|
if (remaining !== 0 || (await target.read(1))[0] !== 2)
|
|
172
179
|
throw new MarctlError('Copy target did not verify the file.');
|
|
173
180
|
break;
|
|
174
181
|
}
|
|
175
182
|
await target.send(await source.read(length));
|
|
176
|
-
|
|
177
|
-
|
|
183
|
+
// Keep one ordered ACK reader while sending up to a bounded window ahead.
|
|
184
|
+
acknowledged = acknowledged.then(async () => {
|
|
185
|
+
if ((await target.read(1))[0] !== 1)
|
|
186
|
+
throw new MarctlError('Copy target rejected the chunk.');
|
|
187
|
+
await source.send(Buffer.from([1]));
|
|
188
|
+
});
|
|
189
|
+
acknowledged.catch((error) => {
|
|
190
|
+
// ACK failures use the same copy-wide cancellation as transport failures.
|
|
191
|
+
source.fail(error);
|
|
192
|
+
});
|
|
193
|
+
pending.push(acknowledged);
|
|
178
194
|
remaining -= length;
|
|
195
|
+
if (pending.length >= WINDOW) await pending.shift();
|
|
179
196
|
}
|
|
180
197
|
await target.done();
|
|
181
198
|
});
|
|
182
199
|
await source.done();
|
|
183
200
|
}
|
|
184
201
|
|
|
185
|
-
async function withLocalWorker(
|
|
202
|
+
async function withLocalWorker(
|
|
203
|
+
mode,
|
|
204
|
+
file,
|
|
205
|
+
size,
|
|
206
|
+
metadata,
|
|
207
|
+
preserve,
|
|
208
|
+
sourceBasename,
|
|
209
|
+
use,
|
|
210
|
+
controller
|
|
211
|
+
) {
|
|
186
212
|
const child = spawn(
|
|
187
213
|
process.execPath,
|
|
188
214
|
['-e', expression, ...workerArguments(mode, file, size, metadata, preserve, sourceBasename)],
|
|
@@ -193,7 +219,13 @@ async function withLocalWorker(mode, file, size, metadata, preserve, sourceBasen
|
|
|
193
219
|
);
|
|
194
220
|
child.stdin.on('error', () => undefined);
|
|
195
221
|
child.stderr.resume();
|
|
196
|
-
const channel = new ByteChannel(
|
|
222
|
+
const channel = new ByteChannel(
|
|
223
|
+
(bytes) =>
|
|
224
|
+
new Promise((resolve, reject) => {
|
|
225
|
+
child.stdin.write(bytes, (error) => (error ? reject(error) : resolve()));
|
|
226
|
+
}),
|
|
227
|
+
controller
|
|
228
|
+
);
|
|
197
229
|
child.stdout.on('data', (bytes) => channel.feed(bytes));
|
|
198
230
|
child.once('error', (error) => channel.fail(error));
|
|
199
231
|
child.once('close', (code) => channel.finish(code ?? 1));
|
|
@@ -216,7 +248,7 @@ async function withRemoteWorker(
|
|
|
216
248
|
preserve,
|
|
217
249
|
sourceBasename,
|
|
218
250
|
translate,
|
|
219
|
-
|
|
251
|
+
controller
|
|
220
252
|
},
|
|
221
253
|
use
|
|
222
254
|
) {
|
|
@@ -234,7 +266,7 @@ async function withRemoteWorker(
|
|
|
234
266
|
transport.send(
|
|
235
267
|
JSON.stringify({ type: 'input', terminalId, data: bytes.toString('base64') })
|
|
236
268
|
),
|
|
237
|
-
|
|
269
|
+
controller
|
|
238
270
|
);
|
|
239
271
|
let sequence = 0;
|
|
240
272
|
const onMessage = (raw, isBinary) => {
|
|
@@ -285,7 +317,7 @@ async function withWorker(endpoint, mode, size, metadata, context, use) {
|
|
|
285
317
|
context.preserve,
|
|
286
318
|
context.sourceBasename,
|
|
287
319
|
use,
|
|
288
|
-
context.
|
|
320
|
+
context.controller
|
|
289
321
|
);
|
|
290
322
|
return withRemoteWorker(
|
|
291
323
|
{
|
|
@@ -321,7 +353,7 @@ export async function runScp({
|
|
|
321
353
|
...(endpoint.host ? { nodeId: ids[references.indexOf(endpoint.host)] } : {})
|
|
322
354
|
});
|
|
323
355
|
const controller = new AbortController();
|
|
324
|
-
const interrupt = () => controller.abort();
|
|
356
|
+
const interrupt = () => controller.abort(new MarctlError('Copy was interrupted.'));
|
|
325
357
|
signals.on?.('SIGINT', interrupt);
|
|
326
358
|
signals.on?.('SIGTERM', interrupt);
|
|
327
359
|
try {
|
|
@@ -329,7 +361,7 @@ export async function runScp({
|
|
|
329
361
|
client,
|
|
330
362
|
WebSocketClass,
|
|
331
363
|
translate,
|
|
332
|
-
|
|
364
|
+
controller,
|
|
333
365
|
preserve,
|
|
334
366
|
sourceBasename
|
|
335
367
|
};
|
|
@@ -340,6 +372,9 @@ export async function runScp({
|
|
|
340
372
|
})
|
|
341
373
|
);
|
|
342
374
|
return 0;
|
|
375
|
+
} catch (error) {
|
|
376
|
+
controller.abort(error);
|
|
377
|
+
throw error;
|
|
343
378
|
} finally {
|
|
344
379
|
signals.removeListener?.('SIGINT', interrupt);
|
|
345
380
|
signals.removeListener?.('SIGTERM', interrupt);
|
package/src/terminal.mjs
CHANGED
|
@@ -28,13 +28,16 @@ export async function runTerminalSession({
|
|
|
28
28
|
let transport;
|
|
29
29
|
let result;
|
|
30
30
|
let primaryError;
|
|
31
|
+
let manifest;
|
|
31
32
|
|
|
32
33
|
try {
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
if (binary) {
|
|
35
|
+
if (command === undefined)
|
|
36
|
+
throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
|
|
37
|
+
manifest = await client.getTerminalManifest(nodeId);
|
|
38
|
+
if (manifest?.binaryTerminal !== true)
|
|
39
|
+
throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
|
|
40
|
+
}
|
|
38
41
|
const created = await client.createTerminal({
|
|
39
42
|
nodeId,
|
|
40
43
|
...dimensions,
|
|
@@ -47,17 +50,18 @@ export async function runTerminalSession({
|
|
|
47
50
|
try {
|
|
48
51
|
const openDirectTerminal =
|
|
49
52
|
openDirectTerminalImpl ?? (await import('./direct.mjs')).openDirectTerminal;
|
|
53
|
+
if (raceConnections && manifest === undefined)
|
|
54
|
+
manifest = await client.getTerminalManifest(nodeId).catch(() => undefined);
|
|
50
55
|
const directOptions = {
|
|
51
56
|
client,
|
|
52
57
|
WebSocketClass,
|
|
53
58
|
nodeId,
|
|
54
59
|
terminalId,
|
|
60
|
+
manifest,
|
|
55
61
|
afterSequence: 0,
|
|
56
62
|
timeoutMs: connectionTimeoutMs
|
|
57
63
|
};
|
|
58
|
-
const canRace =
|
|
59
|
-
raceConnections &&
|
|
60
|
-
(await client.getTerminalManifest(nodeId).catch(() => undefined))?.deferredRelay === true;
|
|
64
|
+
const canRace = raceConnections && manifest?.deferredRelay === true;
|
|
61
65
|
transport = canRace
|
|
62
66
|
? await raceTerminalConnections({
|
|
63
67
|
openDirectTerminal,
|
|
@@ -137,6 +141,16 @@ async function raceTerminalConnections({
|
|
|
137
141
|
}) {
|
|
138
142
|
const abort = new AbortController();
|
|
139
143
|
const relayAbort = new AbortController();
|
|
144
|
+
const preparing = new Set(['direct', 'relay']);
|
|
145
|
+
let start;
|
|
146
|
+
const preparation = new Promise((resolve) => {
|
|
147
|
+
start = resolve;
|
|
148
|
+
});
|
|
149
|
+
const prepared = (candidate) => {
|
|
150
|
+
preparing.delete(candidate);
|
|
151
|
+
if (preparing.size === 0) start();
|
|
152
|
+
return preparation;
|
|
153
|
+
};
|
|
140
154
|
let selected;
|
|
141
155
|
let announce;
|
|
142
156
|
const first = new Promise((resolve) => {
|
|
@@ -153,12 +167,19 @@ async function raceTerminalConnections({
|
|
|
153
167
|
const direct = openDirectTerminal({
|
|
154
168
|
...directOptions,
|
|
155
169
|
signal: abort.signal,
|
|
156
|
-
|
|
170
|
+
onPrepared: () => prepared('direct'),
|
|
171
|
+
onIceConnected: () => {
|
|
157
172
|
if (!select('direct')) throw new Error('DIRECT_RACE_CANCELLED');
|
|
173
|
+
},
|
|
174
|
+
onTransportReady: () => {
|
|
175
|
+
if (selected !== 'direct') throw new Error('DIRECT_RACE_CANCELLED');
|
|
158
176
|
}
|
|
159
177
|
}).then(
|
|
160
178
|
(value) => ({ value }),
|
|
161
|
-
(error) =>
|
|
179
|
+
(error) => {
|
|
180
|
+
prepared('direct');
|
|
181
|
+
return { error };
|
|
182
|
+
}
|
|
162
183
|
);
|
|
163
184
|
const relay = prepareRelayTransport({
|
|
164
185
|
client,
|
|
@@ -167,13 +188,17 @@ async function raceTerminalConnections({
|
|
|
167
188
|
terminalId,
|
|
168
189
|
translate,
|
|
169
190
|
connectionTimeoutMs,
|
|
170
|
-
signal: relayAbort.signal
|
|
191
|
+
signal: relayAbort.signal,
|
|
192
|
+
onPrepared: () => prepared('relay')
|
|
171
193
|
}).then(
|
|
172
194
|
(value) => {
|
|
173
195
|
if (!select('relay')) value.close();
|
|
174
196
|
return { value };
|
|
175
197
|
},
|
|
176
|
-
(error) =>
|
|
198
|
+
(error) => {
|
|
199
|
+
prepared('relay');
|
|
200
|
+
return { error };
|
|
201
|
+
}
|
|
177
202
|
);
|
|
178
203
|
const winner = await Promise.race([
|
|
179
204
|
first,
|
|
@@ -253,9 +278,11 @@ async function prepareRelayTransport({
|
|
|
253
278
|
terminalId,
|
|
254
279
|
translate,
|
|
255
280
|
connectionTimeoutMs,
|
|
256
|
-
signal
|
|
281
|
+
signal,
|
|
282
|
+
onPrepared
|
|
257
283
|
}) {
|
|
258
284
|
const ticket = await client.createAttachTicket({ nodeId, terminalId });
|
|
285
|
+
if (onPrepared) await onPrepared();
|
|
259
286
|
if (signal?.aborted) throw new MarctlError(translate('errors.terminalAttachConnection'));
|
|
260
287
|
const socket = client.openTerminalWebSocket(WebSocketClass, {
|
|
261
288
|
terminalId,
|