@twinsearth/w2m-dsh-plugin 0.4.9 → 0.5.1
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/CHANGELOG.md +100 -0
- package/deploy/networking/LESSONS.md +90 -0
- package/deploy/networking/README.md +130 -0
- package/deploy/networking/acceptance.mjs +306 -0
- package/deploy/networking/wireguard/server-setup.sh +106 -0
- package/deploy/windows/install-service.ps1 +25 -1
- package/deploy/windows/w2m-agent-supervisor.mjs +31 -9
- package/package.json +1 -1
- package/src/agent/agent.mjs +474 -17
- package/src/agent/p2p-node.mjs +118 -23
- package/src/agent/p2p.mjs +137 -9
- package/src/plugin/tools.mjs +136 -15
package/src/agent/agent.mjs
CHANGED
|
@@ -49,7 +49,8 @@ import {
|
|
|
49
49
|
} from './git.mjs';
|
|
50
50
|
import { createSpool } from './spool.mjs';
|
|
51
51
|
import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
|
|
52
|
-
import { DEFAULT_P2P_MODE, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
52
|
+
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
53
|
+
import { punchSession, reverseDialSession } from './p2p.mjs';
|
|
53
54
|
import { createSigner } from '../signing.mjs';
|
|
54
55
|
import { joinUrl, resolveBaseUrl } from './url.mjs';
|
|
55
56
|
|
|
@@ -187,6 +188,42 @@ export const P2P_RESULT_ACK_TIMEOUT_MS = 2_000;
|
|
|
187
188
|
* rather than describe a 30-second wait.
|
|
188
189
|
*/
|
|
189
190
|
export const P2P_CHANNEL_LINGER_MS = 30_000;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Why this agent's own outbound dial did not open a channel (v0.4.x), with its own codes.
|
|
194
|
+
*
|
|
195
|
+
* These are reported in the result envelope's `p2p.reverse_dial_reason`, never thrown and never
|
|
196
|
+
* fatal: a reverse dial that fails changes nothing about the relay path, and "the punch timed out"
|
|
197
|
+
* and "there was nothing to punch at" are different facts that need different responses. A timeout
|
|
198
|
+
* is the ordinary outcome for a dispatcher behind a symmetric NAT that answered nothing -- the same
|
|
199
|
+
* code the dispatcher's own failed push reports (`P2P_PUNCH_TIMEOUT`), prefixed so a reader can tell
|
|
200
|
+
* which end dialled.
|
|
201
|
+
*/
|
|
202
|
+
export const P2P_REVERSE_DIAL_TIMEOUT = 'P2P_REVERSE_DIAL_TIMEOUT';
|
|
203
|
+
export const P2P_REVERSE_DIAL_FAILED = 'P2P_REVERSE_DIAL_FAILED';
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The plan code for "there is a live channel, so there is nothing to dial".
|
|
207
|
+
*
|
|
208
|
+
* Not a failure: it is the ordinary answer when the dispatcher's own push landed. It is a distinct
|
|
209
|
+
* code because the *result* path re-plans on it: a channel that existed when the offer arrived and is
|
|
210
|
+
* gone when the result is ready (the dispatcher closes the channel it pushed the offer over) gets the
|
|
211
|
+
* one reverse-dial attempt the contract allows, whereas every other skip reason stays skipped.
|
|
212
|
+
*/
|
|
213
|
+
export const P2P_CHANNEL_LIVE = 'P2P_CHANNEL_LIVE';
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The mechanism names that go into `p2p.opened_by`.
|
|
217
|
+
*
|
|
218
|
+
* Named rather than inferred at the reader's end: `result_path: 'p2p'` says the result travelled
|
|
219
|
+
* directly, and this says *what opened the path* -- the dispatcher's punch, this machine's dial at
|
|
220
|
+
* offer time (the simultaneous punch), or this machine's dial once the result was ready.
|
|
221
|
+
*/
|
|
222
|
+
export const P2P_OPENED_BY = Object.freeze({
|
|
223
|
+
DISPATCHER_PUNCH: 'dispatcher-punch',
|
|
224
|
+
SIMULTANEOUS_PUNCH: 'simultaneous-punch',
|
|
225
|
+
RESULT_REVERSE_DIAL: 'result-reverse-dial',
|
|
226
|
+
});
|
|
190
227
|
/**
|
|
191
228
|
* Upper bound on the stages a single offer may carry (v0.3.3 `pipeline`).
|
|
192
229
|
*
|
|
@@ -1003,6 +1040,50 @@ export function createAgent(options) {
|
|
|
1003
1040
|
*/
|
|
1004
1041
|
const p2pChannels = new Map();
|
|
1005
1042
|
|
|
1043
|
+
/**
|
|
1044
|
+
* The dispatcher address a task's channel actually travels to, remembered past the channel.
|
|
1045
|
+
*
|
|
1046
|
+
* This is the *observed* address for a channel that arrived inbound (`accept()` answers to the
|
|
1047
|
+
* `rinfo` of the HELLO it received, never to the address that peer announced) and the punched
|
|
1048
|
+
* address for one this agent opened. It outlives the channel on purpose: when the dispatcher
|
|
1049
|
+
* closes the channel it pushed the offer over -- which it does, right after the offer -- the
|
|
1050
|
+
* result still has somewhere to go, and the observed address is the one address a symmetric NAT
|
|
1051
|
+
* on the dispatcher's side is still holding a mapping for. Retyping the announced candidate
|
|
1052
|
+
* instead is the punch that already failed.
|
|
1053
|
+
*
|
|
1054
|
+
* @type {Map<string, {address: string, port: number}>}
|
|
1055
|
+
*/
|
|
1056
|
+
const p2pPeers = new Map();
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* One reverse-dial attempt per `task_id` — the executor's own half of a two-way punch.
|
|
1060
|
+
*
|
|
1061
|
+
* A record exists for every offer this agent takes on, including the ones it decides *not* to
|
|
1062
|
+
* dial for, and that is deliberate: "we did not try, because a channel was already live" and "we
|
|
1063
|
+
* tried and it timed out" must be distinguishable at result time, and a missing record must not
|
|
1064
|
+
* silently mean a second attempt. `promise` settles when the dial is over; it is awaited by the
|
|
1065
|
+
* result path (bounded by the same tuning window), never by the offer path.
|
|
1066
|
+
*
|
|
1067
|
+
* @type {Map<string, {task_id: string, attempted: boolean, ok: boolean, code: string|null,
|
|
1068
|
+
* reason: string|null, stage: 'offer'|'result', session: number|null, candidates: number,
|
|
1069
|
+
* channel: object|null, promise: Promise<object>|null, ms: number|null}>}
|
|
1070
|
+
*/
|
|
1071
|
+
const p2pReverseDials = new Map();
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* How long this agent may spend dialling a dispatcher, from the node's own tuning.
|
|
1075
|
+
*
|
|
1076
|
+
* Read from the node rather than fixed here so the number lives beside `punchTimeoutMs` and
|
|
1077
|
+
* `acceptTimeoutMs`: an operator tuning a slow path tunes one object, and a test that cannot wait
|
|
1078
|
+
* eight seconds injects one.
|
|
1079
|
+
*/
|
|
1080
|
+
function reverseDialTimeoutMs() {
|
|
1081
|
+
const configured = p2pNode?.tuning?.reverseDialTimeoutMs;
|
|
1082
|
+
return typeof configured === 'number' && Number.isFinite(configured) && configured > 0
|
|
1083
|
+
? configured
|
|
1084
|
+
: P2P_DEFAULTS.reverseDialTimeoutMs;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1006
1087
|
/** The agent's P2P facts, always a fresh plain object. */
|
|
1007
1088
|
function p2pStatusSnapshot() {
|
|
1008
1089
|
const nodeStatus = p2pNode?.status ?? null;
|
|
@@ -1193,6 +1274,17 @@ export function createAgent(options) {
|
|
|
1193
1274
|
|
|
1194
1275
|
/** Remember which channel a task's result should travel back over, and release it on close. */
|
|
1195
1276
|
function rememberChannel(taskId, channel) {
|
|
1277
|
+
const peer = channel?.peer;
|
|
1278
|
+
if (
|
|
1279
|
+
peer !== null &&
|
|
1280
|
+
typeof peer?.address === 'string' &&
|
|
1281
|
+
peer.address !== '' &&
|
|
1282
|
+
Number.isInteger(peer?.port) &&
|
|
1283
|
+
peer.port > 0
|
|
1284
|
+
) {
|
|
1285
|
+
// Kept even though this channel may be replaced or closed below: see `p2pPeers`.
|
|
1286
|
+
p2pPeers.set(taskId, { address: peer.address, port: peer.port });
|
|
1287
|
+
}
|
|
1196
1288
|
const previous = p2pChannels.get(taskId);
|
|
1197
1289
|
if (previous?.channel === channel) return;
|
|
1198
1290
|
if (previous) previous.channel.removeListener('close', previous.onClose);
|
|
@@ -1207,7 +1299,231 @@ export function createAgent(options) {
|
|
|
1207
1299
|
const channelFor = (taskId) => p2pChannels.get(taskId)?.channel ?? null;
|
|
1208
1300
|
|
|
1209
1301
|
/**
|
|
1210
|
-
*
|
|
1302
|
+
* What a reverse dial would need, or the named reason there is nothing to dial.
|
|
1303
|
+
*
|
|
1304
|
+
* WHY THE EXECUTOR DIALS AT ALL
|
|
1305
|
+
*
|
|
1306
|
+
* The dispatcher pushes first, and if it is behind a symmetric NAT (CGNAT, in practice) its HELLOs
|
|
1307
|
+
* are addressed from a mapped port that nobody was told about, so they reach nothing. Its own
|
|
1308
|
+
* outbound packet still opened a mapping *towards this machine*, though -- and that is half a path.
|
|
1309
|
+
* The other half is a packet from here, addressed at the dispatcher: the moment this machine sends
|
|
1310
|
+
* one, its own NAT lets the dispatcher's HELLO through and the second packet lands on a path that is
|
|
1311
|
+
* already open. Neither end can do it alone, which is why a peer that only ever accepted cannot
|
|
1312
|
+
* rescue a CGNAT initiator.
|
|
1313
|
+
*
|
|
1314
|
+
* WHERE THE ADDRESSES COME FROM
|
|
1315
|
+
*
|
|
1316
|
+
* The offer's own `p2p.candidates` when the dispatcher published them (it knows its own announced
|
|
1317
|
+
* addresses better than a relay lookup that may already have expired), with the address this task's
|
|
1318
|
+
* channel actually reached first when there was one, and the relay's `/v1/peer/{id}` answer as the
|
|
1319
|
+
* node's own fallback when neither is available.
|
|
1320
|
+
*
|
|
1321
|
+
* @param {object} offer
|
|
1322
|
+
* @returns {{ok:true, machineId:string, session:number, candidates:Array<object>, from:string}
|
|
1323
|
+
* |{ok:false, code:string, reason:string}}
|
|
1324
|
+
*/
|
|
1325
|
+
function reverseDialPlan(offer) {
|
|
1326
|
+
const taskId = typeof offer?.task_id === 'string' && offer.task_id !== '' ? offer.task_id : null;
|
|
1327
|
+
if (taskId === null) {
|
|
1328
|
+
return { ok: false, code: 'P2P_NO_TASK_ID', reason: 'P2P_NO_TASK_ID: the offer names no task to dial for' };
|
|
1329
|
+
}
|
|
1330
|
+
if (p2pMode === 'relay') {
|
|
1331
|
+
return {
|
|
1332
|
+
ok: false,
|
|
1333
|
+
code: 'P2P_DISABLED',
|
|
1334
|
+
reason: 'P2P_DISABLED: p2pMode is "relay", so this machine never dials (the relay path is the only path)',
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
if (p2pNode === null || p2pNode.status.running !== true) {
|
|
1338
|
+
return {
|
|
1339
|
+
ok: false,
|
|
1340
|
+
code: 'P2P_UNREACHABLE',
|
|
1341
|
+
reason: 'P2P_UNREACHABLE: the P2P node is not running, so no direct path can be dialled',
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
if (channelFor(taskId) !== null) {
|
|
1345
|
+
return {
|
|
1346
|
+
ok: false,
|
|
1347
|
+
code: P2P_CHANNEL_LIVE,
|
|
1348
|
+
reason: `P2P_CHANNEL_LIVE: a direct channel for ${taskId} is already open, so there is nothing to dial`,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const block = offer?.p2p;
|
|
1353
|
+
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
|
|
1354
|
+
return {
|
|
1355
|
+
ok: false,
|
|
1356
|
+
code: 'P2P_NO_SESSION',
|
|
1357
|
+
reason: 'P2P_NO_SESSION: the offer published no `p2p` block, so there is no session to join',
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
const dispatcher =
|
|
1361
|
+
typeof offer?.origin_machine_id === 'string' && offer.origin_machine_id !== ''
|
|
1362
|
+
? offer.origin_machine_id
|
|
1363
|
+
: null;
|
|
1364
|
+
if (dispatcher === null) {
|
|
1365
|
+
return {
|
|
1366
|
+
ok: false,
|
|
1367
|
+
code: 'P2P_NO_ORIGIN',
|
|
1368
|
+
reason: 'P2P_NO_ORIGIN: the offer does not name the machine that dispatched it, so there is nothing to dial',
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const machineId = identity.machine_id;
|
|
1373
|
+
const published = Array.isArray(block.candidates) ? block.candidates : [];
|
|
1374
|
+
let session = Number.isInteger(block.session) && block.session > 0 ? block.session : null;
|
|
1375
|
+
let from = 'offer session';
|
|
1376
|
+
if (session === null) {
|
|
1377
|
+
session = punchSession({ punch: block.punch, taskId, machineId });
|
|
1378
|
+
from = 'offer punch';
|
|
1379
|
+
}
|
|
1380
|
+
if (session === null && published.length > 0) {
|
|
1381
|
+
// A dispatcher that published where to dial but not which session: this end picks one and the
|
|
1382
|
+
// far end's `accept()` adopts it -- that asymmetry is what `allowSessionAdoption` exists for.
|
|
1383
|
+
session = reverseDialSession({ originMachineId: dispatcher, taskId, machineId });
|
|
1384
|
+
from = 'derived session';
|
|
1385
|
+
}
|
|
1386
|
+
if (session === null) {
|
|
1387
|
+
// A v0.4.x dispatcher: it published no punch, so there is no session to join and no address it
|
|
1388
|
+
// asked to be reached at. Dialling anyway would be a punch this end invented against a machine
|
|
1389
|
+
// that never said a two-way punch was in play, so the offer is treated exactly as it was before
|
|
1390
|
+
// this change -- the relay carries the result, and the reason says why.
|
|
1391
|
+
return {
|
|
1392
|
+
ok: false,
|
|
1393
|
+
code: 'P2P_NO_SESSION',
|
|
1394
|
+
reason:
|
|
1395
|
+
'P2P_NO_SESSION: the offer published no session or candidates, so there is nothing to dial ' +
|
|
1396
|
+
'(a v0.4.x dispatcher, whose relay copy is the delivery)',
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
const observed = p2pPeers.get(taskId) ?? null;
|
|
1401
|
+
return {
|
|
1402
|
+
ok: true,
|
|
1403
|
+
machineId: dispatcher,
|
|
1404
|
+
session,
|
|
1405
|
+
candidates: observed === null ? [...published] : [observed, ...published],
|
|
1406
|
+
from: observed === null ? from : `${from} + observed peer`,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Start (or reuse) this task's one reverse-dial attempt. Never awaited by the offer path.
|
|
1412
|
+
*
|
|
1413
|
+
* @param {object} offer
|
|
1414
|
+
* @param {{stage?: 'offer'|'result', afterLiveChannel?: boolean}} [options]
|
|
1415
|
+
* @returns {object|null} The record; `promise` settles when the dial is over.
|
|
1416
|
+
*/
|
|
1417
|
+
function startReverseDial(offer, options = {}) {
|
|
1418
|
+
const taskId = typeof offer?.task_id === 'string' && offer.task_id !== '' ? offer.task_id : null;
|
|
1419
|
+
if (taskId === null) return null;
|
|
1420
|
+
const stage = options.stage === 'result' ? 'result' : 'offer';
|
|
1421
|
+
|
|
1422
|
+
const existing = p2pReverseDials.get(taskId) ?? null;
|
|
1423
|
+
if (existing !== null) {
|
|
1424
|
+
// One attempt per task, with exactly one exception: a decision that said "a channel is already
|
|
1425
|
+
// live" is re-planned at result time, because that channel may be gone by then and the result
|
|
1426
|
+
// is what the dial exists for. Everything else -- attempted, disabled, unreachable, no session
|
|
1427
|
+
// -- is final, so a result never pays for a second eight-second wait.
|
|
1428
|
+
const rePlannable = existing.attempted !== true && existing.code === P2P_CHANNEL_LIVE;
|
|
1429
|
+
if (!(options.afterLiveChannel === true && rePlannable)) return existing;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const plan = reverseDialPlan(offer);
|
|
1433
|
+
/** @type {object} */
|
|
1434
|
+
const record = {
|
|
1435
|
+
task_id: taskId,
|
|
1436
|
+
attempted: plan.ok === true,
|
|
1437
|
+
ok: false,
|
|
1438
|
+
code: plan.ok === true ? null : plan.code,
|
|
1439
|
+
reason: plan.ok === true ? null : plan.reason,
|
|
1440
|
+
stage,
|
|
1441
|
+
session: plan.ok === true ? plan.session : null,
|
|
1442
|
+
candidates: plan.ok === true ? plan.candidates.length : 0,
|
|
1443
|
+
from: plan.ok === true ? plan.from : null,
|
|
1444
|
+
channel: null,
|
|
1445
|
+
promise: null,
|
|
1446
|
+
ms: null,
|
|
1447
|
+
};
|
|
1448
|
+
p2pReverseDials.set(taskId, record);
|
|
1449
|
+
|
|
1450
|
+
if (!plan.ok) {
|
|
1451
|
+
record.promise = Promise.resolve(record);
|
|
1452
|
+
if (plan.code !== P2P_CHANNEL_LIVE) {
|
|
1453
|
+
log('info', `not dialling back for ${taskId}: ${plan.reason}`);
|
|
1454
|
+
}
|
|
1455
|
+
return record;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const timeoutMs = reverseDialTimeoutMs();
|
|
1459
|
+
const startedAt = process.hrtime.bigint();
|
|
1460
|
+
record.promise = (async () => {
|
|
1461
|
+
let dialled;
|
|
1462
|
+
try {
|
|
1463
|
+
dialled = await p2pNode.dial(plan.machineId, {
|
|
1464
|
+
session: plan.session,
|
|
1465
|
+
candidates: plan.candidates,
|
|
1466
|
+
timeoutMs,
|
|
1467
|
+
});
|
|
1468
|
+
} catch (error) {
|
|
1469
|
+
// `dial()` documents that it never throws; caught anyway, because this runs detached from
|
|
1470
|
+
// every caller and an unhandled rejection is the one failure that could take the process down.
|
|
1471
|
+
dialled = { ok: false, error: `P2P_REVERSE_DIAL_FAILED: ${error?.message ?? String(error)}` };
|
|
1472
|
+
}
|
|
1473
|
+
record.ms = Math.round(Number(process.hrtime.bigint() - startedAt) / 1e5) / 10;
|
|
1474
|
+
if (dialled?.ok === true) {
|
|
1475
|
+
record.ok = true;
|
|
1476
|
+
record.channel = dialled.channel;
|
|
1477
|
+
record.reused = dialled.reused === true;
|
|
1478
|
+
rememberChannel(taskId, dialled.channel);
|
|
1479
|
+
log('info', `reverse dial to ${plan.machineId} opened a channel for ${taskId}`, {
|
|
1480
|
+
session: dialled.session,
|
|
1481
|
+
ms: record.ms,
|
|
1482
|
+
reused: record.reused,
|
|
1483
|
+
candidates: plan.candidates.length,
|
|
1484
|
+
});
|
|
1485
|
+
return record;
|
|
1486
|
+
}
|
|
1487
|
+
const text = typeof dialled?.error === 'string' && dialled.error !== '' ? dialled.error : 'the dial reported no reason';
|
|
1488
|
+
record.reason = text.startsWith('P2P_PUNCH_TIMEOUT')
|
|
1489
|
+
? `${P2P_REVERSE_DIAL_TIMEOUT}: ${text}`
|
|
1490
|
+
: `${P2P_REVERSE_DIAL_FAILED}: ${text}`;
|
|
1491
|
+
// A distinct code from the reason: the full message names the candidates and the window, and
|
|
1492
|
+
// this is what a reader or a metric can match on without parsing prose.
|
|
1493
|
+
record.code = record.reason.slice(0, record.reason.indexOf(':'));
|
|
1494
|
+
log('warn', `reverse dial for ${taskId} did not open a channel; the relay path is unaffected`, {
|
|
1495
|
+
reason: record.reason,
|
|
1496
|
+
ms: record.ms,
|
|
1497
|
+
});
|
|
1498
|
+
return record;
|
|
1499
|
+
})();
|
|
1500
|
+
|
|
1501
|
+
return record;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* The reverse-dial facts one result envelope reports, or null when there was no record.
|
|
1506
|
+
*
|
|
1507
|
+
* @param {string} taskId
|
|
1508
|
+
* @returns {object|null}
|
|
1509
|
+
*/
|
|
1510
|
+
function reverseDialSummary(taskId) {
|
|
1511
|
+
const record = p2pReverseDials.get(taskId) ?? null;
|
|
1512
|
+
if (record === null) return null;
|
|
1513
|
+
return {
|
|
1514
|
+
attempted: record.attempted === true,
|
|
1515
|
+
ok: record.ok === true,
|
|
1516
|
+
code: record.code,
|
|
1517
|
+
reason: record.reason,
|
|
1518
|
+
stage: record.stage,
|
|
1519
|
+
session: record.session,
|
|
1520
|
+
candidates: record.candidates,
|
|
1521
|
+
ms: record.ms,
|
|
1522
|
+
channel: record.channel ?? null,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** Release the channel a task used, once the attempt is over.
|
|
1211
1527
|
*
|
|
1212
1528
|
* A channel exists for one attempt: the offer arrives on it and the result notice is acknowledged
|
|
1213
1529
|
* on it, and nothing else is expected. Without this, an agent accumulated one live session per task
|
|
@@ -1217,6 +1533,8 @@ export function createAgent(options) {
|
|
|
1217
1533
|
* channel to answer on (the relay copy is the durable one).
|
|
1218
1534
|
*/
|
|
1219
1535
|
function releaseChannel(taskId) {
|
|
1536
|
+
p2pReverseDials.delete(taskId);
|
|
1537
|
+
p2pPeers.delete(taskId);
|
|
1220
1538
|
const entry = p2pChannels.get(taskId);
|
|
1221
1539
|
if (!entry) return;
|
|
1222
1540
|
p2pChannels.delete(taskId);
|
|
@@ -1237,14 +1555,30 @@ export function createAgent(options) {
|
|
|
1237
1555
|
* is answered over the channel, and that is only possible while the channel exists. 30 seconds is
|
|
1238
1556
|
* long enough to cover a retry and short enough that a long-lived agent does not accumulate one
|
|
1239
1557
|
* session per task it has ever run.
|
|
1558
|
+
*
|
|
1559
|
+
* Scheduled even when there is no channel: the timer is also what releases this task's remembered
|
|
1560
|
+
* peer address, and a task whose dial timed out has exactly that and no channel.
|
|
1240
1561
|
*/
|
|
1241
1562
|
function scheduleChannelRelease(taskId) {
|
|
1242
1563
|
const entry = p2pChannels.get(taskId);
|
|
1243
|
-
if (
|
|
1244
|
-
clearTimeout(entry.lingerTimer);
|
|
1564
|
+
if (entry) clearTimeout(entry.lingerTimer);
|
|
1245
1565
|
const timer = setTimeout(() => releaseChannel(taskId), p2pChannelLingerMs);
|
|
1246
1566
|
timer.unref?.();
|
|
1247
|
-
entry.lingerTimer = timer;
|
|
1567
|
+
if (entry) entry.lingerTimer = timer;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Let go of one task's dial record, once its envelope has been built.
|
|
1572
|
+
*
|
|
1573
|
+
* That envelope is the record's last reader: the record exists to decide `result_path` and to name
|
|
1574
|
+
* the mechanism that opened the channel, and both are inside the envelope by the time this runs. A
|
|
1575
|
+
* re-attempt of the same task (`attempt + 1`) is a new decision and starts a fresh dial, which is
|
|
1576
|
+
* why this is not left to the linger timer -- that one waits 30 s, and a retry can arrive sooner.
|
|
1577
|
+
*
|
|
1578
|
+
* @param {string} taskId
|
|
1579
|
+
*/
|
|
1580
|
+
function releaseDialRecord(taskId) {
|
|
1581
|
+
p2pReverseDials.delete(taskId);
|
|
1248
1582
|
}
|
|
1249
1583
|
|
|
1250
1584
|
/** The STUN list the node is actually querying, for a status surface that can be quoted. */
|
|
@@ -1815,6 +2149,28 @@ export function createAgent(options) {
|
|
|
1815
2149
|
* * `session` -- the punch's session id, which is what correlates two agents' logs.
|
|
1816
2150
|
* * `mapping` -- the NAT mapping this agent's announcement measured.
|
|
1817
2151
|
*
|
|
2152
|
+
* v0.4.x adds three more, because "the result came back over the relay" and "the result came back
|
|
2153
|
+
* over the relay *after this machine spent eight seconds dialling*" are different operational
|
|
2154
|
+
* facts and the v0.4.0 block could not tell them apart:
|
|
2155
|
+
*
|
|
2156
|
+
* * `opened_by` -- which mechanism opened the channel, named rather than inferred:
|
|
2157
|
+
* `dispatcher-punch` (the dispatcher's outbound HELLO landed), `simultaneous-punch`
|
|
2158
|
+
* (this machine dialled as the offer arrived, and that is what opened both
|
|
2159
|
+
* filters), `result-reverse-dial` (this machine dialled once the result was
|
|
2160
|
+
* ready). Absent when no channel carried anything.
|
|
2161
|
+
* * `reverse_dial` -- `'p2p'` when this machine's own dial opened a channel, `'relay'` when it was
|
|
2162
|
+
* attempted and did not. Absent when it was never attempted, which is not a
|
|
2163
|
+
* failure and must not be spelled as one.
|
|
2164
|
+
* * `reverse_dial_reason` -- the named code when the dial did not open a channel
|
|
2165
|
+
* (`P2P_REVERSE_DIAL_TIMEOUT`, `P2P_REVERSE_DIAL_FAILED`), or null.
|
|
2166
|
+
* * `reverse_dial_ms` -- what the dial cost, in milliseconds. The window is a bound the contract
|
|
2167
|
+
* states, and a bound nobody measures is a claim: this is the number that says
|
|
2168
|
+
* whether the result path really stayed inside it.
|
|
2169
|
+
* * `result_error` -- why the direct copy of *this result* did not land, when one was attempted
|
|
2170
|
+
* and not acknowledged (`P2P_ACK_TIMEOUT: …`). Null otherwise; this is the
|
|
2171
|
+
* `delivery.error` that `p2pDeliveryMark` has always carried and the envelope
|
|
2172
|
+
* never reported.
|
|
2173
|
+
*
|
|
1818
2174
|
* @param {object} context
|
|
1819
2175
|
* @returns {object}
|
|
1820
2176
|
*/
|
|
@@ -1823,11 +2179,42 @@ export function createAgent(options) {
|
|
|
1823
2179
|
const transport = context.p2pTransport ?? offerTransportOf(offer, p2pStatusSnapshot());
|
|
1824
2180
|
const delivery = context.p2pDelivery ?? p2pDeliveryMark();
|
|
1825
2181
|
const record = context.p2pRecord ?? null;
|
|
2182
|
+
const reverse = context.p2pReverse ?? null;
|
|
1826
2183
|
const dispatcherMode = offer?.p2p?.mode;
|
|
2184
|
+
|
|
2185
|
+
/**
|
|
2186
|
+
* Which mechanism opened the channel that carried the offer or the result.
|
|
2187
|
+
*
|
|
2188
|
+
* Identity, not timing: the record keeps the channel object its dial opened, so "the reverse dial
|
|
2189
|
+
* is what carried this result" is a comparison of two references rather than a guess from the
|
|
2190
|
+
* order things happened in. A channel that arrived on its own can only be the dispatcher's punch.
|
|
2191
|
+
*/
|
|
2192
|
+
const usedChannel = channelFor(offer?.task_id);
|
|
2193
|
+
let openedBy = null;
|
|
2194
|
+
if (delivery.path === 'p2p') {
|
|
2195
|
+
if (reverse?.ok === true && reverse.channel !== null && reverse.channel === usedChannel) {
|
|
2196
|
+
openedBy = reverse.stage === 'result'
|
|
2197
|
+
? P2P_OPENED_BY.RESULT_REVERSE_DIAL
|
|
2198
|
+
: P2P_OPENED_BY.SIMULTANEOUS_PUNCH;
|
|
2199
|
+
} else {
|
|
2200
|
+
openedBy = P2P_OPENED_BY.DISPATCHER_PUNCH;
|
|
2201
|
+
}
|
|
2202
|
+
} else if (transport.offer_path === 'p2p') {
|
|
2203
|
+
openedBy = P2P_OPENED_BY.DISPATCHER_PUNCH;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
1827
2206
|
return {
|
|
1828
2207
|
mode: typeof dispatcherMode === 'string' && dispatcherMode !== '' ? dispatcherMode : p2pMode,
|
|
1829
2208
|
offer_path: transport.offer_path,
|
|
1830
2209
|
...(delivery.path === null ? {} : { result_path: delivery.path }),
|
|
2210
|
+
...(openedBy === null ? {} : { opened_by: openedBy }),
|
|
2211
|
+
...(reverse === null || reverse.attempted !== true
|
|
2212
|
+
? {}
|
|
2213
|
+
: { reverse_dial: reverse.ok === true ? 'p2p' : 'relay' }),
|
|
2214
|
+
...(reverse !== null && typeof reverse.reason === 'string' && reverse.reason !== ''
|
|
2215
|
+
? { reverse_dial_reason: reverse.reason }
|
|
2216
|
+
: {}),
|
|
2217
|
+
...(typeof reverse?.ms === 'number' ? { reverse_dial_ms: reverse.ms } : {}),
|
|
1831
2218
|
// The key is OMITTED when there is nothing to explain, and that is not the same as setting it
|
|
1832
2219
|
// to `undefined`: `JSON.stringify` would drop that, but the envelope hash is computed with JCS,
|
|
1833
2220
|
// whose implementation (rightly) refuses a non-JSON value. Measured -- `reason: undefined`
|
|
@@ -1835,9 +2222,17 @@ export function createAgent(options) {
|
|
|
1835
2222
|
// `jcs: property reason is undefined`, so the task executed and then vanished from the ledger.
|
|
1836
2223
|
...(typeof transport.reason === 'string' && transport.reason !== '' ? { reason: transport.reason } : {}),
|
|
1837
2224
|
rtt_ms: delivery.rtt_ms ?? record?.rtt_ms ?? null,
|
|
1838
|
-
peer:
|
|
1839
|
-
|
|
2225
|
+
// The peer address and session of whichever channel this machine holds for the task: the one
|
|
2226
|
+
// that carried the offer, or the one its own dial opened. For an inbound channel `peer` stays
|
|
2227
|
+
// null (a HELLO carries a session, not an identity) -- but a channel *this* machine dialled has
|
|
2228
|
+
// a peer address it chose, and reporting "no peer" there would hide the one address the punch
|
|
2229
|
+
// actually used.
|
|
2230
|
+
peer: record?.peer ?? (reverse?.ok === true && reverse.channel !== null ? { ...reverse.channel.peer } : null),
|
|
2231
|
+
session: record?.session ?? reverse?.session ?? null,
|
|
1840
2232
|
mapping: p2pNode?.status.mapping ?? null,
|
|
2233
|
+
...(delivery.asked === true && delivery.path === null && typeof delivery.error === 'string' && delivery.error !== ''
|
|
2234
|
+
? { result_error: delivery.error }
|
|
2235
|
+
: {}),
|
|
1841
2236
|
};
|
|
1842
2237
|
}
|
|
1843
2238
|
|
|
@@ -1957,11 +2352,19 @@ export function createAgent(options) {
|
|
|
1957
2352
|
// v0.4.0 §6. Always present, like the relay's own offer fields and for the same reason: a
|
|
1958
2353
|
// v0.3.9 machine and an `auto` one are then distinguishable from the stored envelope alone,
|
|
1959
2354
|
// instead of `transport` being absent both when the direct path was never tried and when it
|
|
1960
|
-
// was tried and lost.
|
|
1961
|
-
//
|
|
1962
|
-
//
|
|
2355
|
+
// was tried and lost.
|
|
2356
|
+
//
|
|
2357
|
+
// v0.4.x widens what `transport` answers, without moving either of the two facts it is read for.
|
|
2358
|
+
// It is "did the direct path carry anything for this machine" -- `p2p` when the offer arrived over
|
|
2359
|
+
// a channel *or* when the result was acknowledged over one, `relay` when the relay carried both.
|
|
2360
|
+
// `p2p.offer_path` and `p2p.result_path` remain the halves, so a reader that needs "which way did
|
|
2361
|
+
// the offer come" still has it verbatim; what changed is that a machine whose *result* found its
|
|
2362
|
+
// own way back no longer looks like a machine that fell all the way to the relay. That is the
|
|
2363
|
+
// reading the relay's own report already documents ("how this machine's envelope travelled"), and
|
|
2364
|
+
// the one a deployment-level question like "is P2P working here" needs answered.
|
|
1963
2365
|
const transport = context.p2pTransport ?? offerTransportOf(offer, p2pStatusSnapshot());
|
|
1964
|
-
|
|
2366
|
+
const directResult = (context.p2pDelivery ?? p2pDeliveryMark()).path === 'p2p';
|
|
2367
|
+
envelope.transport = transport.offer_path === 'p2p' || directResult ? 'p2p' : transport.offer_path;
|
|
1965
2368
|
envelope.p2p = envelopeP2P({ ...context, p2pTransport: transport });
|
|
1966
2369
|
return buildEnvelope(envelope);
|
|
1967
2370
|
}
|
|
@@ -2089,6 +2492,15 @@ export function createAgent(options) {
|
|
|
2089
2492
|
write: offer.write === true,
|
|
2090
2493
|
});
|
|
2091
2494
|
|
|
2495
|
+
// ---- 1b. the two-way punch (v0.4.x) ----------------------------------
|
|
2496
|
+
// Started here, and *started* rather than awaited: the dispatcher may be behind a symmetric NAT
|
|
2497
|
+
// that swallowed its push, and a packet from this machine is the only thing that can open the
|
|
2498
|
+
// other half of that path. Everything the relay does today happens exactly as it did -- the
|
|
2499
|
+
// lease heartbeat, the gates, the command, the relay copy of the result -- while this runs
|
|
2500
|
+
// beside it for a bounded window. Nothing below waits for it; the result path is the one place
|
|
2501
|
+
// that reads its outcome, and even there only to decide `result_path`.
|
|
2502
|
+
startReverseDial(offer);
|
|
2503
|
+
|
|
2092
2504
|
// ---- 2. lease heartbeat ---------------------------------------------
|
|
2093
2505
|
heartbeat = new Heartbeat({
|
|
2094
2506
|
send: (body) => postJson('/v1/heartbeat', body).then((response) => response.json),
|
|
@@ -2425,10 +2837,39 @@ export function createAgent(options) {
|
|
|
2425
2837
|
* envelope on the channel would need the envelope before the acknowledgement that decides one of
|
|
2426
2838
|
* its fields -- see the note in the v0.4.0 report and `PROTOCOL-v0.4.0.md`'s open question.
|
|
2427
2839
|
*
|
|
2840
|
+
* A channel is no longer a precondition (v0.4.x): when there is none, this dials the dispatcher
|
|
2841
|
+
* first (see `reverseDialPlan`) and uses the channel that opens. What has *not* changed is the
|
|
2842
|
+
* bound and the order -- the relay copy still goes out after this returns, whatever it decided, and
|
|
2843
|
+
* the whole function still costs at most one dial window plus one ack window.
|
|
2844
|
+
*
|
|
2428
2845
|
* @param {object} offer
|
|
2846
|
+
* @param {string|null} [status] The outcome this envelope is about to report. A `refused` attempt
|
|
2847
|
+
* has no direct result worth dialling for: nothing ran, the envelope that says so travels over
|
|
2848
|
+
* the relay exactly as it does today, and paying the dial window for it would delay a refusal
|
|
2849
|
+
* for no gain. The value matters only when no decision was recorded at offer time (the refusal
|
|
2850
|
+
* path returns before the offer-time dial is started).
|
|
2429
2851
|
* @returns {Promise<ReturnType<typeof p2pDeliveryMark>>}
|
|
2430
2852
|
*/
|
|
2431
|
-
async function planDirectDelivery(offer) {
|
|
2853
|
+
async function planDirectDelivery(offer, status = null) {
|
|
2854
|
+
const taskId = typeof offer?.task_id === 'string' && offer.task_id !== '' ? offer.task_id : null;
|
|
2855
|
+
|
|
2856
|
+
if (taskId !== null && channelFor(taskId) === null) {
|
|
2857
|
+
// ---- the result-path reverse dial (v0.4.x) ---------------------------
|
|
2858
|
+
// No channel: the offer came over the relay, or it came over a channel the dispatcher has since
|
|
2859
|
+
// closed (it closes the one it pushed the offer over, immediately after the push). Before the
|
|
2860
|
+
// relay carries the result -- which it still does, unchanged, in every failure case -- this
|
|
2861
|
+
// machine tries once, bounded, to dial the dispatcher itself. The attempt is *reused* when the
|
|
2862
|
+
// offer-time dial is still running or already finished, so a task pays for one window at most,
|
|
2863
|
+
// never two.
|
|
2864
|
+
if (!(status === 'refused' && p2pReverseDials.get(taskId) === undefined)) {
|
|
2865
|
+
const record = startReverseDial(offer, { stage: 'result', afterLiveChannel: true });
|
|
2866
|
+
if (record !== null && record.attempted === true && record.promise !== null) {
|
|
2867
|
+
// Bounded by construction: `dial()` resolves at the tuning window or on the first answer.
|
|
2868
|
+
await record.promise;
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2432
2873
|
if (channelFor(offer?.task_id) === null) return p2pDeliveryMark();
|
|
2433
2874
|
const direct = await sendResultDirect(offer.task_id);
|
|
2434
2875
|
if (direct.delivered) {
|
|
@@ -2512,18 +2953,25 @@ export function createAgent(options) {
|
|
|
2512
2953
|
async function finish(context) {
|
|
2513
2954
|
const anchorsAfter = context.anchorsAfter ?? (await safePostState(context.offer, context.warnings));
|
|
2514
2955
|
const toolchain = await toolchainInfo();
|
|
2956
|
+
// The bounded wait happens *before* the envelope is built, because `result_path` has to be
|
|
2957
|
+
// inside the envelope when `envelope_sha256` is taken; see `sendResultDirect`.
|
|
2958
|
+
const p2pDelivery = context.p2pDelivery ?? (await planDirectDelivery(context.offer, context.status));
|
|
2959
|
+
// Read once, here, and handed to `makeEnvelope`: the envelope has to describe the dial that
|
|
2960
|
+
// decided its `result_path`, and the record is released with the channel a moment later.
|
|
2961
|
+
const p2pReverse = context.p2pReverse ?? reverseDialSummary(context.offer?.task_id);
|
|
2515
2962
|
const envelope = makeEnvelope({
|
|
2516
2963
|
...context,
|
|
2517
2964
|
anchorsAfter,
|
|
2518
2965
|
toolchain,
|
|
2519
2966
|
comparePolicy: context.offer.compare_policy,
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
p2pDelivery: context.p2pDelivery ?? (await planDirectDelivery(context.offer)),
|
|
2967
|
+
p2pDelivery,
|
|
2968
|
+
p2pReverse,
|
|
2523
2969
|
});
|
|
2524
2970
|
await deliver(envelope);
|
|
2525
2971
|
// The attempt is over. The channel lingers (bounded) so a duplicate of this attempt can still be
|
|
2526
|
-
// answered on it, and is released afterwards
|
|
2972
|
+
// answered on it, and is released afterwards; the dial's own bookkeeping is consumed by the
|
|
2973
|
+
// envelope above and released with it, so a long-lived agent keeps one record per *live* task.
|
|
2974
|
+
releaseDialRecord(envelope.task_id);
|
|
2527
2975
|
scheduleChannelRelease(envelope.task_id);
|
|
2528
2976
|
return envelope;
|
|
2529
2977
|
}
|
|
@@ -2869,7 +3317,16 @@ export function createAgent(options) {
|
|
|
2869
3317
|
/** Drop a queued task and abort a running one (§3.4). Shared by both transports. */
|
|
2870
3318
|
function cancelTask(taskId, reason) {
|
|
2871
3319
|
const index = queue.findIndex((item) => item.task_id === taskId);
|
|
2872
|
-
if (index !== -1)
|
|
3320
|
+
if (index !== -1) {
|
|
3321
|
+
queue.splice(index, 1);
|
|
3322
|
+
// A task cancelled before it ran never reaches `finish`, which is where a task's direct-path
|
|
3323
|
+
// bookkeeping is normally released: the channel its offer arrived on (or the one its own dial
|
|
3324
|
+
// opened) and the dial record. Nothing will ever be answered on that channel now, so it is let
|
|
3325
|
+
// go here rather than left holding a mapping until the node closes.
|
|
3326
|
+
releaseChannel(taskId);
|
|
3327
|
+
log('info', `task.cancel for queued ${taskId}: dropped before it ran`);
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
2873
3330
|
if (state.current && state.current.task_id === taskId) {
|
|
2874
3331
|
log('warn', `task.cancel for running ${taskId}: ${reason ?? ''}`);
|
|
2875
3332
|
state.current.controller.abort();
|