@twinsearth/w2m-dsh-plugin 0.4.9 → 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.
- package/CHANGELOG.md +66 -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/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/p2p-node.mjs
CHANGED
|
@@ -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}>}
|
|
954
|
-
* how many candidate addresses the relay held for
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
448
|
-
|
|
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
|
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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)
|
|
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 {
|
|
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:
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|