@myagentroam/marctl 0.9.122 → 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/scp-worker.cjs +13 -2
- package/src/scp.mjs +55 -20
package/package.json
CHANGED
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);
|