super-dsh 0.3.0-e → 0.3.0-g
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/README.md +13 -55
- package/client/index.js +141 -10
- package/cordis.patch.yml +6 -3
- package/package.json +12 -5
- package/remote/file-worker.js +341 -0
- package/remote/plugin.d.ts +523 -0
- package/remote/plugin.js +4979 -0
- package/remote/provenance.json +64 -0
- package/superd-hub/dist/a2a-row.d.ts +18 -2
- package/superd-hub/dist/a2a-row.js +11 -69
- package/superd-hub/dist/bundle-setup.d.ts +16 -6
- package/superd-hub/dist/bundle-setup.js +84 -148
- package/superd-hub/dist/client/companion-api.d.ts +5 -0
- package/superd-hub/dist/client/companion-api.js +30 -0
- package/superd-hub/dist/companion-contract.d.ts +9 -0
- package/superd-hub/dist/companion-contract.js +1 -0
- package/superd-hub/dist/companions.d.ts +30 -0
- package/superd-hub/dist/companions.js +209 -0
- package/superd-hub/dist/exec-pty-framing.d.ts +3 -1
- package/superd-hub/dist/exec-pty-framing.js +12 -4
- package/superd-hub/dist/exec-pty-session.d.ts +2 -0
- package/superd-hub/dist/exec-pty-session.js +7 -13
- package/superd-hub/dist/exec.d.ts +5 -2
- package/superd-hub/dist/exec.js +34 -54
- package/superd-hub/dist/fusion-transport.js +1 -0
- package/superd-hub/dist/gateway.js +24 -11
- package/superd-hub/dist/hub-tool.d.ts +3 -0
- package/superd-hub/dist/hub-tool.js +13 -5
- package/superd-hub/dist/remote-file-rpc.d.ts +20 -0
- package/superd-hub/dist/remote-file-rpc.js +574 -0
- package/superd-hub/dist/remote-job-client.d.ts +3 -0
- package/superd-hub/dist/remote-job-client.js +197 -0
- package/superd-hub/dist/remote-job-rpc.d.ts +21 -0
- package/superd-hub/dist/remote-job-rpc.js +155 -0
- package/superd-hub/dist/remote-provider.d.ts +13 -0
- package/superd-hub/dist/remote-provider.js +151 -0
- package/superd-hub/dist/remote-row.d.ts +6 -0
- package/superd-hub/dist/remote-row.js +13 -0
- package/superd-hub/lib/client/index.js +141 -10
- package/superd-hub/package.json +1 -2
- package/superd-hub/scripts/register-companion.mjs +0 -100
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/** Independent binary lane. Task lease, not transport pong, owns native filesystem work. */
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { Readable, PassThrough } from 'node:stream';
|
|
4
|
+
import { once } from 'node:events';
|
|
5
|
+
import { finished, pipeline } from 'node:stream/promises';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { startBinaryProcess, PROCESS_CLEANUP_BOUND_MS, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, checkAbsolute, checkSelection } from '../../remote/plugin.js';
|
|
11
|
+
import { TASK_LEASE_MS, TASK_RENEW_STEP_MS, TASK_CONTROL_TIMEOUT_MS } from './remote-job-rpc.js';
|
|
12
|
+
export const FILE_IDLE_MS = 6000;
|
|
13
|
+
export const FILE_SOCKET_CLOSE_MS = 1000;
|
|
14
|
+
// Canonical group TERM/KILL/settle + socket termination + bounded, constant-count local unlink margin.
|
|
15
|
+
// As with the process lease, assumes the host event loop/kernel/filesystem remain schedulable.
|
|
16
|
+
export const FILE_CLEANUP_MS = PROCESS_CLEANUP_BOUND_MS + FILE_SOCKET_CLOSE_MS + 1000;
|
|
17
|
+
export const FILE_LEASE_PROTOCOL = 1;
|
|
18
|
+
export const REMOTE_FILE_PATH = '/api/superd/file';
|
|
19
|
+
const ws = createRequire(import.meta.url)('ws');
|
|
20
|
+
const control = (socket, value) => {
|
|
21
|
+
if (socket.readyState === 1)
|
|
22
|
+
socket.send(JSON.stringify(value));
|
|
23
|
+
};
|
|
24
|
+
const raw = (data) => Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
25
|
+
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
|
26
|
+
const wait = () => deferred();
|
|
27
|
+
const send = (socket, data) => new Promise((resolve, reject) => socket.send(data, error => error ? reject(error) : resolve()));
|
|
28
|
+
async function closeSocket(socket) {
|
|
29
|
+
if (socket.readyState === 3)
|
|
30
|
+
return;
|
|
31
|
+
const closed = new Promise(resolve => socket.once('close', resolve));
|
|
32
|
+
const deadline = setTimeout(() => socket.terminate(), FILE_SOCKET_CLOSE_MS);
|
|
33
|
+
if (socket.readyState === 0)
|
|
34
|
+
socket.terminate();
|
|
35
|
+
else
|
|
36
|
+
socket.close();
|
|
37
|
+
try {
|
|
38
|
+
await closed;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
clearTimeout(deadline);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function registerRemoteFileRpc(real, opts) {
|
|
45
|
+
const server = new ws.WebSocketServer({ noServer: true, maxPayload: FILE_FRAME_BYTES, perMessageDeflate: false });
|
|
46
|
+
const operations = new Set();
|
|
47
|
+
let disposed = false;
|
|
48
|
+
const handler = (req, socket, head) => {
|
|
49
|
+
let status = 401;
|
|
50
|
+
const allowed = opts.authorize(req, { writeHead: (code) => { status = code; }, end() { } });
|
|
51
|
+
const rejection = disposed ? 503 : allowed ? opts.requestRejection?.(req) : status;
|
|
52
|
+
if (rejection !== undefined) {
|
|
53
|
+
socket.end(`HTTP/1.1 ${rejection} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
server.handleUpgrade(req, socket, head, client => {
|
|
57
|
+
const abort = new AbortController(), complete = wait(), input = new PassThrough({ highWaterMark: FILE_FRAME_BYTES });
|
|
58
|
+
input.on('error', () => { });
|
|
59
|
+
let token = randomUUID(), expires = performance.now() + TASK_LEASE_MS, leaseTimer, idleTimer;
|
|
60
|
+
let failureCloseTimer;
|
|
61
|
+
let request;
|
|
62
|
+
const selection = [];
|
|
63
|
+
let metadata = 0, started = false, writing = false, ended = false, finalizing = false, ack;
|
|
64
|
+
let worker, root, workerError;
|
|
65
|
+
const temporaries = new Set();
|
|
66
|
+
const finalize = async (result) => {
|
|
67
|
+
if (finalizing)
|
|
68
|
+
return;
|
|
69
|
+
finalizing = true;
|
|
70
|
+
clearTimeout(leaseTimer);
|
|
71
|
+
clearTimeout(idleTimer);
|
|
72
|
+
input.destroy();
|
|
73
|
+
worker?.cancel();
|
|
74
|
+
await worker?.done;
|
|
75
|
+
// Worker stderr is drained before child close/done: all created temporary paths are known.
|
|
76
|
+
let cleaned = true;
|
|
77
|
+
try {
|
|
78
|
+
for (const path of temporaries)
|
|
79
|
+
rmSync(path, { force: true });
|
|
80
|
+
if (root)
|
|
81
|
+
rmSync(root, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
cleaned = false;
|
|
85
|
+
result = { op: 'error', error: `file receiver cleanup failed: ${String(error)}` };
|
|
86
|
+
}
|
|
87
|
+
// Terminal ACK follows worker/FD/temp cleanup. Closing socket is also owned, with a fixed bound.
|
|
88
|
+
const closing = setTimeout(() => client.terminate(), FILE_SOCKET_CLOSE_MS);
|
|
89
|
+
try {
|
|
90
|
+
if (client.readyState === 1)
|
|
91
|
+
await send(client, JSON.stringify({ ...result, cleaned })).catch(() => { });
|
|
92
|
+
await closeSocket(client);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearTimeout(closing);
|
|
96
|
+
clearTimeout(failureCloseTimer);
|
|
97
|
+
operations.delete(operation);
|
|
98
|
+
complete.resolve();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const fail = (error) => {
|
|
102
|
+
// A pending WS send can be backpressured even after worker/ACK cancellation.
|
|
103
|
+
// Arm transport termination here, independently of run/finalize unwinding.
|
|
104
|
+
// Reuse the existing socket bound; worker/FD/temp completion still owns done.
|
|
105
|
+
if (client.readyState !== 3)
|
|
106
|
+
failureCloseTimer ??= setTimeout(() => client.terminate(), FILE_SOCKET_CLOSE_MS);
|
|
107
|
+
if (!abort.signal.aborted)
|
|
108
|
+
abort.abort(error);
|
|
109
|
+
input.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
110
|
+
ack?.reject(error);
|
|
111
|
+
worker?.cancel();
|
|
112
|
+
if (!started)
|
|
113
|
+
void finalize({ op: 'error', error: String(error) });
|
|
114
|
+
};
|
|
115
|
+
const operation = { stop: fail, done: complete.promise };
|
|
116
|
+
operations.add(operation);
|
|
117
|
+
const armLease = () => { clearTimeout(leaseTimer); leaseTimer = setTimeout(() => fail(new Error('binary file task lease expired')), Math.max(0, expires - performance.now())); };
|
|
118
|
+
const armIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => fail(new Error('binary file idle/ACK timeout')), FILE_IDLE_MS); };
|
|
119
|
+
const lease = () => ({ protocol: FILE_LEASE_PROTOCOL, token, leaseMs: TASK_LEASE_MS, renewStepMs: TASK_RENEW_STEP_MS, cleanupMs: FILE_CLEANUP_MS });
|
|
120
|
+
client.on('close', () => {
|
|
121
|
+
if (!finalizing)
|
|
122
|
+
fail(new Error('binary file peer disconnected'));
|
|
123
|
+
});
|
|
124
|
+
client.on('error', fail);
|
|
125
|
+
const run = async () => {
|
|
126
|
+
started = true;
|
|
127
|
+
clearTimeout(idleTimer);
|
|
128
|
+
let result = { op: 'done' };
|
|
129
|
+
try {
|
|
130
|
+
abort.signal.throwIfAborted();
|
|
131
|
+
checkSelection(selection);
|
|
132
|
+
root = join(tmpdir(), `remote-file-worker-${randomUUID()}`);
|
|
133
|
+
const workerRequest = { ...request, root };
|
|
134
|
+
// Private canonical worker leaf has Node-builtins-only imports; no host resolver exists in this owned child.
|
|
135
|
+
const script = `import {runFileWorker} from ${JSON.stringify(new URL('../../remote/file-worker.js', import.meta.url).href)}; await runFileWorker(JSON.parse(process.argv[1]));`;
|
|
136
|
+
worker = startBinaryProcess([process.execPath, '--input-type=module', '-e', script, JSON.stringify(workerRequest)]);
|
|
137
|
+
let stderr = '';
|
|
138
|
+
worker.stderr.setEncoding('utf8');
|
|
139
|
+
worker.stderr.on('data', (chunk) => {
|
|
140
|
+
stderr += chunk;
|
|
141
|
+
let at;
|
|
142
|
+
while ((at = stderr.indexOf('\n')) >= 0) {
|
|
143
|
+
const line = stderr.slice(0, at);
|
|
144
|
+
stderr = stderr.slice(at + 1);
|
|
145
|
+
try {
|
|
146
|
+
const message = JSON.parse(line);
|
|
147
|
+
if (typeof message.temporary === 'string') {
|
|
148
|
+
if (message.active)
|
|
149
|
+
temporaries.add(message.temporary);
|
|
150
|
+
else
|
|
151
|
+
temporaries.delete(message.temporary);
|
|
152
|
+
}
|
|
153
|
+
else if (typeof message.error === 'string')
|
|
154
|
+
workerError = message.error;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
workerError = line;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (stderr.length > FILE_FRAME_BYTES) {
|
|
161
|
+
workerError = 'oversized worker metadata';
|
|
162
|
+
fail(new Error(workerError));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
control(client, { op: 'ready' });
|
|
166
|
+
if (request.action === 'import') {
|
|
167
|
+
worker.stdout.resume();
|
|
168
|
+
armIdle();
|
|
169
|
+
await pipeline(input, worker.stdin, { signal: abort.signal });
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
worker.stdin.end(request.action === 'digest' && request.selected ? JSON.stringify(selection) : '');
|
|
173
|
+
let digest = '';
|
|
174
|
+
for await (const chunk of worker.stdout) {
|
|
175
|
+
if (request.action === 'digest') {
|
|
176
|
+
digest += String(chunk);
|
|
177
|
+
if (digest.length > 8192)
|
|
178
|
+
throw new Error('oversized digest response');
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const data = raw(chunk);
|
|
182
|
+
for (let at = 0; at < data.length; at += FILE_FRAME_BYTES) {
|
|
183
|
+
abort.signal.throwIfAborted();
|
|
184
|
+
ack = wait();
|
|
185
|
+
void ack.promise.catch(() => { });
|
|
186
|
+
armIdle();
|
|
187
|
+
await send(client, data.subarray(at, at + FILE_FRAME_BYTES));
|
|
188
|
+
await ack.promise;
|
|
189
|
+
clearTimeout(idleTimer);
|
|
190
|
+
ack = undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (request.action === 'digest' && digest)
|
|
194
|
+
result.digest = JSON.parse(digest);
|
|
195
|
+
}
|
|
196
|
+
const outcome = await worker.done;
|
|
197
|
+
abort.signal.throwIfAborted();
|
|
198
|
+
if (outcome.exit !== 0)
|
|
199
|
+
throw new Error(workerError ?? worker.diagnostic() ?? 'binary file worker failed');
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
const reason = abort.signal.aborted ? abort.signal.reason : error;
|
|
203
|
+
result = { op: 'error', error: reason instanceof Error ? reason.message : String(reason) };
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await finalize(result);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
client.on('message', (data, binary) => {
|
|
210
|
+
if (abort.signal.aborted || finalizing)
|
|
211
|
+
return;
|
|
212
|
+
try {
|
|
213
|
+
const bytes = raw(data);
|
|
214
|
+
if (bytes.length > FILE_FRAME_BYTES)
|
|
215
|
+
throw new Error('binary frame limit exceeded');
|
|
216
|
+
if (binary) {
|
|
217
|
+
if (!started || request?.action !== 'import' || writing || ended)
|
|
218
|
+
throw new Error('unexpected binary frame or peer exceeded ACK window');
|
|
219
|
+
writing = true;
|
|
220
|
+
armIdle();
|
|
221
|
+
void (async () => {
|
|
222
|
+
try {
|
|
223
|
+
if (!input.write(bytes))
|
|
224
|
+
await once(input, 'drain', { signal: abort.signal });
|
|
225
|
+
writing = false;
|
|
226
|
+
control(client, { op: 'ack' });
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
fail(error);
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const message = JSON.parse(bytes.toString());
|
|
235
|
+
if (message.op === 'cancel') {
|
|
236
|
+
fail(new Error('binary file transfer cancelled'));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (message.op === 'renew') {
|
|
240
|
+
if (!started || message.token !== token || performance.now() >= expires)
|
|
241
|
+
throw new Error('binary file lease expired or superseded');
|
|
242
|
+
expires = Math.min(expires + TASK_RENEW_STEP_MS, performance.now() + TASK_LEASE_MS);
|
|
243
|
+
token = randomUUID();
|
|
244
|
+
armLease();
|
|
245
|
+
control(client, { op: 'lease', lease: lease() });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (message.op === 'ack') {
|
|
249
|
+
if (!ack)
|
|
250
|
+
throw new Error('unexpected binary ACK');
|
|
251
|
+
ack.resolve();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (message.op === 'end') {
|
|
255
|
+
if (!started || request?.action !== 'import' || writing || ended)
|
|
256
|
+
throw new Error('unexpected binary end');
|
|
257
|
+
ended = true;
|
|
258
|
+
input.end();
|
|
259
|
+
clearTimeout(idleTimer);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (started)
|
|
263
|
+
throw new Error('unexpected control after begin');
|
|
264
|
+
if (message.op === 'open') {
|
|
265
|
+
if (request || !['export', 'import', 'digest'].includes(message.action) || typeof message.path !== 'string' || (message.mkdir !== undefined && typeof message.mkdir !== 'boolean'))
|
|
266
|
+
throw new Error('invalid binary file request');
|
|
267
|
+
checkAbsolute(message.path);
|
|
268
|
+
request = { action: message.action, path: message.path, mkdir: message.mkdir ?? false, selected: message.selected === true };
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (message.op === 'select') {
|
|
272
|
+
if (request?.action !== 'digest' || typeof message.path !== 'string' || selection.length >= FILE_MAX_ENTRIES)
|
|
273
|
+
throw new Error('invalid selection');
|
|
274
|
+
metadata += bytes.length;
|
|
275
|
+
if (metadata > FILE_MAX_METADATA)
|
|
276
|
+
throw new Error('selection metadata limit exceeded');
|
|
277
|
+
selection.push(message.path);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (message.op === 'begin' && request) {
|
|
281
|
+
if (message.token !== token || performance.now() >= expires)
|
|
282
|
+
throw new Error('binary file preparation expired; nothing started');
|
|
283
|
+
void run();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
throw new Error('unknown binary file control');
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
fail(error);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
armLease();
|
|
293
|
+
control(client, { op: 'prepared', lease: lease() });
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
const unregister = real.registerUpgrade({ path: REMOTE_FILE_PATH, handler });
|
|
297
|
+
return async () => {
|
|
298
|
+
disposed = true;
|
|
299
|
+
unregister();
|
|
300
|
+
const owned = [...operations];
|
|
301
|
+
for (const task of owned)
|
|
302
|
+
task.stop(new Error('Hub file receiver disposed'));
|
|
303
|
+
await Promise.all(owned.map(task => task.done));
|
|
304
|
+
server.close();
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/** Completion owns lease cleanup, local socket closure and (for import) the supplied source FD. */
|
|
308
|
+
export function createRemoteFileEndpoint(connection, providerSignal, track = () => { }) {
|
|
309
|
+
const open = async (action, path, signal, mkdir = false, selection) => {
|
|
310
|
+
checkAbsolute(path);
|
|
311
|
+
if (selection)
|
|
312
|
+
checkSelection(selection);
|
|
313
|
+
signal = providerSignal ? AbortSignal.any([signal, providerSignal]) : signal;
|
|
314
|
+
signal.throwIfAborted();
|
|
315
|
+
const target = connection(), socket = new ws.WebSocket(target.origin.replace(/^http/, 'ws') + REMOTE_FILE_PATH, { headers: { Cookie: target.cookie }, maxPayload: FILE_FRAME_BYTES, perMessageDeflate: false, handshakeTimeout: 5000 });
|
|
316
|
+
const ready = wait(), done = wait(), cleaned = wait();
|
|
317
|
+
let failure, terminal = false, started = false, acknowledged = false, acknowledgedAt = 0, lease, cleanupDeadline = 0, nextRenewAt = 0;
|
|
318
|
+
let ack, next = deferred(), queued, reading = false, digest;
|
|
319
|
+
let sourceRef, timer, renewTimer, renewDeadline, prepareTimer;
|
|
320
|
+
const parseLease = (value) => {
|
|
321
|
+
if (!value || value.protocol !== FILE_LEASE_PROTOCOL || value.leaseMs !== TASK_LEASE_MS || value.renewStepMs !== TASK_RENEW_STEP_MS || value.cleanupMs !== FILE_CLEANUP_MS || typeof value.token !== 'string')
|
|
322
|
+
throw new Error('Hub binary file bounded lease protocol unavailable/unsupported');
|
|
323
|
+
return value;
|
|
324
|
+
};
|
|
325
|
+
const stopRenewing = () => { clearTimeout(renewTimer); clearTimeout(renewDeadline); clearTimeout(prepareTimer); };
|
|
326
|
+
const confirm = () => {
|
|
327
|
+
if (!acknowledged) {
|
|
328
|
+
acknowledged = true;
|
|
329
|
+
acknowledgedAt = performance.now();
|
|
330
|
+
cleaned.resolve();
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const end = (error) => {
|
|
334
|
+
if (terminal)
|
|
335
|
+
return;
|
|
336
|
+
terminal = true;
|
|
337
|
+
failure = error;
|
|
338
|
+
stopRenewing();
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
if (error) {
|
|
341
|
+
sourceRef?.destroy(error);
|
|
342
|
+
ack?.reject(error);
|
|
343
|
+
next.reject(error);
|
|
344
|
+
if (started)
|
|
345
|
+
control(socket, { op: 'cancel' });
|
|
346
|
+
}
|
|
347
|
+
else
|
|
348
|
+
next.resolve(null);
|
|
349
|
+
void (async () => {
|
|
350
|
+
try {
|
|
351
|
+
if (started && !acknowledged) {
|
|
352
|
+
let deadline;
|
|
353
|
+
await Promise.race([cleaned.promise, new Promise(resolve => { deadline = setTimeout(resolve, Math.max(0, cleanupDeadline - performance.now())); })]);
|
|
354
|
+
clearTimeout(deadline);
|
|
355
|
+
}
|
|
356
|
+
// A cleanup ACK covers files/worker. The receiver also owns a bounded closing socket;
|
|
357
|
+
// a proxy may forward ACK but swallow close, so account that explicit resource bound.
|
|
358
|
+
if (acknowledged)
|
|
359
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(0, acknowledgedAt + FILE_SOCKET_CLOSE_MS - performance.now())));
|
|
360
|
+
await closeSocket(socket);
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
signal.removeEventListener('abort', cancel);
|
|
364
|
+
if (failure) {
|
|
365
|
+
ready.reject(failure);
|
|
366
|
+
done.reject(failure);
|
|
367
|
+
}
|
|
368
|
+
else
|
|
369
|
+
done.resolve();
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
};
|
|
373
|
+
const cancel = () => end(new Error('binary transfer cancelled'));
|
|
374
|
+
const renew = () => {
|
|
375
|
+
if (terminal || !lease)
|
|
376
|
+
return;
|
|
377
|
+
renewTimer = setTimeout(() => {
|
|
378
|
+
if (terminal || !lease)
|
|
379
|
+
return;
|
|
380
|
+
// Count a possibly applied request BEFORE sending, even if its response is lost/delayed.
|
|
381
|
+
cleanupDeadline += TASK_RENEW_STEP_MS;
|
|
382
|
+
control(socket, { op: 'renew', token: lease.token });
|
|
383
|
+
renewDeadline = setTimeout(() => end(new Error('Hub binary file task renewal unavailable')), TASK_CONTROL_TIMEOUT_MS);
|
|
384
|
+
}, Math.max(0, nextRenewAt - performance.now()));
|
|
385
|
+
};
|
|
386
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
387
|
+
void ready.promise.catch(() => { });
|
|
388
|
+
void done.promise.catch(() => { });
|
|
389
|
+
void next.promise.catch(() => { });
|
|
390
|
+
track(done.promise.catch(() => { }));
|
|
391
|
+
socket.on('error', (error) => end(new Error(`Hub binary file lane unavailable/unsupported: ${error.message}`)));
|
|
392
|
+
socket.on('close', () => {
|
|
393
|
+
if (!terminal)
|
|
394
|
+
end(new Error('Hub binary file peer disconnected before completion'));
|
|
395
|
+
});
|
|
396
|
+
socket.on('open', () => {
|
|
397
|
+
if (signal.aborted) {
|
|
398
|
+
cancel();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
;
|
|
402
|
+
prepareTimer = setTimeout(() => end(new Error('Hub binary file bounded lease preparation unavailable/unsupported')), TASK_CONTROL_TIMEOUT_MS);
|
|
403
|
+
});
|
|
404
|
+
socket.on('message', (data, binary) => {
|
|
405
|
+
try {
|
|
406
|
+
const bytes = raw(data);
|
|
407
|
+
if (binary) {
|
|
408
|
+
if (terminal)
|
|
409
|
+
return;
|
|
410
|
+
if (action !== 'export' || queued !== undefined)
|
|
411
|
+
throw new Error('unexpected binary frame/ACK window');
|
|
412
|
+
if (reading) {
|
|
413
|
+
reading = false;
|
|
414
|
+
next.resolve(bytes);
|
|
415
|
+
}
|
|
416
|
+
else
|
|
417
|
+
queued = bytes;
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const message = JSON.parse(bytes.toString());
|
|
421
|
+
if (message.op === 'done' || message.op === 'error') {
|
|
422
|
+
if (message.cleaned === true)
|
|
423
|
+
confirm();
|
|
424
|
+
if (!terminal) {
|
|
425
|
+
digest = message.digest;
|
|
426
|
+
end(message.op === 'error' ? new Error(String(message.error)) : signal.aborted ? new Error('binary transfer cancelled') : undefined);
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (terminal)
|
|
431
|
+
return;
|
|
432
|
+
if (message.op === 'prepared') {
|
|
433
|
+
if (lease)
|
|
434
|
+
throw new Error('duplicate file preparation');
|
|
435
|
+
lease = parseLease(message.lease);
|
|
436
|
+
cleanupDeadline = performance.now() + TASK_LEASE_MS + FILE_CLEANUP_MS;
|
|
437
|
+
clearTimeout(prepareTimer);
|
|
438
|
+
control(socket, { op: 'open', action, path, mkdir, selected: selection !== undefined });
|
|
439
|
+
for (const entry of selection ?? [])
|
|
440
|
+
control(socket, { op: 'select', path: entry });
|
|
441
|
+
// No receiving filesystem operation can start without this prepared, bounded token.
|
|
442
|
+
started = true;
|
|
443
|
+
nextRenewAt = performance.now() + TASK_RENEW_STEP_MS;
|
|
444
|
+
control(socket, { op: 'begin', token: lease.token });
|
|
445
|
+
renew();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (message.op === 'lease') {
|
|
449
|
+
lease = parseLease(message.lease);
|
|
450
|
+
clearTimeout(renewDeadline);
|
|
451
|
+
nextRenewAt += TASK_RENEW_STEP_MS;
|
|
452
|
+
renew();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (message.op === 'ready') {
|
|
456
|
+
if (!started)
|
|
457
|
+
throw new Error('unprepared file receiver');
|
|
458
|
+
ready.resolve();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (message.op === 'ack') {
|
|
462
|
+
if (!ack)
|
|
463
|
+
throw new Error('unexpected file ACK');
|
|
464
|
+
ack.resolve();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
throw new Error('unknown binary file response');
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
end(error instanceof Error ? error : new Error(String(error)));
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
await ready.promise;
|
|
474
|
+
return {
|
|
475
|
+
done: done.promise, digest: () => digest,
|
|
476
|
+
async *read() {
|
|
477
|
+
try {
|
|
478
|
+
while (true) {
|
|
479
|
+
if (failure)
|
|
480
|
+
throw failure;
|
|
481
|
+
if (terminal)
|
|
482
|
+
break;
|
|
483
|
+
let chunk;
|
|
484
|
+
if (queued !== undefined) {
|
|
485
|
+
chunk = queued;
|
|
486
|
+
queued = undefined;
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
reading = true;
|
|
490
|
+
chunk = await next.promise;
|
|
491
|
+
}
|
|
492
|
+
;
|
|
493
|
+
next = deferred();
|
|
494
|
+
void next.promise.catch(() => { });
|
|
495
|
+
if (chunk === null)
|
|
496
|
+
break;
|
|
497
|
+
yield chunk;
|
|
498
|
+
control(socket, { op: 'ack' });
|
|
499
|
+
}
|
|
500
|
+
await done.promise;
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
if (!terminal)
|
|
504
|
+
cancel();
|
|
505
|
+
await done.promise.catch(() => { });
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
async write(source) {
|
|
509
|
+
sourceRef = source;
|
|
510
|
+
if (failure) {
|
|
511
|
+
sourceRef.destroy();
|
|
512
|
+
await done.promise;
|
|
513
|
+
throw failure;
|
|
514
|
+
}
|
|
515
|
+
const destroy = () => sourceRef?.destroy(new Error('binary transfer cancelled'));
|
|
516
|
+
signal.addEventListener('abort', destroy, { once: true });
|
|
517
|
+
try {
|
|
518
|
+
for await (const item of source) {
|
|
519
|
+
const b = raw(item);
|
|
520
|
+
for (let at = 0; at < b.length; at += FILE_FRAME_BYTES) {
|
|
521
|
+
signal.throwIfAborted();
|
|
522
|
+
ack = wait();
|
|
523
|
+
void ack.promise.catch(() => { });
|
|
524
|
+
await send(socket, b.subarray(at, at + FILE_FRAME_BYTES));
|
|
525
|
+
timer = setTimeout(() => end(new Error('Hub binary file ACK timeout')), FILE_IDLE_MS);
|
|
526
|
+
await ack.promise;
|
|
527
|
+
clearTimeout(timer);
|
|
528
|
+
ack = undefined;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
control(socket, { op: 'end' });
|
|
532
|
+
await done.promise;
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
if (!terminal)
|
|
536
|
+
end(error instanceof Error ? error : new Error(String(error)));
|
|
537
|
+
await done.promise.catch(() => { });
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
finally {
|
|
541
|
+
sourceRef = undefined;
|
|
542
|
+
signal.removeEventListener('abort', destroy);
|
|
543
|
+
source.destroy();
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
return {
|
|
549
|
+
async exportTree(path, signal) { const request = await open('export', path, signal); return Readable.from(request.read(), { objectMode: false, highWaterMark: FILE_FRAME_BYTES }); },
|
|
550
|
+
importTree(path, source, { mkdir, signal }) {
|
|
551
|
+
const work = (async () => {
|
|
552
|
+
const closed = finished(source, { cleanup: true }).catch(() => { });
|
|
553
|
+
try {
|
|
554
|
+
const request = await open('import', path, signal, mkdir);
|
|
555
|
+
await request.write(source);
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
source.destroy();
|
|
559
|
+
await closed;
|
|
560
|
+
}
|
|
561
|
+
})();
|
|
562
|
+
track(work.then(() => { }, () => { }));
|
|
563
|
+
return work;
|
|
564
|
+
},
|
|
565
|
+
async digest(path, signal, selection) {
|
|
566
|
+
const request = await open('digest', path, signal, false, selection);
|
|
567
|
+
await request.done;
|
|
568
|
+
const d = request.digest();
|
|
569
|
+
if (!d || !/^[a-f0-9]{64}$/.test(d.sha256) || !Number.isSafeInteger(d.bytes) || !Number.isSafeInteger(d.entries))
|
|
570
|
+
throw new Error('invalid binary digest response');
|
|
571
|
+
return d;
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type PreparedRemoteInput, type RemoteProcess } from '../../remote/plugin.js';
|
|
2
|
+
/** Event-driven output; the only periodic operation renews this task's bounded resource lease. */
|
|
3
|
+
export declare function startRemoteProcess(origin: string, cookie: string, input: PreparedRemoteInput, owner: string): RemoteProcess;
|