@yz-social/kdht 0.1.2 → 0.1.4

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/dht/nodeProbe.js CHANGED
@@ -10,29 +10,49 @@ export class NodeProbe extends NodeMessages {
10
10
  static isContactsResult(rpcResult) {
11
11
  return Array.isArray(rpcResult);
12
12
  }
13
- async step(targetKey, finder, helper, keysSeen) {
13
+ async step(targetKey, finder, helper, keysSeen, trace) {
14
14
  // Get up to k previously unseen Helpers from helper, adding results to keysSeen.
15
15
  const contact = helper.contact;
16
16
  // this.log('step with', contact.sname);
17
17
  let results = await contact.sendRPC(finder, targetKey);
18
18
  if (!results) { // disconnected
19
+ if (trace) this.log(helper.name, '=> disconnected');
19
20
  this.log('removing unconnected contact', contact.sname);
20
21
  await this.removeContact(contact);
21
- return [];
22
+ return null; // signal that there is *no* response from this contact - to distinguish from a response that confirms that the contact is alive, even if there are (after filtering) no new contacts to try.
22
23
  }
23
24
  await this.addToRoutingTable(contact); // Live node, so update bucket.
24
25
  // this.log('step added contact', contact.sname);
25
26
  if (this.constructor.isContactsResult(results)) { // Keep only those that we have not seen, and note the new ones we have.
26
- results = results.filter(helper => !keysSeen.has(helper.key) && keysSeen.add(helper.key));
27
- // Results are (helpers around) contacts. Set them up for this host, with contact as sponsor.
27
+ const rawResults = results;
28
+ results = results.filter(helper => !keysSeen.has(helper.key) && keysSeen.add(helper.key)); // add() returns the Set, which is truthy
29
+ // Results are (helpers around) contacts, with distance from the target. Set them up for this host, with contact as sponsor.
28
30
  results = results.map(h => new Helper(this.ensureContact(h.contact, contact), h.distance));
29
- }
31
+ if (trace) {
32
+ const filterMsg = results.length === rawResults.length ? "" : ` after removing ${rawResults.map(h => h.name).filter(name => !results.some(r => r.name === name)).join(', ')}`;
33
+ this.log(`${helper.name} => ${results.length ? results.map(h => h.name) : '<empty>'}${filterMsg}`);
34
+ }
35
+ } else if (trace && false) this.log(`${helper.name} => ${results}`);
36
+
30
37
  return results;
31
38
  }
32
- static alpha = 3; // How many lookup requests are initially tried in parallel. If no progress, we repeat with up to k more.
33
- async iterate(targetKey, finder, k = this.constructor.k, trace = false) {
39
+
40
+ static alpha = 3; // How many lookup requests are kept in flight concurrently.
41
+ static queryTimeoutMs = 10000; // Give up on a query after this many ms.
42
+ async iterate(targetKey, finder, k = this.constructor.k, trace = false, timing = false, includeSelf = false) {
34
43
  // Promise a best-first list of k Helpers from the network, by repeatedly trying to improve our closest known by applying finder.
35
- // But if any finder operation answer isValueResult, answer that instead.
44
+ // But if any finder operation answers isValueResult, answer that instead.
45
+ // Note: When a value is found, returns { value, responder } where responder is the Helper that provided the value.
46
+ // This allows callers to identify which node responded. Use isValueResult() to check, and result.value to get the value.
47
+ //
48
+ // Per Kademlia paper: keeps alpha requests in flight while making progress.
49
+ // If alpha consecutive responses fail to find new nodes, escalates to k parallel queries.
50
+ // Terminates when among the k closest nodes we've seen, none have outstanding queries
51
+ // (they've all either responded or timed out).
52
+ //
53
+ // includeSelf: If true, the local node is eligible to be included in the results (currently used only for storeValue).
54
+
55
+ if (trace) this.log(`iterate: key=${targetKey}, finder=${finder}, k=${k}`);
36
56
 
37
57
  if (targetKey !== this.key) {
38
58
  const bucketIndex = this.getBucketIndex(targetKey);
@@ -40,48 +60,221 @@ export class NodeProbe extends NodeMessages {
40
60
  // Subtle: if we don't have one now, but will after, refreshes will be rescheduled by KBucket constructor.
41
61
  bucket?.resetRefresh();
42
62
  }
43
-
44
- // Each iteration uses a bigger pool than asked for, because some will have disconnected.
45
- let pool = this.findClosestHelpers(targetKey, 2*k); // The k best-first Helpers known so far, that we have NOT queried yet.
63
+
64
+ const alpha = this.constructor.alpha;
65
+ const queryTimeoutMs = this.constructor.queryTimeoutMs;
46
66
  const isValueResult = this.constructor.isValueResult;
47
- const alpha = Math.min(pool.length, this.constructor.alpha);
48
- const keysSeen = new Set(pool.map(h => h.key)); // Every key we've seen at all (candidates and all responses).
49
- keysSeen.add(this.key); // We might or might not be in our list of closest helpers, and we could be in someone else's.
50
- let toQuery = pool.slice(0, alpha);
51
- pool = pool.slice(alpha); // Yes, this could be done with splice instead of slice, above, but it makes things hard to trace.
52
- let best = []; // The accumulated closest-first result.
53
- while (toQuery.length && this.isRunning) { // Stop if WE disconnect.
54
- let requests = toQuery.map(helper => this.step(targetKey, finder, helper, keysSeen));
55
- let results = await Promise.all(requests);
56
- if (trace) this.log(toQuery.map(h => h.name), '=>', results.map(r => r.map?.(h => h.name) || r));
57
-
58
- let found = results.find(isValueResult); // Did we get back a 'findValue' result.
59
- if (found) {
60
- // Store at closest result that didn't have it (if any). This can cause more than k copies in the network.
61
- for (let i = 0; i < toQuery.length; i++) {
62
- if (!isValueResult(results[i])) {
63
- toQuery[i].contact.store(targetKey, found.value);
64
- break;
65
- }
66
- }
67
- return found;
68
- }
69
-
70
- let closer = [].concat(...results); // Flatten results.
71
- // closer might not be in order, and one or more toQuery might belong among them.
72
- best = [...closer, ...toQuery, ...best].sort(Helper.compare).slice(0, k);
73
- if (!closer.length) {
74
- if (toQuery.length === alpha && pool.length) {
75
- toQuery = pool.slice(0, 2*k); // Try again with k more. (Interestingly, not k - alpha.)
76
- pool = pool.slice(2*k);
77
- } else break; // We've tried everything and there's nothing better.
67
+ const iterateStartTime = timing ? Date.now() : 0;
68
+ let requestCount = 0;
69
+
70
+ // This is an iterative procedure, starting from the nodes among our own contacts that are
71
+ // closest to the specified target. The result of findClosestHelpers might turn out to include
72
+ // this node itself, but of course there is no point in sending ourselves a query to request
73
+ // what findClosestHelpers has already delivered, so the node is filtered out before starting
74
+ // the iteration. In the case of a caller that would be happy for this node to appear in the
75
+ // results (if it is indeed one of the closest), the caller should specify includeSelf=true;
76
+ // this is checked at the end of the method before handing the results back.
77
+ let allNodesSeen = this.findClosestHelpers(targetKey, 2*k).filter(h => h.key !== this.key);
78
+ const keysSeen = new Set(allNodesSeen.map(h => h.key)); // Every key we've seen at all (for filtering in step()).
79
+ keysSeen.add(this.key); // Prevent self from being added via other nodes' responses.
80
+
81
+ const pendingTimeouts = new Map(); // helper.key -> timeoutId (queries in flight)
82
+ const queryState = new Map(); // helper.key -> 'responded' | 'timedOut' | 'disconnected' (queries once they respond or are abandoned)
83
+ const queryResponders = []; // helpers that have responded (for building result)
84
+ let responsesWithoutNewNodes = 0; // count of successive empty responses
85
+ let maxInFlight = alpha; // Normal: alpha parallel queries. Escalates to k when a "round" of alpha queries all fail to find new nodes.
86
+ let iterationFinished = false;
87
+
88
+ let resolveIteration;
89
+ const iterationPromise = new Promise((resolve) => resolveIteration = (...args) => { iterationFinished = true; resolve(...args) }); // to be resolved with a value result, or undefined
90
+
91
+ // Check if termination condition is met:
92
+ // We're complete when we've found k 'responded' nodes with no unresolved nodes
93
+ // (pending or not-yet-queried) closer than them.
94
+ const isComplete = () => {
95
+ let respondedCount = 0;
96
+ for (const h of allNodesSeen) {
97
+ const state = queryState.get(h.key);
98
+ if (state === 'responded') {
99
+ respondedCount++;
100
+ if (respondedCount >= k) return true; // Found k responded with no unresolved gaps
101
+ } else if (!state) {
102
+ // No record means it's either a pending query, or that a query hasn't even been sent yet. We need to wait for it to resolve one way or another.
103
+ return false;
104
+ }
105
+ // 'timedOut' or 'disconnected': resolved but doesn't count, continue
106
+ }
107
+ // Exhausted list: fewer than k responsive nodes exist, but all are resolved
108
+ return true;
109
+ };
110
+
111
+ // Get the next closest node that needs to be queried
112
+ const getNextToQuery = () => {
113
+ for (const h of allNodesSeen) {
114
+ if (!pendingTimeouts.has(h.key) && !queryState.has(h.key)) {
115
+ return h;
116
+ }
117
+ }
118
+ return null;
119
+ };
120
+
121
+ // Handler for when a request completes. result is only expected if status='responded'.
122
+ const handleCompletion = (helper, status, result) => {
123
+ if (iterationFinished) return; // too late
124
+
125
+ if (!this.isRunning) {
126
+ resolveIteration();
127
+ return;
128
+ }
129
+
130
+ // Clear the timeout (if still active)
131
+ const timeoutId = pendingTimeouts.get(helper.key);
132
+ if (timeoutId) {
133
+ clearTimeout(timeoutId);
134
+ pendingTimeouts.delete(helper.key);
135
+ }
136
+
137
+ if (timing) {
138
+ const elapsed = Date.now() - iterateStartTime;
139
+ const label = status === 'responded' ? 'Response' : status === 'timedOut' ? 'Timeout' : 'Disconnected';
140
+ console.log(` ${label}: ${elapsed}ms - ${helper.name} (${pendingTimeouts.size} pending)`);
141
+ }
142
+
143
+ // Handle disconnected or timed-out node
144
+ if (status !== 'responded') {
145
+ queryState.set(helper.key, status);
78
146
  } else {
79
- pool = [...closer, ...pool].slice(0, 2*k); // k best-first nodes that we have not queried.
80
- toQuery = pool.slice(0, alpha);
81
- pool = pool.slice(alpha);
147
+ // Response arrived - mark as responded (even if previously marked as timed out)
148
+ queryState.set(helper.key, 'responded');
149
+ queryResponders.push(helper);
150
+
151
+ // Check for value result (immediate termination, after attempting to add one more storage node)
152
+ if (isValueResult(result)) {
153
+ // Store at closest node that didn't have it (if any). This can cause more than k copies in the network.
154
+ const sortedResponders = [...queryResponders].sort(Helper.compare);
155
+ for (const h of sortedResponders) {
156
+ if (h.key !== helper.key) { // Skip the one that returned the value
157
+ h.contact.store(targetKey, result.value);
158
+ break;
159
+ }
160
+ }
161
+
162
+ // Include responder info in the result for diagnostics
163
+ resolveIteration({ value: result.value, responder: helper });
164
+ return;
165
+ }
166
+
167
+ // Result is array of Helpers (may be empty if node had no new contacts)
168
+ // Merge new helpers into allNodesSeen and track progress
169
+ if (result.length > 0) {
170
+ allNodesSeen.push(...result);
171
+ allNodesSeen.sort(Helper.compare); // Keep sorted by distance (best-first).
172
+ responsesWithoutNewNodes = 0; // reset counter
173
+ maxInFlight = alpha; // Back to normal parallelism when making progress
174
+ } else {
175
+ responsesWithoutNewNodes++;
176
+ if (responsesWithoutNewNodes >= alpha && maxInFlight < k) {
177
+ // A "round" of alpha queries all failed to find new nodes.
178
+ // Per Kademlia paper: escalate to k parallel queries to cast a wider net.
179
+ maxInFlight = k;
180
+ if (trace) this.log('escalating to', k, 'parallel queries after', alpha, 'empty responses');
181
+ }
182
+ }
183
+ }
184
+
185
+ // Check termination
186
+ if (isComplete()) {
187
+ if (trace) this.log('terminated: k closest nodes all resolved');
188
+ resolveIteration();
189
+ return;
82
190
  }
191
+
192
+ // Or terminate when network is exhausted (fewer than k nodes available)
193
+ if (pendingTimeouts.size === 0 && !getNextToQuery()) {
194
+ if (trace) this.log('terminated: network exhausted');
195
+ resolveIteration();
196
+ return;
197
+ }
198
+
199
+ // Launch requests to maintain maxInFlight in flight
200
+ while (pendingTimeouts.size < maxInFlight && launchNext()) {
201
+ // launchNext returns false when no more candidates
202
+ }
203
+ };
204
+
205
+ // Launch a request to the next candidate
206
+ const launchNext = () => {
207
+ const helper = getNextToQuery();
208
+ if (!helper) return false;
209
+
210
+ // Set up active timeout that fires if no response arrives in time
211
+ const timeoutId = setTimeout(() => {
212
+ if (iterationFinished) return;
213
+ if (!pendingTimeouts.has(helper.key)) return; // Already resolved
214
+
215
+ if (trace) this.log('query timed out:', helper.name);
216
+
217
+ handleCompletion(helper, 'timedOut');
218
+ }, queryTimeoutMs);
219
+
220
+ pendingTimeouts.set(helper.key, timeoutId);
221
+
222
+ if (timing) {
223
+ requestCount++;
224
+ const elapsed = Date.now() - iterateStartTime;
225
+ console.log(` Launch ${requestCount}: ${elapsed}ms - ${helper.name} (${pendingTimeouts.size} pending)`);
226
+ }
227
+
228
+ this.step(targetKey, finder, helper, keysSeen, trace)
229
+ .then(result => handleCompletion(helper, 'responded', result))
230
+ .catch(err => {
231
+ // Handle errors - treat as disconnected
232
+ handleCompletion(helper, 'disconnected');
233
+ });
234
+
235
+ return true;
236
+ };
237
+
238
+ // Handle edge case: no nodes to query (isolated node)
239
+ if (allNodesSeen.length === 0) {
240
+ const contactCount = this.contacts.length;
241
+ this.log(`iterate(${finder}): no nodes to query - isolated node with ${contactCount} contacts in routing table`);
242
+ return [];
243
+ }
244
+
245
+ // Launch initial alpha requests
246
+ for (let i = 0; i < alpha; i++) {
247
+ if (!launchNext()) break;
83
248
  }
84
- if (trace) this.log('probe result', best.map(helper => helper.name));
85
- return best;
249
+
250
+ // Wait for iteration to complete (converged or exhausted, or found a value)
251
+ const valueResult = await iterationPromise; // undefined if no value result was received
252
+
253
+ if (timing) {
254
+ const totalElapsed = Date.now() - iterateStartTime;
255
+ console.log(` Total iterate time: ${totalElapsed}ms (${queryState.size} queried, ${queryResponders.length} responded)`);
256
+ }
257
+
258
+ if (valueResult) {
259
+ if (trace) this.log(`value result: ${valueResult} after responses from ${queryResponders.length} nodes`);
260
+ return valueResult;
261
+ }
262
+
263
+ // If includeSelf is true, add self to the results so that on re-sort it might end up among the k closest.
264
+ // This is used for storeValue, where the local node is itself a valid storage location.
265
+ if (includeSelf) {
266
+ const selfDistance = this.constructor.distance(this.key, targetKey);
267
+ const selfHelper = new Helper(this.contact, selfDistance);
268
+ queryResponders.push(selfHelper);
269
+ }
270
+
271
+ // Build result: k closest nodes that have actually responded
272
+ const closestResponsive = queryResponders
273
+ .sort(Helper.compare)
274
+ .slice(0, k);
275
+
276
+ if (trace) this.log('probe result', closestResponsive.map(helper => `${helper.name}@${String(helper.distance).slice(0,2)}[${String(helper.distance).length}]`).join(', '));
277
+
278
+ return closestResponsive;
86
279
  }
87
280
  }
@@ -7,7 +7,7 @@ export class NodeRefresh extends NodeKeys {
7
7
  super({refreshTimeIntervalMS, ...properties});
8
8
  }
9
9
  static stopRefresh() { // Stop all repeat timers in all instances the next time they come around.
10
- this.constructor.refreshTimeIntervalMS = 0;
10
+ this.refreshTimeIntervalMS = 0;
11
11
  }
12
12
  stopRefresh() { // Stop repeat timeers in this instance.
13
13
  this.refreshTimeIntervalMS = 0;
@@ -15,7 +15,10 @@ export class NodeRefresh extends NodeKeys {
15
15
  isStopped(interval) {
16
16
  return !this.isRunning || 0 === this.refreshTimeIntervalMS || 0 === this.constructor.refreshTimeIntervalMS || 0 === interval;
17
17
  }
18
- fuzzyInterval(target = this.refreshTimeIntervalMS/2, margin = target/2) { // Like static fuzzyInterval with target defaulting to refreshTimeIntervalMS/2.
18
+ // The refreshTimeIntervalMS is the number of nominal number milliseconds we expect to be able to handle for short session timems.
19
+ // The actual period between bucket and data refreshes may be more or less than this, depending on how well we deal with churn.
20
+ // That actual average time between refereshes is the default target value here. E.g., this.refreshTimeIntervalMS / 2, or 1.5 * this.refreshTimeIntervalMS, etc.
21
+ fuzzyInterval(target = 2 * this.refreshTimeIntervalMS, margin = target/2) { // Like static fuzzyInterval with target defaulting to refreshTimeIntervalMS/2.
19
22
  return this.constructor.fuzzyInterval(target, margin);
20
23
  }
21
24
  static fuzzyInterval(target, margin = target/2) {
@@ -32,7 +35,7 @@ export class NodeRefresh extends NodeKeys {
32
35
  return this.workQueue = this.workQueue.then(thunk);
33
36
  }
34
37
  timers = new Map();
35
- schedule(timerKey, statisticsKey, thunk) {
38
+ schedule(timerKey, statisticsKey, thunk, timeout = this.fuzzyInterval()) {
36
39
  // Schedule thunk() to occur at a fuzzyInterval from now, cancelling any
37
40
  // existing timer at the same key. This is used in such a way that:
38
41
  // 1. A side effect of calling thunk() is that it will be scheduled again, if appropriate.
@@ -41,7 +44,6 @@ export class NodeRefresh extends NodeKeys {
41
44
  // E.g., bucket index 1 === 1 and stored value key BigInt(1) === BigInt(1), but 1 !== BigInt(1)
42
45
  if (this.isStopped()) return;
43
46
  const start = Date.now();
44
- const timeout = this.fuzzyInterval();
45
47
  clearInterval(this.timers.get(timerKey));
46
48
  this.timers.set(timerKey, setTimeout(async () => {
47
49
  const lag = Date.now() - start - timeout;
@@ -5,7 +5,11 @@ export class NodeStorage extends NodeRefresh {
5
5
  storage = new Map(); // keys must be preserved as bigint, not converted to string.
6
6
  // TODO: store across sessions
7
7
  storeLocally(key, value) { // Store in memory by a BigInt key (must be already hashed). Not persistent.
8
+ const hadValue = this.storage.has(key);
8
9
  this.storage.set(key, value);
10
+ if (this.constructor.diagnosticTrace) {
11
+ this.log(`storeLocally(${key}, ${value}) - ${hadValue ? 'updated' : 'NEW'}`);
12
+ }
9
13
  // TODO: The paper says this can be optimized.
10
14
  // Claude.ai suggests just writing to the next in line, but that doesn't work.
11
15
  this.schedule(key, 'storage', () => this.storeValue(key, value));
@@ -16,7 +16,7 @@ export class NodeTransports extends NodeStorage {
16
16
  }
17
17
  return false;
18
18
  }
19
- static maxTransports = 32;
19
+ static maxTransports = 62; // FIXME: try 124
20
20
  noteContactForTransport(contact) { // We're about to use this contact for a message, so keep track of it.
21
21
  // Requires: if we later addToRoutingTable successfully, it should be removed from looseTransports.
22
22
  // Requires: if we later remove contact because of a failed send, it should be removed from looseTransports.
@@ -36,7 +36,7 @@ export class NodeTransports extends NodeStorage {
36
36
  }
37
37
  let dropped = removeLast(this.looseTransports);
38
38
  if (dropped) {
39
- console.log('dropping loose transport', dropped.name, 'in', this.name);
39
+ //console.log(this.name, 'dropping loose transport', dropped.name);
40
40
  } else { // Find the bucket with the most connections.
41
41
  let bestBucket = null, bestCount = 0;
42
42
  this.forEachBucket(bucket => {
@@ -48,7 +48,7 @@ export class NodeTransports extends NodeStorage {
48
48
  });
49
49
  dropped = removeLast(bestBucket.contacts);
50
50
  if (!dropped) console.log('Unable to find something to drop in', this.report(null));
51
- else console.log('dropping transport', dropped.name, 'in', this.name, bestBucket.index, 'among', bestCount);
51
+ //else console.log(this.name, 'dropping transport', dropped.name, 'in bucket', bestBucket.index, 'among', bestCount);
52
52
  }
53
53
  dropped.disconnectTransport();
54
54
  }
@@ -5,7 +5,7 @@ export class NodeUtilities {
5
5
  }
6
6
  isRunning = true;
7
7
  static delay(ms, value) { // Promise to resolve (to nothing) after a given number of milliseconds
8
- return new Promise(resolve => setTimeout(resolve, ms, value));
8
+ return new Promise(resolve => setTimeout(resolve, Math.max(0, ms), value));
9
9
  }
10
10
  static randomInteger(max) { // Return a random number between 0 (inclusive) and max (exclusive).
11
11
  return Math.floor(Math.random() * max);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yz-social/kdht",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Pure Kademlia base, for testing variations.",
5
5
  "exports": {
6
6
  ".": "./index.js",
@@ -9,25 +9,15 @@
9
9
  },
10
10
  "type": "module",
11
11
  "scripts": {
12
- "serverTest": "node spec/portal.js --nPortals 20 --nWrites 40",
13
- "serverTestLight": "node spec/portal.js --nPortals 10 --nWrites 40",
14
- "smoke": "node spec/portal.js --nPortals 5 --nBots 5 --thrash --nWrites 10",
15
- "smoke20": "node spec/portal.js --nPortals 10 --nBots 10 --thrash --nWrites 10",
16
- "load30": "node spec/portal.js --nPortals 10 --nBots 20 --nWrites 100",
17
- "load50": "node spec/portal.js --nPortals 10 --nBots 65 --nWrites 100",
18
- "load75": "node spec/portal.js --nPortals 10 --nBots 40 --nWrites 100",
19
- "load100": "node spec/portal.js --nPortals 10 --nBots 90 --nWrites 100",
20
- "load150": "node spec/portal.js --nPortals 15 --nBots 135 --nWrites 100",
21
- "load200": "node spec/portal.js --nPortals 20 --nBots 180 --nWrites 100",
22
- "thrash30": "node spec/portal.js --nPortals 10 --nBots 20 --thrash true --nWrites 100",
23
- "thrash50": "node spec/portal.js --nPortals 10 --nBots 40 --thrash true --nWrites 100",
24
- "thrash100": "node spec/portal.js --nPortals 10 --nBots 90 --thrash true --nWrites 100",
25
- "thrash150": "node spec/portal.js --nPortals 15 --nBots 135 --thrash true --nWrites 100",
26
- "thrash200": "node spec/portal.js --nPortals 20 --nBots 180 --thrash true --nWrites 100",
27
- "test": "npx jasmine"
12
+ "start": "node spec/portal.js",
13
+ "stop": "pkill kdht",
14
+ "background": "npm stop; (npm start 1>server.log 2>&1 &); sleep 1",
15
+ "bots": "node spec/bots.js",
16
+ "thrashbots": "node spec/bots.js --thrash",
17
+ "test": "npx jasmine && echo '-- SUCCESS --' || echo '**** FAIL ****'"
28
18
  },
29
19
  "dependencies": {
30
- "@yz-social/webrtc": "^0.1.0",
20
+ "@yz-social/webrtc": "^0.1.1",
31
21
  "uuid": "^13.0.0"
32
22
  },
33
23
  "devDependencies": {
package/portals/node.js CHANGED
@@ -28,4 +28,11 @@ export async function setup({baseURL, externalBaseURL = '', verbose, fixedSpacin
28
28
  const bootstrap = joinURL && await contact.ensureRemoteContact(bootstrapName, joinURL);
29
29
  process.send(contact.sname); // Report in to server as available for others to bootstrap through.
30
30
  if (bootstrap) await contact.join(bootstrap);
31
+ contact.host.xlog('joined');
32
+ process.on('SIGINT', async () => {
33
+ console.log(process.title, 'Shutdown for Ctrl+C');
34
+ await contact.disconnect();
35
+ process.exit(0);
36
+ });
37
+ return contact;
31
38
  }
package/spec/bots.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import {cpus, availableParallelism } from 'node:os';
2
3
  import cluster from 'node:cluster';
3
4
  import process from 'node:process';
4
5
  import { launchWriteRead } from './writes.js';
@@ -7,14 +8,16 @@ import { WebContact, Node } from '../index.js';
7
8
  import yargs from 'yargs';
8
9
  import { hideBin } from 'yargs/helpers';
9
10
 
11
+ const logicalCores = availableParallelism();
12
+
10
13
  // Todo: Allow a remote portal to be specified (passing a host to WebContact.create/ensureRemoteContact).
11
14
  const argv = yargs(hideBin(process.argv))
12
- .usage("Launch nBots that connect to the network through the local portal. A bot is just an ordinary node that can only be contacted through another node. They provide either continuity or churn-testing, depend on whether or not they are told to 'thrash'.")
15
+ .usage(`Launch nBots that connect to the network through the local portal. A bot is just an ordinary node that can only be contacted through another node. They provide either continuity or churn-testing, depend on whether or not they are told to 'thrash'. Model description "${cpus()[0].model}", ${logicalCores} logical cores.`)
13
16
  .option('nBots', {
14
17
  alias: 'n',
15
18
  alias: 'nbots',
16
19
  type: 'number',
17
- default: 20,
20
+ default: Math.min(logicalCores / 2, 2),
18
21
  description: "The number of bots, which can only be reached through the network."
19
22
  })
20
23
  .option('baseURL', {
@@ -61,13 +64,20 @@ let contact = await WebContact.create({name: host, debug: argv.verbose});
61
64
  let bootstrapName = await contact.fetchBootstrap(argv.baseURL);
62
65
  let bootstrapContact = await contact.ensureRemoteContact(bootstrapName, argv.baseURL);
63
66
  await contact.join(bootstrapContact);
67
+ contact.host.xlog('joined');
68
+
69
+ // process.on('SIGINT', async () => {
70
+ // console.log(process.title, 'Shutdown for Ctrl+C');
71
+ // await contact.disconnect();
72
+ // process.exit(0);
73
+ // });
64
74
 
65
75
  while (argv.thrash) {
66
76
  await Node.delay(contact.host.fuzzyInterval(Node.refreshTimeIntervalMS));
67
77
  const old = contact;
68
78
  const next = uuidv4();
69
79
  contact.host.xlog('disconnecting');
70
- contact.disconnect();
80
+ await contact.disconnect();
71
81
  await Node.delay(1e3); // TODO: remove?
72
82
 
73
83
  contact = await WebContact.create({name: next, debug: argv.verbose});
@@ -10,7 +10,7 @@ const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach} = glob
10
10
  import { setupServerNodes, shutdownServerNodes,
11
11
  start1, setupClientsByTime, shutdownClientNodes,
12
12
  getContacts, getRandomLiveContact,
13
- startThrashing, write1, read1, Node } from './dhtImplementation.js';
13
+ startThrashing, write1, read1, Node, Contact } from './dhtImplementation.js';
14
14
 
15
15
  // Some definitions:
16
16
  //
@@ -118,7 +118,13 @@ async function parallelReadAll(start = 0) {
118
118
  const contacts = await getContacts();
119
119
  const readPromises = await Promise.all(contacts.map(async (_, index) => {
120
120
  if (index < start) return;
121
- const value = await read1(await getRandomLiveContact(), index);
121
+ let value;
122
+ for (let liveTry = 0; liveTry < 4; liveTry++) {
123
+ const contact = await getRandomLiveContact();
124
+ value = await read1(contact, index);
125
+ if (contact.isRunning) break;
126
+ console.log('\n\n*** read from dead contact. retrying', liveTry, '***\n');
127
+ }
122
128
  expect(value).toBe(index);
123
129
  }));
124
130
  return readPromises.length - start;
@@ -136,8 +142,8 @@ describe("DHT", function () {
136
142
  function test(parameters = {}) {
137
143
  // Define a suite of tests with the given parameters.
138
144
  const {nServerNodes = 10,
139
- pingTimeMS = 40, // Round-trip network time. Implementation should pad network calls to achieve what is specified here.
140
- maxTransports = 152, // How many direct connections are allowed per node?
145
+ pingTimeMS = Contact.pingTimeMS, // Round-trip network time. Implementation should pad network calls to achieve what is specified here.
146
+ maxTransports = Node.maxTransports, // How many direct connections are allowed per node?
141
147
  maxClientNodes = Infinity, // If zero, will try to make as many as it can in refreshTimeIntervalMS.
142
148
  refreshTimeIntervalMS = 15e3, // How long on average does a client stay up?
143
149
  setupTimeMS = Math.max(2e3, refreshTimeIntervalMS), // How long to create the test nodes.
@@ -148,7 +154,7 @@ describe("DHT", function () {
148
154
  } = parameters;
149
155
  const suiteLabel = `Server nodes: ${nServerNodes}, setup time: ${setupTimeMS}, max client nodes: ${maxClientNodes ?? Infinity}, ping: ${pingTimeMS}ms, max connections: ${maxTransports}, refresh: ${refreshTimeIntervalMS.toFixed(3)}ms, pause before write: ${runtimeBeforeWriteMS.toFixed(3)}ms, pause before read: ${runtimeBeforeReadMS.toFixed(3)}ms, thrash before: ${startThrashingBefore}`;
150
156
 
151
- describe(suiteLabel, function () {
157
+ describe(notes || suiteLabel, function () {
152
158
  beforeAll(async function () {
153
159
  console.log('\n' + suiteLabel);
154
160
  if (notes) console.log(notes);
@@ -206,17 +212,25 @@ describe("DHT", function () {
206
212
 
207
213
  // Each call here sets up a full suite of tests with the given parameters, which can be useful for development and debugging.
208
214
  // For example:
215
+ test({maxClientNodes: 10, startThrashingBefore: 'never', runtimeBeforeWriteMS: 0, runtimeBeforeReadMS: 0, notes: "Smoke"});
209
216
  test({pingTimeMS: 0, refreshTimeIntervalMS: 0, startThrashingBefore: 'never', notes: "Runs flat out if probing and disconnects turned off."});
210
217
  test({setupTimeMS: 1e3, pingTimeMS: 0, startThrashingBefore: 'never', notes: "Probing on, but no disconnects or network delay."});
211
- test({maxClientNodes: 30, pingTimeMS: 0, refreshTimeIntervalMS: 5e3, notes: "Small networks allow faster smoke-testing."});
212
- test({maxTransports: 85, maxClientNodes: 90, pingTimeMS: 10, setupTimeMS: 20e3, notes: "Limit number of transports enough to exercise the reconnect logic."});
213
- test({maxClientNodes: 140, setupTimeMS: 60e3, pingTimeMS: 10, notes: "Relatively larger network size."});
218
+ test({pingTimeMS: 0, refreshTimeIntervalMS: 5e3, notes: "Small networks allow faster thrash smoke-testing."});
219
+ test({notes: "Normal ops"});
220
+
221
+ // test({maxClientNodes: 55, setupTimeMS: 240e3, pingTimeMS: 40, maxTransports: 62,
222
+ // //startThrashingBefore: 'never', runtimeBeforeWriteMS: 0, runtimeBeforeReadMS: 0,
223
+ // notes: "Moderate transport-dropping for currently over-constricted contact limits."});
224
+
225
+
226
+ //test({maxTransports: 85, maxClientNodes: 90, pingTimeMS: 10, setupTimeMS: 20e3, notes: "Limit number of transports enough to exercise the reconnect logic."});
227
+ //test({maxClientNodes: 140, setupTimeMS: 60e3, pingTimeMS: 10, notes: "Relatively larger network size."});
214
228
 
215
229
  //test({maxTransports: 95, maxClientNodes: 100, refreshTimeIntervalMS: 0, startThrashingBefore: 'never', notes: 'dev: no refresh, no thrashing'});
216
230
  //test({maxTransports: 95, maxClientNodes: 100, startThrashingBefore: 'never', notes: 'dev: no thrashing'});
217
231
 
218
232
  //test({maxClientNodes: 7, nServerNodes: 5, refreshTimeIntervalMS: 3e3, runtimeBeforeWriteMS: 0e3, runtimeBeforeReadMS: 0e3, startThrashingBefore: 'never'});
219
- //test({maxClientNodes: 3, nServerNodes: 3, refreshTimeIntervalMS: 3e3, runtimeBeforeWriteMS: 6e3, runtimeBeforeReadMS: 6e3});
233
+ //test({maxClientNodes: 3, nServerNodes: 3, startThrashingBefore: 'never', refreshTimeIntervalMS: 3e3, runtimeBeforeWriteMS: 6e3, runtimeBeforeReadMS: 6e3});
220
234
 
221
235
 
222
236
  // TODO:
@@ -11,10 +11,8 @@
11
11
 
12
12
  // In the present case, these manipulate a Contact that directly contains a
13
13
  // DHT node with simulated networking.
14
- //import { InProcessWebContact as Contact, Node } from '../index.js';
15
- //import { SimulatedContact as Contact, Node } from '../index.js';
16
14
  import { SimulatedConnectionContact as Contact, Node } from '../index.js';
17
- export { Node };
15
+ export { Node, Contact };
18
16
 
19
17
 
20
18
  export async function start1(name, bootstrapContact, refreshTimeIntervalMS, isServerNode = false) {
@@ -24,6 +22,7 @@ export async function start1(name, bootstrapContact, refreshTimeIntervalMS, isSe
24
22
  }
25
23
 
26
24
  export async function startServerNode(name, bootstrapContact, refreshTimeIntervalMS) {
25
+ Node.refreshTimeIntervalMS = refreshTimeIntervalMS;
27
26
  return await start1(name, bootstrapContact, refreshTimeIntervalMS, true);
28
27
  }
29
28
 
@@ -187,5 +186,6 @@ async function serialSetupClientsByTime(refreshTimeIntervalMS, nServerNodes, max
187
186
  export async function shutdownClientNodes(nServerNodes, nClientNodes) {
188
187
  await stopThrashing();
189
188
  await new Promise(resolve => setTimeout(resolve, 5e3));
189
+ Node.refreshTimeIntervalMS = 0;
190
190
  await shutdown(nServerNodes, nClientNodes + nServerNodes);
191
191
  }