@twinsearth/w2m-dsh-plugin 2.0.8 → 2.0.10
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +13 -0
- package/TEST-CASES-JOINT-FLEET-2.0.8.md +119 -0
- package/TEST-PLAN-JOINT-FLEET-2.0.8.md +99 -0
- package/bin/remote-s2-capture.mjs +116 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/DSH-UPGRADE-2.0.8.md +129 -0
- package/docs/TRANSFER-LAYER.md +162 -0
- package/docs/acceptance/2.0.10/RELEASE-2100.json +19 -0
- package/docs/acceptance/2.0.9/RELEASE-2090.json +24 -0
- package/docs/adr/0002-p2p-transport.md +67 -0
- package/docs/plans/REMOTE-DESKTOP-2.1.0.md +153 -0
- package/lib/client.js +29 -3
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +7 -0
- package/src/agent/discovery.mjs +81 -0
- package/src/agent/iroh-sidecar.mjs +149 -0
- package/src/agent/p2p-node.mjs +11 -1
- package/src/agent/p2p-race.mjs +105 -0
- package/src/agent/p2p.mjs +105 -18
- package/src/agent/path-scheduler.mjs +106 -0
- package/src/agent/transfer/artifact-ref-v3.mjs +93 -0
- package/src/agent/transfer/ferry.mjs +12 -1
- package/src/agent/transfer/router.mjs +80 -0
- package/src/agent/transfer/want-block.mjs +46 -0
- package/src/agent/transport.mjs +146 -0
- package/src/plugin/tools.mjs +35 -25
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3 discovery seam: providers only produce candidate addresses.
|
|
3
|
+
*
|
|
4
|
+
* Current order is relay -> mDNS -> DHT. A candidate is never an authorisation:
|
|
5
|
+
* the dial path still verifies the peer's static key and the task identity.
|
|
6
|
+
* Adding a DHT sidecar later means implementing the same `discover(machineId)`
|
|
7
|
+
* shape; it does not change the transport or the relay.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DISCOVERY_SOURCE = Object.freeze({
|
|
11
|
+
RELAY: 'relay',
|
|
12
|
+
MDNS: 'mdns',
|
|
13
|
+
DHT: 'dht',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Order used only to break duplicate-address ties, not to trust a source. */
|
|
17
|
+
const SOURCE_RANK = Object.freeze({
|
|
18
|
+
[DISCOVERY_SOURCE.RELAY]: 0,
|
|
19
|
+
[DISCOVERY_SOURCE.MDNS]: 1,
|
|
20
|
+
[DISCOVERY_SOURCE.DHT]: 2,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const usable = (candidate) => candidate !== null
|
|
24
|
+
&& typeof candidate === 'object'
|
|
25
|
+
&& typeof candidate.address === 'string'
|
|
26
|
+
&& candidate.address !== ''
|
|
27
|
+
&& candidate.address !== '0.0.0.0'
|
|
28
|
+
&& candidate.address !== '::'
|
|
29
|
+
&& Number.isInteger(candidate.port)
|
|
30
|
+
&& candidate.port > 0
|
|
31
|
+
&& candidate.port <= 65535;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Merge candidates from every provider, deduplicating by address:port.
|
|
35
|
+
* The first source in rank order wins the label; the address itself is kept
|
|
36
|
+
* once. No provider can inject a non-address payload into this list.
|
|
37
|
+
*/
|
|
38
|
+
export function mergeCandidates(lists, { limit = 8 } = {}) {
|
|
39
|
+
const merged = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const sorted = [...lists].sort((a, b) => (SOURCE_RANK[a.source] ?? 9) - (SOURCE_RANK[b.source] ?? 9));
|
|
42
|
+
for (const list of sorted) {
|
|
43
|
+
for (const candidate of Array.isArray(list.candidates) ? list.candidates : []) {
|
|
44
|
+
if (!usable(candidate)) continue;
|
|
45
|
+
const key = candidate.address + ':' + candidate.port;
|
|
46
|
+
if (seen.has(key)) continue;
|
|
47
|
+
seen.add(key);
|
|
48
|
+
merged.push({ address: candidate.address, port: candidate.port, source: list.source ?? candidate.source ?? 'unknown' });
|
|
49
|
+
if (merged.length >= limit) return merged;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Relay discovery, built on the existing authenticated /v1/peer route. */
|
|
56
|
+
export function createRelayDiscovery({ getJson } = {}) {
|
|
57
|
+
if (typeof getJson !== 'function') return { ok: false, code: 'DISCOVERY_NO_GET_JSON' };
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
source: DISCOVERY_SOURCE.RELAY,
|
|
61
|
+
async discover(machineId) {
|
|
62
|
+
if (typeof machineId !== 'string' || machineId === '') return { ok: false, code: 'DISCOVERY_BAD_MACHINE_ID', candidates: [] };
|
|
63
|
+
const answer = await getJson('/v1/peer/' + encodeURIComponent(machineId));
|
|
64
|
+
if (answer?.ok !== true || answer?.status === 404) return { ok: false, code: 'DISCOVERY_RELAY_MISS', candidates: [] };
|
|
65
|
+
const candidates = Array.isArray(answer?.json?.peer?.candidates) ? answer.json.peer.candidates : [];
|
|
66
|
+
return { ok: true, source: DISCOVERY_SOURCE.RELAY, candidates, staticKey: answer?.json?.peer?.static_key ?? null };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A provider for tests and for an injected mDNS implementation. It carries no
|
|
73
|
+
* network logic, so the caller decides how candidates were observed.
|
|
74
|
+
*/
|
|
75
|
+
export function createStaticDiscovery({ source, candidates = [] } = {}) {
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
source: source ?? 'static',
|
|
79
|
+
async discover() { return { ok: true, source: source ?? 'static', candidates: candidates.slice() }; },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W1 spike: iroh sidecar transport (JSON over length-prefixed stdio).
|
|
3
|
+
*
|
|
4
|
+
* The real binary is intentionally not bundled or auto-downloaded: the sidecar
|
|
5
|
+
* command/args come from configuration and are verified by the caller. This
|
|
6
|
+
* module owns only the process lifecycle and the frame contract, so a fake
|
|
7
|
+
* sidecar can exercise every failure path in CI without Rust or a TLS relay.
|
|
8
|
+
*
|
|
9
|
+
* Wire (parent -> child and child -> parent): 4-byte big-endian length + UTF-8 JSON.
|
|
10
|
+
* { id, method, params } request
|
|
11
|
+
* { id, result } / { id, error } response
|
|
12
|
+
* { type: 'ready' } child is listening
|
|
13
|
+
* { type: 'message', peer, data } inbound payload (base64)
|
|
14
|
+
* { type: 'status', paths, ... } diagnostics
|
|
15
|
+
*/
|
|
16
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
export const IROH_TRANSPORT = 'iroh-v1';
|
|
19
|
+
export const IROH_CODE = Object.freeze({
|
|
20
|
+
START_FAILED: 'IROH_SIDECAR_START_FAILED',
|
|
21
|
+
TIMEOUT: 'IROH_TIMEOUT',
|
|
22
|
+
EXITED: 'IROH_SIDECAR_EXITED',
|
|
23
|
+
CLOSED: 'IROH_CLOSED',
|
|
24
|
+
BAD_FRAME: 'IROH_BAD_FRAME',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function encode(value) {
|
|
28
|
+
const body = Buffer.from(JSON.stringify(value), 'utf8');
|
|
29
|
+
const header = Buffer.alloc(4);
|
|
30
|
+
header.writeUInt32BE(body.length);
|
|
31
|
+
return Buffer.concat([header, body]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createIrohSidecarTransport({ command, args = [], env = {}, timeoutMs = 2500, spawnImpl = nodeSpawn } = {}) {
|
|
35
|
+
if (typeof command !== 'string' || command === '') {
|
|
36
|
+
return { ok: false, code: IROH_CODE.START_FAILED, detail: 'iroh sidecar command is required' };
|
|
37
|
+
}
|
|
38
|
+
const handlers = new Set();
|
|
39
|
+
const pending = new Map();
|
|
40
|
+
let child = null;
|
|
41
|
+
let buffer = Buffer.alloc(0);
|
|
42
|
+
let nextId = 1;
|
|
43
|
+
let ready = null;
|
|
44
|
+
let closed = false;
|
|
45
|
+
let lastStatus = { paths: [] };
|
|
46
|
+
|
|
47
|
+
const failPending = (code, detail) => {
|
|
48
|
+
for (const [id, entry] of pending) {
|
|
49
|
+
clearTimeout(entry.timer);
|
|
50
|
+
entry.reject(Object.assign(new Error(detail ?? code), { code }));
|
|
51
|
+
pending.delete(id);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const onFrame = (message) => {
|
|
56
|
+
if (message?.type === 'ready') { ready?.(); ready = null; return; }
|
|
57
|
+
if (message?.type === 'message') {
|
|
58
|
+
const payload = Buffer.from(String(message.data ?? ''), 'base64');
|
|
59
|
+
for (const handler of handlers) handler({ peer: message.peer ?? null, payload });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (message?.type === 'status') { lastStatus = { paths: Array.isArray(message.paths) ? message.paths : [] }; return; }
|
|
63
|
+
if (typeof message?.id === 'number' && pending.has(message.id)) {
|
|
64
|
+
const entry = pending.get(message.id);
|
|
65
|
+
clearTimeout(entry.timer);
|
|
66
|
+
pending.delete(message.id);
|
|
67
|
+
if (message.error) entry.reject(Object.assign(new Error(String(message.error.message ?? message.error)), { code: message.error.code ?? IROH_CODE.EXITED }));
|
|
68
|
+
else entry.resolve(message.result ?? null);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const onData = (chunk) => {
|
|
73
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
74
|
+
while (buffer.length >= 4) {
|
|
75
|
+
const length = buffer.readUInt32BE(0);
|
|
76
|
+
if (buffer.length < 4 + length) return;
|
|
77
|
+
const body = buffer.subarray(4, 4 + length);
|
|
78
|
+
buffer = buffer.subarray(4 + length);
|
|
79
|
+
try { onFrame(JSON.parse(body.toString('utf8'))); } catch { /* a bad frame is dropped, not fatal */ }
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const rpc = (method, params = {}) => new Promise((resolve, reject) => {
|
|
84
|
+
if (closed || child === null) { reject(Object.assign(new Error(IROH_CODE.CLOSED), { code: IROH_CODE.CLOSED })); return; }
|
|
85
|
+
const id = nextId;
|
|
86
|
+
nextId += 1;
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
pending.delete(id);
|
|
89
|
+
reject(Object.assign(new Error(IROH_CODE.TIMEOUT), { code: IROH_CODE.TIMEOUT }));
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
pending.set(id, { resolve, reject, timer });
|
|
93
|
+
child.stdin.write(encode({ id, method, params }));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const transport = {
|
|
97
|
+
name: IROH_TRANSPORT,
|
|
98
|
+
async start() {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
try {
|
|
101
|
+
child = spawnImpl(command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: { ...process.env, ...env } });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: error instanceof Error ? error.message : String(error) });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const timer = setTimeout(() => { resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: IROH_CODE.TIMEOUT }); }, timeoutMs);
|
|
107
|
+
ready = () => { clearTimeout(timer); resolve({ ok: true, name: IROH_TRANSPORT }); };
|
|
108
|
+
child.stdout.on('data', onData);
|
|
109
|
+
child.stderr.on('data', () => {});
|
|
110
|
+
child.on('exit', () => {
|
|
111
|
+
closed = true;
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
failPending(IROH_CODE.EXITED, 'iroh sidecar exited');
|
|
114
|
+
if (ready !== null) { ready = null; resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: 'sidecar exited before ready' }); }
|
|
115
|
+
});
|
|
116
|
+
child.on('error', (error) => {
|
|
117
|
+
closed = true;
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: error.message });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
async send(peer, payload) {
|
|
124
|
+
if (closed || child === null) return { ok: false, code: IROH_CODE.CLOSED };
|
|
125
|
+
try {
|
|
126
|
+
await rpc('send', { peer, data: Buffer.from(payload).toString('base64') });
|
|
127
|
+
return { ok: true, peer };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return { ok: false, code: error.code ?? IROH_CODE.EXITED, detail: error.message };
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
onMessage(handler) {
|
|
133
|
+
if (typeof handler !== 'function') return () => {};
|
|
134
|
+
handlers.add(handler);
|
|
135
|
+
return () => handlers.delete(handler);
|
|
136
|
+
},
|
|
137
|
+
paths() { return lastStatus.paths.map((path) => ({ ...path, transport: IROH_TRANSPORT })); },
|
|
138
|
+
diagnostics() { return { name: IROH_TRANSPORT, closed, pending: pending.size, paths: lastStatus.paths.length }; },
|
|
139
|
+
async stop() {
|
|
140
|
+
if (closed) return { ok: true };
|
|
141
|
+
closed = true;
|
|
142
|
+
failPending(IROH_CODE.CLOSED, 'transport stopped');
|
|
143
|
+
try { child?.stdin.end(); child?.kill(); } catch { /* already gone */ }
|
|
144
|
+
child = null;
|
|
145
|
+
return { ok: true };
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
return { ok: true, transport };
|
|
149
|
+
}
|
package/src/agent/p2p-node.mjs
CHANGED
|
@@ -1232,7 +1232,7 @@ export class P2PNode extends EventEmitter {
|
|
|
1232
1232
|
* that publishes no key is genuinely an older build and `PLAINTEXT_PEER` is honest; a peer that
|
|
1233
1233
|
* publishes one we had not learned is *our* gap, and after this it is not a gap at all.
|
|
1234
1234
|
*/
|
|
1235
|
-
let peerKey = this.peerKeys.get(machineId);
|
|
1235
|
+
let peerKey = typeof options.peerKey === 'string' && options.peerKey !== '' ? options.peerKey : this.peerKeys.get(machineId);
|
|
1236
1236
|
if (peerKey === null && announcedStaticKey !== null) {
|
|
1237
1237
|
this.peerKeys.set(machineId, announcedStaticKey, 'relay');
|
|
1238
1238
|
peerKey = announcedStaticKey;
|
|
@@ -1497,6 +1497,16 @@ export class P2PNode extends EventEmitter {
|
|
|
1497
1497
|
* @param {import('./p2p.mjs').P2PChannel} channel
|
|
1498
1498
|
* @param {{from:string|null, session:number}} details
|
|
1499
1499
|
*/
|
|
1500
|
+
/** Close and forget a cached channel so a fresh dial can arm a new handshake. */
|
|
1501
|
+
dropChannel(session) {
|
|
1502
|
+
const channel = this.channels.get(session);
|
|
1503
|
+
if (channel === undefined) return false;
|
|
1504
|
+
this.channels.delete(session);
|
|
1505
|
+
this.claimed.delete(session);
|
|
1506
|
+
try { channel.close('replaced-by-rehandshake', { silent: true }); } catch { /* already gone */ }
|
|
1507
|
+
return true;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1500
1510
|
adoptChannel(channel, details) {
|
|
1501
1511
|
const previous = this.channels.get(details.session);
|
|
1502
1512
|
if (previous !== undefined && previous !== channel) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-transport race: capability negotiation + first-usable-path wins.
|
|
3
|
+
*
|
|
4
|
+
* The control plane never waits for this. A dispatch goes through the relay
|
|
5
|
+
* immediately; this race only decides which data-channel carries results.
|
|
6
|
+
* There is deliberately no relay fallback here: if every transport loses, the
|
|
7
|
+
* caller gets ALL_TRANSPORTS_FAILED and the task result stays on the control
|
|
8
|
+
* plane, which is a different (and already-working) path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const TRANSPORT_PRIORITY = Object.freeze(['iroh', 'udp']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Intersect the local and remote capability lists.
|
|
15
|
+
* A peer that declares nothing is an older build and gets the UDP path only.
|
|
16
|
+
*
|
|
17
|
+
* @param {{local?: string[], remote?: string[]|null, priority?: string[]}} input
|
|
18
|
+
* @returns {string[]} ordered transport names both ends can attempt
|
|
19
|
+
*/
|
|
20
|
+
export function negotiateTransports({ local = ['udp'], remote = null, priority = TRANSPORT_PRIORITY } = {}) {
|
|
21
|
+
const localSet = new Set(local.filter((name) => typeof name === 'string'));
|
|
22
|
+
const remoteSet = remote === null ? new Set(['udp']) : new Set(remote.filter((name) => typeof name === 'string'));
|
|
23
|
+
return priority.filter((name) => localSet.has(name) && remoteSet.has(name));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run every candidate's start() concurrently and resolve with the first channel.
|
|
28
|
+
* The losers are aborted as soon as a winner exists (or the total budget expires).
|
|
29
|
+
*
|
|
30
|
+
* @param {{
|
|
31
|
+
* candidates: Array<{name: string, timeoutMs?: number, start: (ctx: {signal: AbortSignal}) => Promise<any>}>,
|
|
32
|
+
* budgetMs?: number,
|
|
33
|
+
* now?: () => number,
|
|
34
|
+
* }} input
|
|
35
|
+
* @returns {Promise<{ok: true, name: string, channel: any, attempts: object[], elapsedMs: number}
|
|
36
|
+
* | {ok: false, code: 'ALL_TRANSPORTS_FAILED'|'NO_TRANSPORTS', attempts: object[], elapsedMs: number}>}
|
|
37
|
+
*/
|
|
38
|
+
export async function raceTransports({ candidates, budgetMs = 3000, now = Date.now } = {}) {
|
|
39
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
40
|
+
return { ok: false, code: 'NO_TRANSPORTS', attempts: [], elapsedMs: 0 };
|
|
41
|
+
}
|
|
42
|
+
const startedAt = now();
|
|
43
|
+
const attempts = [];
|
|
44
|
+
const controllers = new Map();
|
|
45
|
+
let settled = false;
|
|
46
|
+
let resolveRace = null;
|
|
47
|
+
const race = new Promise((resolve) => { resolveRace = resolve; });
|
|
48
|
+
|
|
49
|
+
const finish = (result) => {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
for (const [name, controller] of controllers.entries()) {
|
|
53
|
+
if (result.ok === true && result.name === name) continue; // the winner keeps its channel
|
|
54
|
+
controller.abort();
|
|
55
|
+
}
|
|
56
|
+
resolveRace({ ...result, elapsedMs: now() - startedAt });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const budgetTimer = setTimeout(() => {
|
|
60
|
+
finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
|
|
61
|
+
}, budgetMs);
|
|
62
|
+
budgetTimer.unref?.();
|
|
63
|
+
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
if (typeof candidate?.start !== 'function') continue;
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
controllers.set(candidate.name, controller);
|
|
68
|
+
const record = { name: candidate.name, ok: false, code: null, elapsedMs: null };
|
|
69
|
+
attempts.push(record);
|
|
70
|
+
const started = now();
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
record.code = 'TRANSPORT_TIMEOUT';
|
|
73
|
+
record.elapsedMs = now() - started;
|
|
74
|
+
controller.abort();
|
|
75
|
+
if (attempts.every((attempt) => attempt.elapsedMs !== null) && attempts.every((attempt) => attempt.ok !== true)) {
|
|
76
|
+
finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
|
|
77
|
+
}
|
|
78
|
+
}, Number.isFinite(candidate.timeoutMs) ? candidate.timeoutMs : budgetMs);
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
Promise.resolve()
|
|
81
|
+
.then(() => candidate.start({ signal: controller.signal }))
|
|
82
|
+
.then((channel) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
if (settled) return;
|
|
85
|
+
record.ok = true;
|
|
86
|
+
record.elapsedMs = now() - started;
|
|
87
|
+
finish({ ok: true, name: candidate.name, channel, attempts });
|
|
88
|
+
})
|
|
89
|
+
.catch((error) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
if (settled) return;
|
|
92
|
+
record.code = error?.code ?? 'TRANSPORT_FAILED';
|
|
93
|
+
record.message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
record.elapsedMs = now() - started;
|
|
95
|
+
if (attempts.every((attempt) => attempt.ok !== true && attempt.elapsedMs !== null)) {
|
|
96
|
+
finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await race;
|
|
102
|
+
clearTimeout(budgetTimer);
|
|
103
|
+
if (result.ok !== true) return result;
|
|
104
|
+
return result;
|
|
105
|
+
}
|
package/src/agent/p2p.mjs
CHANGED
|
@@ -114,6 +114,42 @@ export const DEFAULT_WINDOW = 32;
|
|
|
114
114
|
export const DEFAULT_RTO_MS = 300;
|
|
115
115
|
export const MAX_RTO_MS = 3000;
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* RFC 6298 RTT estimator. The old channel used a constant 300 ms RTO, which is
|
|
119
|
+
* wrong on both a LAN (too slow) and a lossy WAN (too fast). This keeps SRTT and
|
|
120
|
+
* RTTVAR and clamps the result; callers still apply per-attempt exponential
|
|
121
|
+
* backoff on top.
|
|
122
|
+
*/
|
|
123
|
+
export class RttEstimator {
|
|
124
|
+
constructor({ initialRtoMs = DEFAULT_RTO_MS, minRtoMs = 100, maxRtoMs = MAX_RTO_MS } = {}) {
|
|
125
|
+
this.srtt = null;
|
|
126
|
+
this.rttvar = null;
|
|
127
|
+
this.minRtoMs = minRtoMs;
|
|
128
|
+
this.maxRtoMs = maxRtoMs;
|
|
129
|
+
this.rtoMs = Math.min(maxRtoMs, Math.max(minRtoMs, initialRtoMs));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
sample(rttMs) {
|
|
133
|
+
if (!Number.isFinite(rttMs) || rttMs < 0) return this.rtoMs;
|
|
134
|
+
if (this.srtt === null) {
|
|
135
|
+
this.srtt = rttMs;
|
|
136
|
+
this.rttvar = rttMs / 2;
|
|
137
|
+
} else {
|
|
138
|
+
this.rttvar = 0.75 * this.rttvar + 0.25 * Math.abs(this.srtt - rttMs);
|
|
139
|
+
this.srtt = 0.875 * this.srtt + 0.125 * rttMs;
|
|
140
|
+
}
|
|
141
|
+
this.rtoMs = Math.min(this.maxRtoMs, Math.max(this.minRtoMs, this.srtt + 4 * this.rttvar));
|
|
142
|
+
return this.rtoMs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
reset() {
|
|
146
|
+
this.srtt = null;
|
|
147
|
+
this.rttvar = null;
|
|
148
|
+
this.rtoMs = Math.min(this.maxRtoMs, Math.max(this.minRtoMs, DEFAULT_RTO_MS));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
117
153
|
/** Retransmits per fragment before the message is declared undeliverable. */
|
|
118
154
|
export const DEFAULT_MAX_ATTEMPTS = 8;
|
|
119
155
|
|
|
@@ -985,9 +1021,14 @@ export class P2PChannel extends EventEmitter {
|
|
|
985
1021
|
this.session = options.session;
|
|
986
1022
|
this.tuning = {
|
|
987
1023
|
window: options.tuning?.window ?? DEFAULT_WINDOW,
|
|
1024
|
+
maxWindow: options.tuning?.maxWindow ?? 256,
|
|
988
1025
|
rtoMs: options.tuning?.rtoMs ?? DEFAULT_RTO_MS,
|
|
989
1026
|
maxAttempts: options.tuning?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
990
1027
|
};
|
|
1028
|
+
/** Adaptive send window: starts at the tuning value, grows on completed messages. */
|
|
1029
|
+
this.window = this.tuning.window;
|
|
1030
|
+
/** RTT/RTO measured from ACK arrival, instead of the fixed 300 ms default. */
|
|
1031
|
+
this.rtt = new RttEstimator({ initialRtoMs: this.tuning.rtoMs, maxRtoMs: MAX_RTO_MS });
|
|
991
1032
|
|
|
992
1033
|
const crypto = options.crypto ?? null;
|
|
993
1034
|
/** @type {CryptoSession|null} */
|
|
@@ -1150,11 +1191,25 @@ export class P2PChannel extends EventEmitter {
|
|
|
1150
1191
|
const entry = this.outgoing.get(messageId);
|
|
1151
1192
|
if (!entry) return;
|
|
1152
1193
|
entry.acked.add(index);
|
|
1194
|
+
entry.inFlight.delete(index);
|
|
1195
|
+
// Karn: a fragment that was retransmitted has an ambiguous RTT, so only the
|
|
1196
|
+
// first transmission is sampled.
|
|
1197
|
+
if (entry.sentAt.has(index) && !entry.retransmitted.has(index)) {
|
|
1198
|
+
this.rtt.sample(Date.now() - entry.sentAt.get(index));
|
|
1199
|
+
entry.sentAt.delete(index);
|
|
1200
|
+
}
|
|
1153
1201
|
if (entry.acked.size === entry.fragments.length) {
|
|
1154
1202
|
this.outgoing.delete(messageId);
|
|
1155
1203
|
clearTimeout(entry.timer);
|
|
1204
|
+
// A completed message is the only honest RTT-free signal that the path holds more
|
|
1205
|
+
// than the previous window: grow conservatively, and halve on a retransmit timeout.
|
|
1206
|
+
this.window = Math.min(this.tuning.maxWindow, this.window + Math.max(1, Math.floor(this.window / 8)));
|
|
1156
1207
|
entry.resolve();
|
|
1208
|
+
return;
|
|
1157
1209
|
}
|
|
1210
|
+
// ACK-driven progress: the next window leaves as soon as a slot frees, instead of
|
|
1211
|
+
// waiting for the RTO timer (the 0.12 MiB/s ceiling).
|
|
1212
|
+
if (entry.pump !== null) entry.pump();
|
|
1158
1213
|
}
|
|
1159
1214
|
|
|
1160
1215
|
/**
|
|
@@ -1207,6 +1262,10 @@ export class P2PChannel extends EventEmitter {
|
|
|
1207
1262
|
const entry = {
|
|
1208
1263
|
fragments,
|
|
1209
1264
|
acked: new Set(),
|
|
1265
|
+
inFlight: new Set(),
|
|
1266
|
+
sentAt: new Map(),
|
|
1267
|
+
retransmitted: new Set(),
|
|
1268
|
+
pump: null,
|
|
1210
1269
|
resolve,
|
|
1211
1270
|
reject,
|
|
1212
1271
|
attempts: new Map(),
|
|
@@ -1215,45 +1274,73 @@ export class P2PChannel extends EventEmitter {
|
|
|
1215
1274
|
|
|
1216
1275
|
const pump = () => {
|
|
1217
1276
|
if (this.closed) return;
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
for (const index of pending) {
|
|
1227
|
-
const attempt = (entry.attempts.get(index) ?? 0) + 1;
|
|
1228
|
-
entry.attempts.set(index, attempt);
|
|
1277
|
+
// Send only what the current window has room for. inFlight is the honest
|
|
1278
|
+
// count of fragments the peer has not acknowledged yet; re-sending those
|
|
1279
|
+
// is the timer's job, not pump's.
|
|
1280
|
+
let budget = Math.max(1, this.window - entry.inFlight.size);
|
|
1281
|
+
for (let i = 0; i < fragments.length && budget > 0; i += 1) {
|
|
1282
|
+
if (entry.acked.has(i) || entry.inFlight.has(i)) continue;
|
|
1283
|
+
const attempt = (entry.attempts.get(i) ?? 0) + 1;
|
|
1284
|
+
entry.attempts.set(i, attempt);
|
|
1229
1285
|
if (attempt > this.tuning.maxAttempts) {
|
|
1230
1286
|
this.outgoing.delete(messageId);
|
|
1287
|
+
clearTimeout(entry.timer);
|
|
1288
|
+
this.window = Math.max(8, Math.floor(this.window / 2));
|
|
1231
1289
|
reject(
|
|
1232
1290
|
new Error(
|
|
1233
|
-
`P2P_FRAGMENT_UNACKED: fragment ${
|
|
1291
|
+
`P2P_FRAGMENT_UNACKED: fragment ${i}/${fragments.length} of message ${messageId} ` +
|
|
1234
1292
|
`after ${this.tuning.maxAttempts} attempts`,
|
|
1235
1293
|
),
|
|
1236
1294
|
);
|
|
1237
1295
|
return;
|
|
1238
1296
|
}
|
|
1239
|
-
|
|
1297
|
+
entry.inFlight.add(i);
|
|
1298
|
+
if (!entry.sentAt.has(i)) entry.sentAt.set(i, Date.now());
|
|
1299
|
+
budget -= 1;
|
|
1300
|
+
const body = Buffer.alloc(12 + fragments[i].length);
|
|
1240
1301
|
body.writeUInt32BE(messageId, 0);
|
|
1241
|
-
body.writeUInt32BE(
|
|
1302
|
+
body.writeUInt32BE(i, 4);
|
|
1242
1303
|
body.writeUInt32BE(fragments.length, 8);
|
|
1243
|
-
fragments[
|
|
1304
|
+
fragments[i].copy(body, 12);
|
|
1244
1305
|
this.sendFrame(KIND.DATA, body);
|
|
1245
1306
|
}
|
|
1246
1307
|
|
|
1247
|
-
|
|
1248
|
-
const
|
|
1308
|
+
if (entry.inFlight.size === 0) return;
|
|
1309
|
+
const worst = Math.max(...[...entry.inFlight].map((i) => entry.attempts.get(i) ?? 1));
|
|
1310
|
+
const delay = Math.min(this.rtt.rtoMs * 2 ** (worst - 1), MAX_RTO_MS);
|
|
1249
1311
|
// One timer per message, not one per channel: a shared field means a second
|
|
1250
1312
|
// concurrent send silently cancels the first one's retransmission schedule.
|
|
1251
1313
|
clearTimeout(entry.timer);
|
|
1252
1314
|
entry.timer = setTimeout(() => {
|
|
1253
|
-
if (this.outgoing.has(messageId))
|
|
1315
|
+
if (!this.outgoing.has(messageId)) return;
|
|
1316
|
+
// Retransmit the fragments that are still unacknowledged, then schedule again.
|
|
1317
|
+
for (const i of entry.inFlight) {
|
|
1318
|
+
entry.retransmitted.add(i);
|
|
1319
|
+
const attempt = (entry.attempts.get(i) ?? 0) + 1;
|
|
1320
|
+
entry.attempts.set(i, attempt);
|
|
1321
|
+
if (attempt > this.tuning.maxAttempts) {
|
|
1322
|
+
this.outgoing.delete(messageId);
|
|
1323
|
+
this.window = Math.max(8, Math.floor(this.window / 2));
|
|
1324
|
+
reject(
|
|
1325
|
+
new Error(
|
|
1326
|
+
`P2P_FRAGMENT_UNACKED: fragment ${i}/${fragments.length} of message ${messageId} ` +
|
|
1327
|
+
`after ${this.tuning.maxAttempts} attempts`,
|
|
1328
|
+
),
|
|
1329
|
+
);
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
const body = Buffer.alloc(12 + fragments[i].length);
|
|
1333
|
+
body.writeUInt32BE(messageId, 0);
|
|
1334
|
+
body.writeUInt32BE(i, 4);
|
|
1335
|
+
body.writeUInt32BE(fragments.length, 8);
|
|
1336
|
+
fragments[i].copy(body, 12);
|
|
1337
|
+
this.sendFrame(KIND.DATA, body);
|
|
1338
|
+
}
|
|
1339
|
+
pump();
|
|
1254
1340
|
}, delay);
|
|
1255
1341
|
entry.timer.unref?.();
|
|
1256
1342
|
};
|
|
1343
|
+
entry.pump = pump;
|
|
1257
1344
|
|
|
1258
1345
|
pump();
|
|
1259
1346
|
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PathScheduler: score candidate paths, and switch only on sustained evidence.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately pure policy: no sockets, no timers beyond the overlap
|
|
5
|
+
* deadline it reports. The caller opens the challenger, keeps the old path for
|
|
6
|
+
* `keepUntilMs` (make-before-break), then closes it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const PATH_KIND = Object.freeze({
|
|
10
|
+
IROH_DIRECT: 'iroh-direct',
|
|
11
|
+
UDP_DIRECT: 'udp-direct',
|
|
12
|
+
LIBP2P: 'libp2p',
|
|
13
|
+
IROH_RELAY: 'iroh-relay',
|
|
14
|
+
CONTROL_PLANE: 'control-plane',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Higher is better. The type weight dominates; latency and loss refine it. */
|
|
18
|
+
export const PATH_WEIGHT = Object.freeze({
|
|
19
|
+
[PATH_KIND.IROH_DIRECT]: 5,
|
|
20
|
+
[PATH_KIND.UDP_DIRECT]: 4,
|
|
21
|
+
[PATH_KIND.LIBP2P]: 3,
|
|
22
|
+
[PATH_KIND.IROH_RELAY]: 2,
|
|
23
|
+
[PATH_KIND.CONTROL_PLANE]: 1,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const SCHEDULER_CODE = Object.freeze({
|
|
27
|
+
OK: 'PATH_OK',
|
|
28
|
+
NOT_ENCRYPTED: 'PATH_NOT_ENCRYPTED',
|
|
29
|
+
NO_CANDIDATES: 'PATH_NO_CANDIDATES',
|
|
30
|
+
SWITCH_PENDING: 'PATH_SWITCH_PENDING',
|
|
31
|
+
ALL_FAILED: 'ALL_TRANSPORTS_FAILED',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{kind: string, encrypted: boolean, firstFrameRttMs: number, lossRate: number}} path
|
|
36
|
+
* @returns {number} score; encrypted paths outrank plaintext by a wide margin
|
|
37
|
+
*/
|
|
38
|
+
export function scorePath({ kind, encrypted, firstFrameRttMs = 0, lossRate = 0 }) {
|
|
39
|
+
const weight = PATH_WEIGHT[kind] ?? 0;
|
|
40
|
+
if (encrypted !== true) return -1;
|
|
41
|
+
if (!Number.isFinite(weight) || weight <= 0) return -1;
|
|
42
|
+
const rtt = Number.isFinite(firstFrameRttMs) ? Math.max(0, firstFrameRttMs) : 1000;
|
|
43
|
+
const loss = Number.isFinite(lossRate) ? Math.min(1, Math.max(0, lossRate)) : 1;
|
|
44
|
+
return weight * 1000 - rtt - loss * 2000;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A path switch is allowed only when:
|
|
49
|
+
* - the challenger is encrypted;
|
|
50
|
+
* - two consecutive samples beat the current path by > 20%;
|
|
51
|
+
* - the challenger's kind is at least as preferred as the current one
|
|
52
|
+
* (no switching down to a relay while a direct path is healthy).
|
|
53
|
+
*/
|
|
54
|
+
export class PathScheduler {
|
|
55
|
+
constructor({ overlapMs = 5000, improvement = 1.2, requiredSamples = 2, now = Date.now } = {}) {
|
|
56
|
+
this.overlapMs = overlapMs;
|
|
57
|
+
this.improvement = improvement;
|
|
58
|
+
this.requiredSamples = requiredSamples;
|
|
59
|
+
this.now = now;
|
|
60
|
+
this.current = null;
|
|
61
|
+
this.challenger = null;
|
|
62
|
+
this.leading = 0;
|
|
63
|
+
this.lastPlan = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setCurrent(path) {
|
|
67
|
+
this.current = path;
|
|
68
|
+
this.challenger = null;
|
|
69
|
+
this.leading = 0;
|
|
70
|
+
return this.current;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Record one challenger sample; returns a switch plan or null. */
|
|
74
|
+
observe(path) {
|
|
75
|
+
if (path?.encrypted !== true) return null;
|
|
76
|
+
const challengerScore = scorePath(path);
|
|
77
|
+
const currentScore = this.current === null ? Number.NEGATIVE_INFINITY : scorePath(this.current);
|
|
78
|
+
if (currentScore === Number.NEGATIVE_INFINITY) {
|
|
79
|
+
this.current = path;
|
|
80
|
+
return { switch: false, reason: 'first-path', path };
|
|
81
|
+
}
|
|
82
|
+
const sameChallenger = this.challenger !== null && this.challenger.id === path.id;
|
|
83
|
+
if (challengerScore > currentScore * this.improvement) {
|
|
84
|
+
this.challenger = path;
|
|
85
|
+
this.leading = sameChallenger ? this.leading + 1 : 1;
|
|
86
|
+
if (this.leading < this.requiredSamples) return null;
|
|
87
|
+
if ((PATH_WEIGHT[path.kind] ?? 0) < (PATH_WEIGHT[this.current.kind] ?? 0)) return null;
|
|
88
|
+
const plan = { switch: true, from: this.current, to: path, keepUntilMs: this.now() + this.overlapMs, reason: 'better-path' };
|
|
89
|
+
this.lastPlan = plan;
|
|
90
|
+
this.current = path;
|
|
91
|
+
this.challenger = null;
|
|
92
|
+
this.leading = 0;
|
|
93
|
+
return plan;
|
|
94
|
+
}
|
|
95
|
+
this.challenger = path;
|
|
96
|
+
this.leading = 0;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Close the loser only after the overlap window has elapsed. */
|
|
101
|
+
breakOld(plan, atMs = this.now()) {
|
|
102
|
+
if (plan?.switch !== true) return { ok: false, code: SCHEDULER_CODE.NOT_ENCRYPTED };
|
|
103
|
+
if (atMs < plan.keepUntilMs) return { ok: false, code: SCHEDULER_CODE.SWITCH_PENDING, closeAtMs: plan.keepUntilMs };
|
|
104
|
+
return { ok: true, close: plan.from };
|
|
105
|
+
}
|
|
106
|
+
}
|