@twinsearth/w2m-dsh-plugin 0.4.8 → 0.5.0

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.
@@ -252,6 +252,16 @@ export const P2P_DEFAULTS = Object.freeze({
252
252
  refreshMs: 20_000,
253
253
  punchTimeoutMs: 5_000,
254
254
  acceptTimeoutMs: 5_000,
255
+ /**
256
+ * How long an executor may spend dialling a dispatcher it did not punch first.
257
+ *
258
+ * Deliberately longer than `punchTimeoutMs`. A punch between two peers that both expected it
259
+ * succeeds in one round trip or not at all; a *reverse* dial is racing the other end's own
260
+ * punch attempt and a NAT filter that only opens once the other side's packet has arrived, so
261
+ * it needs room for several HELLO rounds. 8 s is the window the v0.4.x contract states, and it
262
+ * is bounded on purpose: the relay copy of an offer and of a result is never held behind it.
263
+ */
264
+ reverseDialTimeoutMs: 8_000,
255
265
  maxCandidates: 8,
256
266
  });
257
267
 
@@ -945,13 +955,23 @@ export class P2PNode extends EventEmitter {
945
955
  * initiator has to pick one, and a caller with a task id should pass one derived
946
956
  * from it so two concurrent tasks do not share a session.
947
957
  * @param {number} [options.timeoutMs] Punch deadline; default `tuning.punchTimeoutMs`.
958
+ * @param {Array<{address:string, port:number}>} [options.candidates] Addresses to punch
959
+ * at, used **instead of** asking the relay. This is how an executor dials a dispatcher
960
+ * whose candidates travelled inside the offer (`p2p.candidates`): the offer is evidence
961
+ * the dispatcher itself produced moments ago, and a relay lookup would be a second,
962
+ * slower opinion about the same machine — one that answers 404 for a dispatcher whose
963
+ * announcement has already expired. The list is validated here exactly like the relay's
964
+ * answer is, so an offer cannot smuggle in a wildcard address or a zero port.
948
965
  * @param {number} [options.acceptTimeoutMs] Accepted for interface symmetry with
949
966
  * {@link accept} and unused here: the initiator waits for the HELLO_ACK, which is
950
967
  * the punch itself, and never waits to be accepted.
951
968
  * @returns {Promise<{ok:boolean, channel:import('./p2p.mjs').P2PChannel|null,
952
969
  * peer:{address:string,port:number}|null, session:number, error:string|null,
953
- * attempts:number, rttMs:number|null, peersAnnounced:number}>} `peersAnnounced` is
954
- * how many candidate addresses the relay held for that machine.
970
+ * attempts:number, rttMs:number|null, peersAnnounced:number, reused:boolean}>}
971
+ * `peersAnnounced` is how many candidate addresses the caller (or the relay) held for
972
+ * that machine; `reused` says the session already had a live channel and no punch was
973
+ * needed, which is what keeps a simultaneous punch from producing two channels for one
974
+ * session — and therefore two deliveries of every message.
955
975
  */
956
976
  async dial(machineId, options = {}) {
957
977
  const empty = {
@@ -963,6 +983,7 @@ export class P2PNode extends EventEmitter {
963
983
  attempts: 0,
964
984
  rttMs: null,
965
985
  peersAnnounced: 0,
986
+ reused: false,
966
987
  };
967
988
 
968
989
  if (!this.isEnabled()) {
@@ -974,9 +995,6 @@ export class P2PNode extends EventEmitter {
974
995
  if (typeof machineId !== 'string' || machineId === '') {
975
996
  return { ...empty, error: "P2P_BAD_MACHINE_ID: dial() needs the peer's machine id" };
976
997
  }
977
- if (typeof this.getJson !== 'function') {
978
- return { ...empty, error: "P2P_NO_GET_JSON: dial() needs getJson to read the peer's candidates" };
979
- }
980
998
 
981
999
  let session = options.session;
982
1000
  if (session === undefined) {
@@ -997,25 +1015,44 @@ export class P2PNode extends EventEmitter {
997
1015
  return { ...empty, session, error, ...extra };
998
1016
  };
999
1017
 
1000
- let lookup = null;
1001
- try {
1002
- lookup = await this.getJson(`/v1/peer/${encodeURIComponent(machineId)}`);
1003
- } catch (error) {
1004
- return fail(`P2P_LOOKUP_FAILED: ${messageOf(error)}`);
1005
- }
1006
- if (this.socket !== socket || !this.started) {
1007
- return { ...empty, session, error: 'P2P_NODE_NOT_RUNNING: the node closed while dialling' };
1008
- }
1009
- if (lookup?.ok !== true) {
1010
- if (lookup?.status === 404) {
1011
- // A 404 is the relay saying "this machine has not announced" — an ordinary state
1012
- // (the peer may be relay-only, or between refreshes), and it has its own code.
1013
- return fail(`P2P_NO_CANDIDATES: the relay holds no live announcement for ${machineId}`);
1018
+ // "One session, one channel" is enforced where a simultaneous punch actually collides: after the
1019
+ // punch resolves, against whatever the accept loop claimed while it was in flight (see
1020
+ // `claimedMeanwhile` below and `adoptChannel`). There is deliberately no cheap pre-check here: a
1021
+ // caller that dials a session this node already holds is still punching, the far end
1022
+ // re-acknowledges it, and that contract is what the re-ack case in `test/p2p-node.test.mjs` pins
1023
+ // down. Suppressing the punch would change it to save one datagram.
1024
+
1025
+ // `options.candidates` present and non-empty means "punch at these" -- an empty array is the same
1026
+ // statement as the field being absent ("nobody told me where"), and falls through to the relay.
1027
+ const fromOffer = Array.isArray(options.candidates) && options.candidates.length > 0;
1028
+
1029
+ let announced = [];
1030
+ if (fromOffer) {
1031
+ announced = options.candidates;
1032
+ } else {
1033
+ if (typeof this.getJson !== 'function') {
1034
+ return { ...empty, error: "P2P_NO_GET_JSON: dial() needs getJson to read the peer's candidates" };
1035
+ }
1036
+ let lookup = null;
1037
+ try {
1038
+ lookup = await this.getJson(`/v1/peer/${encodeURIComponent(machineId)}`);
1039
+ } catch (error) {
1040
+ return fail(`P2P_LOOKUP_FAILED: ${messageOf(error)}`);
1014
1041
  }
1015
- return fail(`P2P_LOOKUP_FAILED: ${describeLookupFailure(lookup)}`);
1042
+ if (this.socket !== socket || !this.started) {
1043
+ return { ...empty, session, error: 'P2P_NODE_NOT_RUNNING: the node closed while dialling' };
1044
+ }
1045
+ if (lookup?.ok !== true) {
1046
+ if (lookup?.status === 404) {
1047
+ // A 404 is the relay saying "this machine has not announced" — an ordinary state
1048
+ // (the peer may be relay-only, or between refreshes), and it has its own code.
1049
+ return fail(`P2P_NO_CANDIDATES: the relay holds no live announcement for ${machineId}`);
1050
+ }
1051
+ return fail(`P2P_LOOKUP_FAILED: ${describeLookupFailure(lookup)}`);
1052
+ }
1053
+ announced = Array.isArray(lookup.json?.peer?.candidates) ? lookup.json.peer.candidates : [];
1016
1054
  }
1017
1055
 
1018
- const announced = Array.isArray(lookup.json?.peer?.candidates) ? lookup.json.peer.candidates : [];
1019
1056
  const remote = [];
1020
1057
  for (const candidate of announced) {
1021
1058
  const usable = usableCandidate(candidate);
@@ -1025,7 +1062,15 @@ export class P2PNode extends EventEmitter {
1025
1062
  if (remote.length >= this.tuning.maxCandidates) break;
1026
1063
  }
1027
1064
  if (remote.length === 0) {
1028
- return fail(`P2P_NO_CANDIDATES: ${machineId} announced no usable address`);
1065
+ // `peersAnnounced` still counts what was offered, not what survived validation: a caller
1066
+ // reading "the punch failed" needs to see that there *was* a list, and that every entry in it
1067
+ // was unusable -- a wildcard address, a zero port -- rather than that nobody had an address.
1068
+ return fail(
1069
+ fromOffer
1070
+ ? `P2P_NO_CANDIDATES: the offer's candidate list for ${machineId} held no usable address`
1071
+ : `P2P_NO_CANDIDATES: ${machineId} announced no usable address`,
1072
+ { peersAnnounced: announced.length },
1073
+ );
1029
1074
  }
1030
1075
 
1031
1076
  let outcome = null;
@@ -1055,6 +1100,31 @@ export class P2PNode extends EventEmitter {
1055
1100
  }
1056
1101
 
1057
1102
  this.punchesOut += 1;
1103
+ // The accept loop may have claimed this session while the punch was in flight — the far end
1104
+ // dialled us at the same moment we dialled it, which is the whole point of a simultaneous
1105
+ // punch. Its channel is the better one: its peer is the address the far end's packet actually
1106
+ // came from, while ours is the address that end merely *announced*. Keep the observed one.
1107
+ const claimedMeanwhile = this.channels.get(session);
1108
+ if (claimedMeanwhile !== undefined && claimedMeanwhile !== outcome.channel) {
1109
+ try {
1110
+ // Silent: this is a duplicate local view of a live session, not the end of one. A BYE here
1111
+ // would close the peer's channel for the session the survivor is about to use.
1112
+ outcome.channel.close('session-claimed', { silent: true });
1113
+ } catch {
1114
+ /* the path is already gone */
1115
+ }
1116
+ return {
1117
+ ok: true,
1118
+ channel: claimedMeanwhile,
1119
+ peer: { ...claimedMeanwhile.peer },
1120
+ session,
1121
+ error: null,
1122
+ attempts: outcome.punch.attempts,
1123
+ rttMs: outcome.punch.rttMs,
1124
+ peersAnnounced: announced.length,
1125
+ reused: true,
1126
+ };
1127
+ }
1058
1128
  this.adoptChannel(outcome.channel, { from: machineId, session: outcome.channel.session });
1059
1129
  return {
1060
1130
  ok: true,
@@ -1065,6 +1135,7 @@ export class P2PNode extends EventEmitter {
1065
1135
  attempts: outcome.punch.attempts,
1066
1136
  rttMs: outcome.punch.rttMs,
1067
1137
  peersAnnounced: announced.length,
1138
+ reused: false,
1068
1139
  };
1069
1140
  }
1070
1141
 
@@ -1072,6 +1143,14 @@ export class P2PNode extends EventEmitter {
1072
1143
  * Wire one channel into the node: deliver its payloads as `'message'` events, claim
1073
1144
  * its session, and drop the claim when it closes.
1074
1145
  *
1146
+ * **One session, one channel — enforced here, not assumed.** The map is keyed by session and a
1147
+ * second channel for a session already in it is *closed*, because `P2PChannel` filters incoming
1148
+ * datagrams by session alone: two live channels on one session would each assemble and deliver
1149
+ * every message of that session, so the caller would see each payload twice and `result_path`
1150
+ * would be decided by whichever listener was registered last. A simultaneous punch makes that
1151
+ * the normal case rather than a corner case, since both ends' HELLOs arrive while both are still
1152
+ * dialling. See `dial()`, which returns the surviving channel rather than the one it just opened.
1153
+ *
1075
1154
  * The claim is released on close on purpose. `claimed` means "this session has a live
1076
1155
  * channel"; a broken path has to be re-punchable with the same session, and holding
1077
1156
  * the claim after the channel is gone would silently refuse the peer's retry.
@@ -1080,6 +1159,20 @@ export class P2PNode extends EventEmitter {
1080
1159
  * @param {{from:string|null, session:number}} details
1081
1160
  */
1082
1161
  adoptChannel(channel, details) {
1162
+ const previous = this.channels.get(details.session);
1163
+ if (previous !== undefined && previous !== channel) {
1164
+ // Closed *before* the claim is taken: the old channel's own `'close'` handler deletes the
1165
+ // session from `claimed`, and running it after the new claim would drop a claim that is
1166
+ // still live -- which is the state that lets a peer open a second channel on this session.
1167
+ this.channels.delete(details.session);
1168
+ try {
1169
+ // Silent, for the same reason as the duplicate in `dial()`: the session is not over, it is
1170
+ // being represented by a better channel, and a BYE would close the peer's view of it.
1171
+ previous.close('session-replaced', { silent: true });
1172
+ } catch {
1173
+ /* the path is already gone; that is the state this replacement produces */
1174
+ }
1175
+ }
1083
1176
  this.channels.set(details.session, channel);
1084
1177
  this.claimed.add(details.session);
1085
1178
  channel.on('message', (payload) => {
@@ -1093,7 +1186,9 @@ export class P2PNode extends EventEmitter {
1093
1186
  });
1094
1187
  channel.on('close', () => {
1095
1188
  if (this.channels.get(details.session) === channel) this.channels.delete(details.session);
1096
- this.claimed.delete(details.session);
1189
+ // The claim belongs to whichever channel currently holds the session, so a *replaced*
1190
+ // channel's close must not drop a claim its replacement depends on.
1191
+ if (this.channels.get(details.session) === undefined) this.claimed.delete(details.session);
1097
1192
  });
1098
1193
  // P2PChannel documents an `'error'` event. Listening is not optional bookkeeping:
1099
1194
  // an unhandled `'error'` emit would throw, and the whole point of this node is that
package/src/agent/p2p.mjs CHANGED
@@ -22,6 +22,15 @@
22
22
  * address the peer's packets will come from. `punch()` reports failure with the
23
23
  * attempts it made, and the caller falls back to the relay -- explicitly, never
24
24
  * silently.
25
+ * * **Both ends may send, and one of them usually should not have to.** A peer behind
26
+ * a CGNAT can send all it likes and still never be heard: its own outbound HELLO opens
27
+ * its mapping, but the *other* end's filter stays shut until something of its own has
28
+ * gone the other way. That is why `punch()` and `accept()` are not alternatives: an
29
+ * executor that normally only accepts can also dial the dispatcher with the session the
30
+ * offer published (see {@link punchSession}), and the two outbound packets together open
31
+ * a path that neither alone could. Both ends use the address the *other's* packet came
32
+ * from -- the observed `rinfo` -- rather than the address that was announced, which is
33
+ * the one property that makes a symmetric NAT survivable at all.
25
34
  *
26
35
  * Framing (all integers big-endian):
27
36
  *
@@ -123,6 +132,105 @@ export function deriveSession(...parts) {
123
132
  return value === 0 ? 1 : value;
124
133
  }
125
134
 
135
+ /**
136
+ * Derivation labels, named once so `deriveSession` is never called with a hand-typed
137
+ * string on one side and a different one on the other.
138
+ *
139
+ * The label is part of the hash: two peers that disagreed about it would compute
140
+ * different session ids for the same punch, discard each other's HELLO, and report a
141
+ * timeout that looks exactly like a NAT problem. That is the most expensive possible
142
+ * misdiagnosis, which is why the strings live next to `deriveSession` rather than at
143
+ * the two call sites.
144
+ */
145
+ export const PUNCH_SESSION_LABEL = 'w2m-p2p-punch';
146
+ export const REVERSE_DIAL_SESSION_LABEL = 'w2m-p2p-reverse';
147
+
148
+ /** Longest accepted punch id, so a hostile offer cannot make either end hash a novel. */
149
+ export const MAX_PUNCH_ID_LENGTH = 64;
150
+
151
+ /**
152
+ * Is `value` a usable punch id?
153
+ *
154
+ * A punch id is what a dispatcher publishes through the relay so the executor can join the
155
+ * *same* session; it is deliberately not the session itself. One dispatcher leases one task
156
+ * to several machines at once, and a single shared session id would make the second machine's
157
+ * HELLO look like a re-acknowledgement of the first machine's live channel -- two peers on one
158
+ * session, and a result delivered to the wrong address. The published id is therefore an
159
+ * *input* to {@link punchSession}, which mixes in the machine id and yields a session that is
160
+ * unique per (dispatch, task, machine) while still being computable by both ends without a
161
+ * second round trip.
162
+ *
163
+ * Both shapes a JSON hop can carry are accepted: a hex string (what {@link mintPunchId}
164
+ * produces) and a positive integer (what a caller that treats the id as a number would write).
165
+ * Numbers are bounded to 32 bits so `deriveSession`'s string form of them is stable.
166
+ *
167
+ * @param {unknown} value
168
+ * @returns {boolean}
169
+ */
170
+ export function isPunchId(value) {
171
+ if (typeof value === 'number') return Number.isInteger(value) && value > 0 && value <= 0xffffffff;
172
+ return typeof value === 'string' && value !== '' && value.length <= MAX_PUNCH_ID_LENGTH;
173
+ }
174
+
175
+ /**
176
+ * Mint a punch id for one dispatch.
177
+ *
178
+ * Random rather than derived: the session id ends up as the only thing that identifies a
179
+ * channel's frames (`P2PChannel.onDatagram` discards anything carrying another session), so
180
+ * a value an off-path observer could predict would be a value an off-path observer could
181
+ * inject with. Eight random bytes is the cheapest size that makes guessing hopeless.
182
+ *
183
+ * @returns {string} 16 lower-case hex characters.
184
+ */
185
+ export function mintPunchId() {
186
+ return randomBytes(8).toString('hex');
187
+ }
188
+
189
+ /**
190
+ * The session a dispatcher punches with, derived from the punch id it published.
191
+ *
192
+ * Deterministic on purpose, and identical on both ends: the dispatcher knows the punch id, the
193
+ * task id and the peer's machine id from the lease; the executor knows the first two from the
194
+ * offer and the third because it is its own id. No extra round trip, and the executor's HELLO
195
+ * carries the very session the dispatcher's in-flight punch is filtering for -- which is what
196
+ * makes the two halves one punch instead of two punches that time out against each other.
197
+ *
198
+ * @param {object} options
199
+ * @param {unknown} options.punch The published punch id; see {@link isPunchId}.
200
+ * @param {unknown} options.taskId
201
+ * @param {unknown} options.machineId The *executor's* machine id, not the dispatcher's.
202
+ * @returns {number|null} `null` when the inputs cannot produce one, so a caller can fall back
203
+ * to its own id instead of punching at a session the far end is not using.
204
+ */
205
+ export function punchSession({ punch, taskId, machineId } = {}) {
206
+ if (!isPunchId(punch)) return null;
207
+ if (typeof taskId !== 'string' || taskId === '') return null;
208
+ if (typeof machineId !== 'string' || machineId === '') return null;
209
+ return deriveSession(PUNCH_SESSION_LABEL, punch, taskId, machineId);
210
+ }
211
+
212
+ /**
213
+ * The session an executor uses when the offer named where to dial but no session to join.
214
+ *
215
+ * This is the compatibility path: a dispatcher that publishes its candidates without a punch
216
+ * id (or an older one that publishes nothing at all, where the caller never gets here). The
217
+ * executor picks the id, and the dispatcher's `accept()` adopts whatever the HELLO carries --
218
+ * that asymmetry is exactly what `allowSessionAdoption` exists for, and it is why a dial can
219
+ * still be attempted against a peer that never named a session.
220
+ *
221
+ * @param {object} options
222
+ * @param {unknown} options.originMachineId The dispatcher's machine id, from the offer.
223
+ * @param {unknown} options.taskId
224
+ * @param {unknown} options.machineId The executor's own machine id.
225
+ * @returns {number|null}
226
+ */
227
+ export function reverseDialSession({ originMachineId, taskId, machineId } = {}) {
228
+ if (typeof originMachineId !== 'string' || originMachineId === '') return null;
229
+ if (typeof taskId !== 'string' || taskId === '') return null;
230
+ if (typeof machineId !== 'string' || machineId === '') return null;
231
+ return deriveSession(REVERSE_DIAL_SESSION_LABEL, originMachineId, taskId, machineId);
232
+ }
233
+
126
234
  /**
127
235
  * Try to open a bidirectional path to one of `remote`'s candidate addresses.
128
236
  *
@@ -444,9 +552,36 @@ export class P2PChannel extends EventEmitter {
444
552
  });
445
553
  }
446
554
 
447
- /** Stop the channel. The socket is NOT closed: the caller owns it. */
448
- close(reason = 'closed') {
555
+ /**
556
+ * Stop the channel. The socket is NOT closed: the caller owns it.
557
+ *
558
+ * @param {string} [reason] Reported on the `'close'` event and in the rejection of every pending
559
+ * send.
560
+ * @param {{silent?: boolean}} [options] `silent: true` drops this channel **without telling the
561
+ * peer**, which is what discarding a *duplicate local view* of a live session requires. A punch
562
+ * can produce two channel objects for one session (both ends dialled, and the accept loop answered
563
+ * while the punch was still in flight); keeping the observed-address one and closing the other is
564
+ * right, but a BYE there would tell the peer a session that is very much alive was over, and would
565
+ * tear down the channel the survivor is about to use.
566
+ */
567
+ close(reason = 'closed', options = {}) {
449
568
  if (this.closed) return;
569
+ // The BYE goes out BEFORE `closed` is set, and that ordering is the whole fix. `sendFrame`
570
+ // refuses to send once the channel is closed -- a guard that is right for retransmissions and was
571
+ // silently wrong for the goodbye: with `closed` set first, the BYE below could never leave, so the
572
+ // peer was never told the path was over and kept writing into a channel nothing was listening on.
573
+ // Measured, on the path this release is about: a dispatcher's `pushOffer` closes the channel it
574
+ // pushed the offer over, and without the BYE the executor's side still looked live for the whole
575
+ // task, so its result was sent into that dead channel and only fell back to the relay after eight
576
+ // unacknowledged fragments (`P2P_FRAGMENT_UNACKED`) -- the "result came back relay" half of the
577
+ // failure, with no reverse dial attempted because a channel appeared to exist.
578
+ if (reason !== 'peer-closed' && options.silent !== true) {
579
+ try {
580
+ this.sendFrame(KIND.BYE, Buffer.alloc(0));
581
+ } catch {
582
+ /* the path is already gone */
583
+ }
584
+ }
450
585
  this.closed = true;
451
586
  this.socket.removeListener('message', this.onDatagram);
452
587
  for (const entry of this.outgoing.values()) {
@@ -455,13 +590,6 @@ export class P2PChannel extends EventEmitter {
455
590
  }
456
591
  this.outgoing.clear();
457
592
  this.incoming.clear();
458
- if (reason !== 'peer-closed') {
459
- try {
460
- this.sendFrame(KIND.BYE, Buffer.alloc(0));
461
- } catch {
462
- /* the path is already gone */
463
- }
464
- }
465
593
  this.emit('close', reason);
466
594
  }
467
595
  }
@@ -69,6 +69,11 @@ import {
69
69
  normalizeP2PMode,
70
70
  stunServersWithShared,
71
71
  } from '../agent/p2p-node.mjs';
72
+ // The punch identity both ends have to agree on (v0.4.x). Imported from the transport that will use
73
+ // it, never re-derived here: the session is the only thing that tells one punch's frames from
74
+ // another's, so a second implementation of the formula that drifted by one input would make every
75
+ // reverse dial time out while both ends insisted they were sending.
76
+ import { mintPunchId, punchSession } from '../agent/p2p.mjs';
72
77
  import { parseServer } from '../agent/stun.mjs';
73
78
  // The result-deduplication identity the relay mints. Imported rather than re-implemented: the value
74
79
  // is compared byte for byte by the executor, so a second copy of the formula that drifted by one
@@ -110,7 +115,7 @@ const MAX_STAGES = 16;
110
115
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
111
116
  * overrides it at runtime for tests and packagers.
112
117
  */
113
- const PLUGIN_VERSION = '0.4.8';
118
+ const PLUGIN_VERSION = '0.5.0';
114
119
 
115
120
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
116
121
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -1041,6 +1046,81 @@ function createP2PTransport(cfg) {
1041
1046
  };
1042
1047
  }
1043
1048
 
1049
+ /**
1050
+ * The `p2p` block a dispatch publishes through the relay, and what it is for.
1051
+ *
1052
+ * WHAT THE EXECUTOR CANNOT DO WITHOUT IT
1053
+ *
1054
+ * A dispatcher behind a symmetric NAT (CGNAT, in practice) can send all it likes and still be heard
1055
+ * by nobody: its outbound HELLO opens its own mapping, but the peer's filter stays shut until
1056
+ * *something of the peer's* has gone the other way. So the executor has to dial first — and to dial
1057
+ * it needs two things the offer itself can carry: where the dispatcher says it is, and which punch
1058
+ * to join. Without them the executor has only the relay lookup, which is a second opinion about the
1059
+ * same machine that answers 404 for a dispatcher whose announcement has just expired.
1060
+ *
1061
+ * WHY A PUNCH ID RATHER THAN A SESSION ID
1062
+ *
1063
+ * The session id cannot be known when this request is written: one dispatch leases one task to
1064
+ * several machines, and the relay decides which — in the same request that mints the task id. A
1065
+ * single session shared by those machines would be a defect, not a simplification: on the
1066
+ * dispatcher's own node the second machine's HELLO would look like a re-acknowledgement of the
1067
+ * first machine's live channel, so one of them would have its result delivered to the other's
1068
+ * address. The block therefore publishes a per-dispatch *punch id*, and both ends derive the same
1069
+ * per-machine session from it (`punchSession` in `src/agent/p2p.mjs`): the dispatcher from the
1070
+ * lease, the executor from the offer plus its own machine id. The pushed frame — which is addressed
1071
+ * to one machine, so the value *is* known — carries the derived `session` as well.
1072
+ *
1073
+ * COMPATIBILITY
1074
+ *
1075
+ * Both keys are additions *inside* an object the relay already treats as opaque (`validateTaskRouting`
1076
+ * checks `p2p` is an object with a valid `mode` and copies the rest verbatim), and both a v0.4.x
1077
+ * relay and a v0.4.x agent ignore keys they do not read. `relay` mode publishes `{mode}` alone: there
1078
+ * is no punch, and saying so is better than publishing a stale one.
1079
+ *
1080
+ * @param {ReturnType<typeof readConfig>} cfg
1081
+ * @param {object} runtime The P2P runtime handle from {@link createP2PRuntime}.
1082
+ * @returns {{mode: string, punch?: string, candidates?: Array<{address:string, port:number}>}}
1083
+ */
1084
+ function buildP2POfferBlock(cfg, runtime) {
1085
+ const block = { mode: cfg.p2pMode };
1086
+ if (cfg.p2pMode === 'relay') return block;
1087
+ // Minted here, before the task exists, because it has to travel in the request that *creates* the
1088
+ // task: the relay republishes the stored block to the executor, and there is no way to amend it
1089
+ // afterwards.
1090
+ block.punch = mintPunchId();
1091
+ // Announced candidates, when the node has already announced. Not waited for: `start()` kicks the
1092
+ // first announce off rather than awaiting the STUN sweep, and a dispatch delayed by four STUN
1093
+ // servers would trade a fast path for a slower one. Absent means "ask the relay", which is exactly
1094
+ // what the executor does when the field is missing.
1095
+ const candidates = runtime?.node?.status?.candidates;
1096
+ if (Array.isArray(candidates) && candidates.length > 0) {
1097
+ block.candidates = candidates.map((candidate) => ({ address: candidate.address, port: candidate.port }));
1098
+ }
1099
+ return block;
1100
+ }
1101
+
1102
+ /**
1103
+ * One machine's copy of the `p2p` block: everything published, plus the session for *this* machine.
1104
+ *
1105
+ * The added `session` is what makes the push and a reverse dial one punch rather than two: an
1106
+ * executor that received the offer over a channel may still have to dial back (the dispatcher closes
1107
+ * the channel it pushed over, immediately after the push), and a dial carrying a session the
1108
+ * dispatcher is not filtering for would be answered by its `accept()` at best — a second session,
1109
+ * a second channel, and two live sessions for one task where one would do.
1110
+ *
1111
+ * @param {unknown} published The block from {@link buildP2POfferBlock}, or whatever the caller sent.
1112
+ * @param {{taskId: string, machineId: string}} identity
1113
+ * @returns {object|null}
1114
+ */
1115
+ function offerP2PFor(published, { taskId, machineId }) {
1116
+ if (published === null || published === undefined) return null;
1117
+ if (typeof published !== 'object' || Array.isArray(published)) return null;
1118
+ const block = { ...published };
1119
+ const session = punchSession({ punch: block.punch, taskId, machineId });
1120
+ if (session !== null) block.session = session;
1121
+ return block;
1122
+ }
1123
+
1044
1124
  /**
1045
1125
  * Mint the offer frame the executor needs, from the lease the relay just issued.
1046
1126
  *
@@ -1103,7 +1183,7 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
1103
1183
  requirements: payload.requirements,
1104
1184
  compare_policy: payload.compare_policy,
1105
1185
  origin_machine_id: originMachineId,
1106
- p2p: payload.p2p ?? null,
1186
+ p2p: offerP2PFor(payload.p2p, { taskId, machineId }),
1107
1187
  },
1108
1188
  skipped: null,
1109
1189
  };
@@ -1112,25 +1192,40 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
1112
1192
  /**
1113
1193
  * Push one offer over the direct path. Best effort by construction: nothing here throws.
1114
1194
  *
1195
+ * The session comes from the frame's own `p2p.session` — minted per machine by
1196
+ * {@link offerP2PFor} from the punch id the relay is publishing for this same task. Dialling with a
1197
+ * session of the node's own invention would leave the two ends on two sessions, and an executor
1198
+ * whose reverse dial happened to arrive first would open a *second* channel beside this one instead of
1199
+ * the same one. `reused` in the answer is the honest report of the case where the executor's dial got
1200
+ * there first and this push therefore travelled over the channel that dial opened.
1201
+ *
1115
1202
  * @param {P2PNode} node
1116
1203
  * @param {object} frame
1117
- * @returns {Promise<{ok: boolean, error: string|null}>}
1204
+ * @returns {Promise<{ok: boolean, error: string|null, session: number|null, reused: boolean}>}
1118
1205
  */
1119
1206
  async function pushOffer(node, frame) {
1207
+ const session = Number.isInteger(frame?.p2p?.session) && frame.p2p.session > 0 ? frame.p2p.session : undefined;
1120
1208
  try {
1121
- const dialled = await node.dial(frame.machine_id);
1122
- if (!dialled.ok) return { ok: false, error: dialled.error ?? 'P2P_DIAL_FAILED' };
1209
+ const dialled = await node.dial(frame.machine_id, session === undefined ? {} : { session });
1210
+ if (!dialled.ok) {
1211
+ return { ok: false, error: dialled.error ?? 'P2P_DIAL_FAILED', session: dialled.session ?? null, reused: false };
1212
+ }
1123
1213
  const channel = dialled.channel;
1124
1214
  try {
1125
1215
  await channel.send(JSON.stringify(frame));
1126
- return { ok: true, error: null };
1216
+ return { ok: true, error: null, session: dialled.session ?? null, reused: dialled.reused === true };
1127
1217
  } finally {
1128
1218
  // The offer is one frame over a reliable channel: holding the channel open would keep a NAT
1129
1219
  // mapping alive for a conversation that is over, and the result comes back the responder's way.
1130
1220
  channel.close('offer-sent');
1131
1221
  }
1132
1222
  } catch (error) {
1133
- return { ok: false, error: `P2P_PUSH_THREW: ${error instanceof Error ? error.message : String(error)}` };
1223
+ return {
1224
+ ok: false,
1225
+ error: `P2P_PUSH_THREW: ${error instanceof Error ? error.message : String(error)}`,
1226
+ session: session ?? null,
1227
+ reused: false,
1228
+ };
1134
1229
  }
1135
1230
  }
1136
1231
 
@@ -1175,6 +1270,13 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId }) {
1175
1270
  ok: result.ok,
1176
1271
  error: result.error,
1177
1272
  dedupe_key: built.frame.dedupe_key === null ? null : 'relay-formula',
1273
+ // The session this push and the executor's reverse dial meet on, so a machine's own
1274
+ // `p2p.session` in the ledger can be matched to the dispatcher's attempt without reading two
1275
+ // logs side by side. `null` when there was nothing to punch over (a v0.4.x-shaped frame).
1276
+ session: result.session,
1277
+ // True when the executor's dial got there first and this push travelled over the channel that
1278
+ // dial opened — a two-way punch that worked, reported as such rather than as a plain push.
1279
+ reused: result.reused === true,
1178
1280
  });
1179
1281
  }
1180
1282
  return out;
@@ -2942,6 +3044,14 @@ export async function apply(ctx, config = {}) {
2942
3044
  const originMachineId =
2943
3045
  typeof p2p.machineId === 'string' && p2p.machineId !== '' ? p2p.machineId : null;
2944
3046
 
3047
+ /**
3048
+ * v0.4.x: the punch this dispatch will use, published with the task so the executor can join
3049
+ * the same session instead of waiting to be reached. See {@link buildP2POfferBlock} for why the
3050
+ * block carries a punch id rather than the session itself, and why `relay` mode publishes
3051
+ * `{mode}` alone.
3052
+ */
3053
+ const p2pOffer = buildP2POfferBlock(cfg, p2p);
3054
+
2945
3055
  const payload = {
2946
3056
  mode,
2947
3057
  // Under pipeline the relay derives this from stage 0; sending the caller's value when there is
@@ -2970,7 +3080,7 @@ export async function apply(ctx, config = {}) {
2970
3080
  // which path the dispatcher expected to use — and so the executor in `direct` mode knows it
2971
3081
  // must refuse an offer that did not arrive over the direct path.
2972
3082
  ...(originMachineId !== null ? { origin_machine_id: originMachineId } : {}),
2973
- p2p: { mode: cfg.p2pMode },
3083
+ p2p: p2pOffer,
2974
3084
  };
2975
3085
 
2976
3086
  const result = await request({
@@ -3012,7 +3122,17 @@ export async function apply(ctx, config = {}) {
3012
3122
  * `relay` mode and a machine with no direct path both report `attempted: 0`, which is the
3013
3123
  * honest answer ("there was nothing to push over") rather than an empty object.
3014
3124
  */
3015
- let directPushes = { mode: cfg.p2pMode, attempted: 0, delivered: 0, failed: 0, pushes: [] };
3125
+ let directPushes = {
3126
+ mode: cfg.p2pMode,
3127
+ // The punch this dispatch published and dialled with. Repeated here so one dispatch's answer
3128
+ // is self-contained: an operator correlating it with a machine's `p2p.session` or with the
3129
+ // machine's own log has the value in hand without a second call.
3130
+ punch: p2pOffer.punch ?? null,
3131
+ attempted: 0,
3132
+ delivered: 0,
3133
+ failed: 0,
3134
+ pushes: [],
3135
+ };
3016
3136
  if (cfg.p2pMode !== 'relay' && p2p.node !== null && p2p.start?.ok === true) {
3017
3137
  try {
3018
3138
  const pushes = await pushOffers(p2p.node, {
@@ -3022,21 +3142,22 @@ export async function apply(ctx, config = {}) {
3022
3142
  originMachineId,
3023
3143
  });
3024
3144
  directPushes = {
3025
- mode: cfg.p2pMode,
3145
+ ...directPushes,
3026
3146
  attempted: pushes.length,
3027
3147
  delivered: pushes.filter((entry) => entry.ok).length,
3028
3148
  failed: pushes.filter((entry) => !entry.ok).length,
3029
3149
  pushes,
3150
+ // How many of the machines that were reached were reached over a channel *this* machine
3151
+ // did not open — the executor dialled first, and the push landed on that path. Zero on a
3152
+ // fleet where only the dispatcher initiates, which is what makes the two-way punch
3153
+ // visible in one dispatch's own answer instead of only in the ledger.
3154
+ reused_channels: pushes.filter((entry) => entry.reused === true).length,
3030
3155
  };
3031
3156
  } catch (error) {
3032
3157
  // `pushOffers` is written not to throw; this is here so a future edit cannot turn a
3033
3158
  // best-effort optimisation into a failed dispatch.
3034
3159
  directPushes = {
3035
- mode: cfg.p2pMode,
3036
- attempted: 0,
3037
- delivered: 0,
3038
- failed: 0,
3039
- pushes: [],
3160
+ ...directPushes,
3040
3161
  error: `P2P_PUSH_FAILED: ${error instanceof Error ? error.message : String(error)}`,
3041
3162
  };
3042
3163
  }