socket-function 1.1.5 → 1.1.7
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/SocketFunction.d.ts +4 -0
- package/SocketFunction.ts +6 -0
- package/SocketFunctionTypes.ts +1 -0
- package/index.d.ts +15 -0
- package/package.json +4 -3
- package/run-test.js +59 -0
- package/src/CallFactory.ts +26 -3
- package/test.ts +158 -24
- package/time/trueTimeShim.d.ts +11 -0
- package/time/trueTimeShim.ts +527 -224
package/SocketFunction.d.ts
CHANGED
|
@@ -29,6 +29,10 @@ export declare class SocketFunction {
|
|
|
29
29
|
serialize: (obj: unknown) => MaybePromise<Buffer[]>;
|
|
30
30
|
deserialize: (buffers: Buffer[]) => MaybePromise<unknown>;
|
|
31
31
|
};
|
|
32
|
+
/** We will try the alternate node IDs first, however, if they fail, we will go through all of them and then eventually try the original node ID.
|
|
33
|
+
* VERY useful, allowing us to change global ips to local ones, which short-circuits the router, massively increasing bandwidth and decreasing latency.
|
|
34
|
+
*/
|
|
35
|
+
static GET_ALTERNATE_NODE_IDS: (nodeId: string) => MaybePromise<string[] | undefined>;
|
|
32
36
|
static WIRE_WARN_TIME: number;
|
|
33
37
|
private static onMountCallbacks;
|
|
34
38
|
static exposedClasses: Set<string>;
|
package/SocketFunction.ts
CHANGED
|
@@ -72,11 +72,17 @@ export class SocketFunction {
|
|
|
72
72
|
// In retrospect... dynamically changing the wire serializer is a BAD idea. If any calls happen
|
|
73
73
|
// before it is changed, things just break. Also, it needs to be changed on both sides,
|
|
74
74
|
// or else things break. Also, it is very hard to detect when the issue is different serializers
|
|
75
|
+
// NOTE: The only reason this is still exposed is in case in the future we want to intercept our traffic, and we want convenient functions to know how to decode it (although there are a still few other layers under this, for compression and Buffer[] sending efficiency).
|
|
75
76
|
public static readonly WIRE_SERIALIZER = {
|
|
76
77
|
serialize: measureWrap((obj: unknown): MaybePromise<Buffer[]> => [cborxInstance.encode(obj)], "WIRE_SERIALIZER|serialize"),
|
|
77
78
|
deserialize: measureWrap((buffers: Buffer[]): MaybePromise<unknown> => cborxInstance.decode(buffers[0]), "WIRE_SERIALIZER|deserialize"),
|
|
78
79
|
};
|
|
79
80
|
|
|
81
|
+
/** We will try the alternate node IDs first, however, if they fail, we will go through all of them and then eventually try the original node ID.
|
|
82
|
+
* VERY useful, allowing us to change global ips to local ones, which short-circuits the router, massively increasing bandwidth and decreasing latency.
|
|
83
|
+
*/
|
|
84
|
+
public static GET_ALTERNATE_NODE_IDS = (nodeId: string): MaybePromise<string[] | undefined> => undefined;
|
|
85
|
+
|
|
80
86
|
public static WIRE_WARN_TIME = 100;
|
|
81
87
|
|
|
82
88
|
private static onMountCallbacks = new Map<string, (() => MaybePromise<void>)[]>();
|
package/SocketFunctionTypes.ts
CHANGED
|
@@ -130,5 +130,6 @@ export type CallerContextBase = {
|
|
|
130
130
|
// requests can be redirected to us and would accept them, even though they are being
|
|
131
131
|
// blatantly MITMed).
|
|
132
132
|
// IF they are the server, calling us back, then this will just be ""
|
|
133
|
+
// If they're connecting on the same machine, this might be a special domain (127-0-0-1.querysub.com). This is controlled by the GET_ALTERNATE_NODE_IDS override.
|
|
133
134
|
localNodeId: string;
|
|
134
135
|
};
|
package/index.d.ts
CHANGED
|
@@ -38,6 +38,10 @@ declare module "socket-function/SocketFunction" {
|
|
|
38
38
|
serialize: (obj: unknown) => MaybePromise<Buffer[]>;
|
|
39
39
|
deserialize: (buffers: Buffer[]) => MaybePromise<unknown>;
|
|
40
40
|
};
|
|
41
|
+
/** We will try the alternate node IDs first, however, if they fail, we will go through all of them and then eventually try the original node ID.
|
|
42
|
+
* VERY useful, allowing us to change global ips to local ones, which short-circuits the router, massively increasing bandwidth and decreasing latency.
|
|
43
|
+
*/
|
|
44
|
+
static GET_ALTERNATE_NODE_IDS: (nodeId: string) => MaybePromise<string[] | undefined>;
|
|
41
45
|
static WIRE_WARN_TIME: number;
|
|
42
46
|
private static onMountCallbacks;
|
|
43
47
|
static exposedClasses: Set<string>;
|
|
@@ -1408,6 +1412,17 @@ declare module "socket-function/test" {
|
|
|
1408
1412
|
}
|
|
1409
1413
|
|
|
1410
1414
|
declare module "socket-function/time/trueTimeShim" {
|
|
1415
|
+
export declare function getTimeComponentsDetailed(): {
|
|
1416
|
+
systemTime: number;
|
|
1417
|
+
fromOffset: number;
|
|
1418
|
+
toOffset: number;
|
|
1419
|
+
fromTime: number;
|
|
1420
|
+
toTime: number;
|
|
1421
|
+
};
|
|
1422
|
+
export declare function getTimeComponents(): {
|
|
1423
|
+
systemTime: number;
|
|
1424
|
+
offset: number;
|
|
1425
|
+
};
|
|
1411
1426
|
export declare function getTrueTime(): number;
|
|
1412
1427
|
export declare function getTrueTimeOffset(): number;
|
|
1413
1428
|
export declare function waitForFirstTimeSync(): Promise<void> | undefined;
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "socket-function",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/pako": "^2.0.3",
|
|
8
8
|
"@types/ws": "^8.5.3",
|
|
9
9
|
"cbor-x": "^1.6.0",
|
|
10
|
+
"lmdb": "^3.5.1",
|
|
10
11
|
"mobx": "^6.6.2",
|
|
11
12
|
"pako": "^2.1.0",
|
|
12
13
|
"preact": "10.24.3",
|
|
@@ -16,11 +17,11 @@
|
|
|
16
17
|
},
|
|
17
18
|
"types": "index.d.ts",
|
|
18
19
|
"scripts": {
|
|
19
|
-
"test": "yarn typenode ./test
|
|
20
|
+
"test": "yarn typenode ./test.ts",
|
|
21
|
+
"test-all": "node run-test.js",
|
|
20
22
|
"type": "yarn tsc --noEmit",
|
|
21
23
|
"emit-dts": "yarn tsc --project tsconfig.declarations.json || exit 0",
|
|
22
24
|
"generate-index-dts": "node ./generateIndexDts.js",
|
|
23
|
-
"update-typings": "yarn generate-index-dts && yarn emit-dts",
|
|
24
25
|
"update-types": "yarn emit-dts && yarn generate-index-dts",
|
|
25
26
|
"prepublishOnly": "yarn update-types",
|
|
26
27
|
"testsni": "yarn typenode ./src/sniTest.ts"
|
package/run-test.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// Clean up old test files
|
|
6
|
+
console.log('Cleaning up old test files...');
|
|
7
|
+
const distDir = path.join(__dirname, 'dist');
|
|
8
|
+
if (fs.existsSync(distDir)) {
|
|
9
|
+
const files = fs.readdirSync(distDir)
|
|
10
|
+
.filter(f => f.startsWith('time-samples-') && f.endsWith('.json'));
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
fs.unlinkSync(path.join(distDir, file));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Launch 4 concurrent processes
|
|
17
|
+
console.log('Launching 4 concurrent test processes...\n');
|
|
18
|
+
|
|
19
|
+
function runTest() {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const proc = spawn('yarn', ['test'], {
|
|
22
|
+
stdio: 'inherit',
|
|
23
|
+
shell: true
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
proc.on('close', (code) => {
|
|
27
|
+
if (code === 0) {
|
|
28
|
+
resolve();
|
|
29
|
+
} else {
|
|
30
|
+
reject(new Error(`Process exited with code ${code}`));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
proc.on('error', reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Promise.all([
|
|
39
|
+
runTest(),
|
|
40
|
+
runTest(),
|
|
41
|
+
runTest(),
|
|
42
|
+
runTest()
|
|
43
|
+
])
|
|
44
|
+
.then(() => {
|
|
45
|
+
console.log('\n\nAll sampling complete. Running verification...\n');
|
|
46
|
+
|
|
47
|
+
const verify = spawn('yarn', ['test', 'verify'], {
|
|
48
|
+
stdio: 'inherit',
|
|
49
|
+
shell: true
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
verify.on('close', (code) => {
|
|
53
|
+
process.exit(code);
|
|
54
|
+
});
|
|
55
|
+
})
|
|
56
|
+
.catch((err) => {
|
|
57
|
+
console.error('Error running tests:', err);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
package/src/CallFactory.ts
CHANGED
|
@@ -19,8 +19,6 @@ import { measureFnc, measureWrap, registerMeasureInfo } from "./profiling/measur
|
|
|
19
19
|
import { MaybePromise } from "./types";
|
|
20
20
|
import { Zip } from "./Zip";
|
|
21
21
|
import { LZ4 } from "./lz4/LZ4";
|
|
22
|
-
//LZ4.compress;
|
|
23
|
-
//LZ4.decompress;
|
|
24
22
|
|
|
25
23
|
setFlag(require, "pako", "allowclient", true);
|
|
26
24
|
|
|
@@ -282,16 +280,22 @@ export async function createCallFactory(
|
|
|
282
280
|
};
|
|
283
281
|
|
|
284
282
|
let webSocketPromise: Promise<SenderInterface> | undefined;
|
|
283
|
+
let hasEverConnected = false;
|
|
285
284
|
if (webSocketBase) {
|
|
286
285
|
webSocketPromise = Promise.resolve(webSocketBase);
|
|
287
286
|
await initializeWebsocket(webSocketBase);
|
|
288
287
|
}
|
|
289
288
|
|
|
290
|
-
async function initializeWebsocket(newWebSocket: SenderInterface) {
|
|
289
|
+
async function initializeWebsocket(newWebSocket: SenderInterface, skipCloseHandling = false) {
|
|
291
290
|
registerOnce();
|
|
292
291
|
callFactory.receivedInitializeState = undefined;
|
|
293
292
|
|
|
294
293
|
function onClose(error: string) {
|
|
294
|
+
// We try various connections, and if they fail, we will just try other node IDs until we finally do connect, and then we stick with that nodeId, and when it disconnects we need to handle disconnections normally.
|
|
295
|
+
if (skipCloseHandling && !hasEverConnected) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
295
299
|
callFactory.connectionId = { nodeId };
|
|
296
300
|
callFactory.lastClosed = Date.now();
|
|
297
301
|
callFactory.isConnected = false;
|
|
@@ -341,6 +345,7 @@ export async function createCallFactory(
|
|
|
341
345
|
console.log(`Connection established to ${niceConnectionName}`);
|
|
342
346
|
}
|
|
343
347
|
callFactory.isConnected = true;
|
|
348
|
+
hasEverConnected = true;
|
|
344
349
|
resolve();
|
|
345
350
|
});
|
|
346
351
|
newWebSocket.addEventListener("close", () => resolve());
|
|
@@ -348,6 +353,7 @@ export async function createCallFactory(
|
|
|
348
353
|
});
|
|
349
354
|
} else if (newWebSocket.readyState === 1 /* OPEN */) {
|
|
350
355
|
callFactory.isConnected = true;
|
|
356
|
+
hasEverConnected = true;
|
|
351
357
|
} else {
|
|
352
358
|
onClose(new Error(`Websocket received in closed state`).stack!);
|
|
353
359
|
}
|
|
@@ -441,6 +447,23 @@ export async function createCallFactory(
|
|
|
441
447
|
}
|
|
442
448
|
lastConnectionAttempt = Date.now();
|
|
443
449
|
|
|
450
|
+
// Try alternates, and if any work, use them
|
|
451
|
+
try {
|
|
452
|
+
let alternates = await SocketFunction.GET_ALTERNATE_NODE_IDS(nodeId);
|
|
453
|
+
if (alternates) {
|
|
454
|
+
for (let alternateNodeId of alternates) {
|
|
455
|
+
let newWebSocket = createWebsocket(alternateNodeId);
|
|
456
|
+
await initializeWebsocket(newWebSocket, true);
|
|
457
|
+
|
|
458
|
+
if (callFactory.isConnected) {
|
|
459
|
+
return newWebSocket;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
} catch (e) {
|
|
464
|
+
console.error("Error getting alternate node IDs", e);
|
|
465
|
+
}
|
|
466
|
+
|
|
444
467
|
let newWebSocket = createWebsocket(nodeId);
|
|
445
468
|
await initializeWebsocket(newWebSocket);
|
|
446
469
|
|
package/test.ts
CHANGED
|
@@ -1,33 +1,167 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import * as
|
|
4
|
-
import os from "os";
|
|
5
|
-
import debugbreak from "debugbreak";
|
|
6
|
-
import { forwardPort } from "./src/forwardPort";
|
|
1
|
+
import { getTimeComponentsDetailed, waitForFirstTimeSync } from "./time/trueTimeShim";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
7
4
|
|
|
5
|
+
const SAMPLE_COUNT = 1000;
|
|
6
|
+
const SAMPLE_INTERVAL_MS = 10;
|
|
8
7
|
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
// Generate a simple numeric ID for this test run
|
|
9
|
+
const TEST_RUN_ID = Math.floor(Math.random() * 10000);
|
|
10
|
+
|
|
11
|
+
async function sampleMode() {
|
|
12
|
+
await waitForFirstTimeSync();
|
|
13
|
+
|
|
14
|
+
type Sample = {
|
|
15
|
+
id: number;
|
|
16
|
+
systemTime: number;
|
|
17
|
+
offset: number;
|
|
18
|
+
fromOffset: number;
|
|
19
|
+
toOffset: number;
|
|
20
|
+
fromTime: number;
|
|
21
|
+
toTime: number;
|
|
22
|
+
fraction: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const samples: Sample[] = [];
|
|
26
|
+
|
|
27
|
+
console.log(`[Test ID: ${TEST_RUN_ID}] Sampling ${SAMPLE_COUNT} time components...`);
|
|
28
|
+
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
|
29
|
+
const detailed = getTimeComponentsDetailed();
|
|
30
|
+
|
|
31
|
+
// Calculate smearing using the same systemTime
|
|
32
|
+
const elapsed = detailed.systemTime - detailed.fromTime;
|
|
33
|
+
const duration = detailed.toTime - detailed.fromTime;
|
|
34
|
+
const fraction = duration > 0 ? Math.min(1, elapsed / duration) : 0;
|
|
35
|
+
const offset = detailed.fromOffset + (detailed.toOffset - detailed.fromOffset) * fraction;
|
|
36
|
+
|
|
37
|
+
samples.push({
|
|
38
|
+
id: TEST_RUN_ID,
|
|
39
|
+
systemTime: detailed.systemTime,
|
|
40
|
+
offset,
|
|
41
|
+
fromOffset: detailed.fromOffset,
|
|
42
|
+
toOffset: detailed.toOffset,
|
|
43
|
+
fromTime: detailed.fromTime,
|
|
44
|
+
toTime: detailed.toTime,
|
|
45
|
+
fraction,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (SAMPLE_INTERVAL_MS > 0) {
|
|
49
|
+
await new Promise(resolve => setTimeout(resolve, SAMPLE_INTERVAL_MS));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Write to file with unique name
|
|
54
|
+
const distDir = path.join(__dirname, "dist");
|
|
55
|
+
if (!fs.existsSync(distDir)) {
|
|
56
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const filename = path.join(distDir, `time-samples-${Date.now()}-${process.pid}.json`);
|
|
60
|
+
fs.writeFileSync(filename, JSON.stringify(samples, null, 2));
|
|
61
|
+
|
|
62
|
+
console.log(`[Test ID: ${TEST_RUN_ID}] Wrote ${samples.length} samples to ${filename}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function verifyMode() {
|
|
66
|
+
const distDir = path.join(__dirname, "dist");
|
|
67
|
+
if (!fs.existsSync(distDir)) {
|
|
68
|
+
console.error("No dist directory found. Run sampling mode first.");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
13
71
|
|
|
14
|
-
|
|
72
|
+
const files = fs.readdirSync(distDir)
|
|
73
|
+
.filter(f => f.startsWith("time-samples-") && f.endsWith(".json"))
|
|
74
|
+
.map(f => path.join(distDir, f));
|
|
15
75
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// });
|
|
21
|
-
// server.listen(externalPort, "0.0.0.0");
|
|
76
|
+
if (files.length === 0) {
|
|
77
|
+
console.error("No sample files found. Run sampling mode first.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
22
80
|
|
|
23
|
-
|
|
24
|
-
// const externalIP = await getExternalIP();
|
|
25
|
-
// let test = await fetch(`http://${externalIP}:${externalPort}`);
|
|
26
|
-
// console.log(await test.text());
|
|
27
|
-
// }
|
|
81
|
+
console.log(`Found ${files.length} sample files`);
|
|
28
82
|
|
|
83
|
+
type Sample = {
|
|
84
|
+
id: number;
|
|
85
|
+
systemTime: number;
|
|
86
|
+
offset: number;
|
|
87
|
+
fromOffset: number;
|
|
88
|
+
toOffset: number;
|
|
89
|
+
fromTime: number;
|
|
90
|
+
toTime: number;
|
|
91
|
+
fraction: number;
|
|
92
|
+
file: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Load all samples from all files
|
|
96
|
+
const allSamples: Sample[] = [];
|
|
97
|
+
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
100
|
+
const samples = JSON.parse(content);
|
|
101
|
+
for (const sample of samples) {
|
|
102
|
+
allSamples.push({ ...sample, file: path.basename(file) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(`Loaded ${allSamples.length} total samples`);
|
|
107
|
+
|
|
108
|
+
// Sort by systemTime
|
|
109
|
+
allSamples.sort((a, b) => a.systemTime - b.systemTime);
|
|
110
|
+
|
|
111
|
+
// Verify that systemTime + offset is monotonically increasing
|
|
112
|
+
let errors = 0;
|
|
113
|
+
let lastTrueTime = -Infinity;
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < allSamples.length; i++) {
|
|
116
|
+
const sample = allSamples[i];
|
|
117
|
+
const trueTime = sample.systemTime + sample.offset;
|
|
118
|
+
|
|
119
|
+
if (trueTime < lastTrueTime) {
|
|
120
|
+
errors++;
|
|
121
|
+
const prev = allSamples[i - 1];
|
|
122
|
+
const prevTrueTime = prev.systemTime + prev.offset;
|
|
123
|
+
|
|
124
|
+
console.error(`\nERROR at index ${i}: trueTime went backwards!`);
|
|
125
|
+
console.error(` Previous [ID: ${prev.id}, file: ${prev.file}]:`);
|
|
126
|
+
console.error(` systemTime: ${prev.systemTime}`);
|
|
127
|
+
console.error(` offset: ${prev.offset}`);
|
|
128
|
+
console.error(` trueTime: ${prevTrueTime}`);
|
|
129
|
+
console.error(` smearing: ${prev.fromOffset} -> ${prev.toOffset} (${(prev.fraction * 100).toFixed(2)}%)`);
|
|
130
|
+
console.error(` timeWindow: ${prev.fromTime} -> ${prev.toTime}`);
|
|
131
|
+
console.error(` Current [ID: ${sample.id}, file: ${sample.file}]:`);
|
|
132
|
+
console.error(` systemTime: ${sample.systemTime}`);
|
|
133
|
+
console.error(` offset: ${sample.offset}`);
|
|
134
|
+
console.error(` trueTime: ${trueTime}`);
|
|
135
|
+
console.error(` smearing: ${sample.fromOffset} -> ${sample.toOffset} (${(sample.fraction * 100).toFixed(2)}%)`);
|
|
136
|
+
console.error(` timeWindow: ${sample.fromTime} -> ${sample.toTime}`);
|
|
137
|
+
console.error(` Difference: ${trueTime - lastTrueTime}ms`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lastTrueTime = trueTime;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (errors === 0) {
|
|
144
|
+
console.log(`✓ SUCCESS: All ${allSamples.length} samples are correctly ordered!`);
|
|
145
|
+
console.log(` Time range: ${allSamples[0].systemTime} to ${allSamples[allSamples.length - 1].systemTime}`);
|
|
146
|
+
console.log(` Duration: ${allSamples[allSamples.length - 1].systemTime - allSamples[0].systemTime}ms`);
|
|
147
|
+
} else {
|
|
148
|
+
console.error(`✗ FAILED: Found ${errors} ordering violations`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function main() {
|
|
154
|
+
const mode = process.argv[2];
|
|
29
155
|
|
|
30
|
-
|
|
156
|
+
if (mode === "once") {
|
|
157
|
+
await waitForFirstTimeSync();
|
|
158
|
+
console.log(Date.now());
|
|
159
|
+
}
|
|
160
|
+
else if (mode === "verify") {
|
|
161
|
+
await verifyMode();
|
|
162
|
+
} else {
|
|
163
|
+
await sampleMode();
|
|
164
|
+
}
|
|
31
165
|
}
|
|
32
166
|
|
|
33
|
-
main().catch(e => console.error(e));
|
|
167
|
+
main().catch(e => console.error(e)).finally(() => process.exit(0));
|
package/time/trueTimeShim.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
export declare function getTimeComponentsDetailed(): {
|
|
2
|
+
systemTime: number;
|
|
3
|
+
fromOffset: number;
|
|
4
|
+
toOffset: number;
|
|
5
|
+
fromTime: number;
|
|
6
|
+
toTime: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function getTimeComponents(): {
|
|
9
|
+
systemTime: number;
|
|
10
|
+
offset: number;
|
|
11
|
+
};
|
|
1
12
|
export declare function getTrueTime(): number;
|
|
2
13
|
export declare function getTrueTimeOffset(): number;
|
|
3
14
|
export declare function waitForFirstTimeSync(): Promise<void> | undefined;
|
package/time/trueTimeShim.ts
CHANGED
|
@@ -1,225 +1,528 @@
|
|
|
1
|
-
import { SocketFunction } from "../SocketFunction";
|
|
2
|
-
import { blue, green, red, yellow } from "../src/formatting/logColors";
|
|
3
|
-
import { isNode } from "../src/misc";
|
|
4
|
-
|
|
5
|
-
// IMPOTRANT! We don't ensure that the times of return are unique. We cannot ensure they are unique because the amount of precision is only about ten thousand date times per millisecond, Which would mean if the calling code called date.now frequently enough, which doesn't even have to be that frequent, it could slowly drift farther and farther ahead of the real time, which would be really bad.
|
|
6
|
-
|
|
7
|
-
module.allowclient = true;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
let
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
1
|
+
import { SocketFunction } from "../SocketFunction";
|
|
2
|
+
import { blue, green, red, yellow } from "../src/formatting/logColors";
|
|
3
|
+
import { isNode } from "../src/misc";
|
|
4
|
+
|
|
5
|
+
// IMPOTRANT! We don't ensure that the times of return are unique. We cannot ensure they are unique because the amount of precision is only about ten thousand date times per millisecond, Which would mean if the calling code called date.now frequently enough, which doesn't even have to be that frequent, it could slowly drift farther and farther ahead of the real time, which would be really bad.
|
|
6
|
+
|
|
7
|
+
module.allowclient = true;
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const UPDATE_VERIFY_COUNT = 3;
|
|
11
|
+
|
|
12
|
+
// Configuration for cross-process synchronization
|
|
13
|
+
const UPDATE_TRANSITION_GAP = 1000 * 60 * 20; // 5 minutes between current and next
|
|
14
|
+
const UPDATE_CHECK_INTERVAL = 1000 * 60 * 5; // Check every 1 minute
|
|
15
|
+
const DEBUG_TIME_SYNC = false; // Enable debug logging for time synchronization
|
|
16
|
+
|
|
17
|
+
// Time can never go backwards, but we can run at a slower rate until the output time allows
|
|
18
|
+
// the real time to catch up with it.
|
|
19
|
+
const MINIMUM_TIME_RATE = 0.5;
|
|
20
|
+
|
|
21
|
+
const THROW_ON_ERROR = false;
|
|
22
|
+
|
|
23
|
+
// Hugely important as if we don't synchronize between processes, it means our logs are going to be confusing and out of order.
|
|
24
|
+
// - Of course, cross-machine, the logs could be out of order. However, due to the latency between machines, that's less likely. The latency will probably be a few milliseconds, and hopefully, our time isn't more than a few milliseconds off of the real time. However, between processes, the latency could easily be microseconds, and our time will absolutely certainly be microseconds off of the real time.
|
|
25
|
+
let USE_LMDB_PROCESS_SYNC = true;
|
|
26
|
+
|
|
27
|
+
function debugLog(...args: any[]) {
|
|
28
|
+
if (DEBUG_TIME_SYNC) {
|
|
29
|
+
console.log("[TimeSync]", ...args);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type TimeOffsetData = {
|
|
34
|
+
lastOffset: number;
|
|
35
|
+
lastUpdateTime: number;
|
|
36
|
+
offset: number;
|
|
37
|
+
updateTime: number;
|
|
38
|
+
nextOffset: number;
|
|
39
|
+
nextUpdateTime: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let cachedTimeOffsetData: TimeOffsetData | undefined = undefined;
|
|
43
|
+
let didFirstTimeSync = false;
|
|
44
|
+
let onFirstTimeSync!: () => void;
|
|
45
|
+
let firstTimeSyncPromise = new Promise<void>((resolve) => {
|
|
46
|
+
onFirstTimeSync = resolve;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const baseGetTime = Date.now;
|
|
50
|
+
let lastTime = 0;
|
|
51
|
+
let lastBaseTime = 0;
|
|
52
|
+
|
|
53
|
+
export function getTimeComponentsDetailed(): {
|
|
54
|
+
systemTime: number;
|
|
55
|
+
fromOffset: number;
|
|
56
|
+
toOffset: number;
|
|
57
|
+
fromTime: number;
|
|
58
|
+
toTime: number;
|
|
59
|
+
} {
|
|
60
|
+
const systemTime = baseGetTime();
|
|
61
|
+
const data = cachedTimeOffsetData;
|
|
62
|
+
|
|
63
|
+
if (!data) {
|
|
64
|
+
return {
|
|
65
|
+
systemTime,
|
|
66
|
+
fromOffset: 0,
|
|
67
|
+
toOffset: 0,
|
|
68
|
+
fromTime: systemTime,
|
|
69
|
+
toTime: systemTime,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (systemTime < data.lastUpdateTime) {
|
|
74
|
+
// Before everything (shouldn't happen)
|
|
75
|
+
if (THROW_ON_ERROR) throw new Error(`systemTime ${systemTime} is before lastUpdateTime ${data.lastUpdateTime}`);
|
|
76
|
+
return {
|
|
77
|
+
systemTime,
|
|
78
|
+
fromOffset: data.lastOffset,
|
|
79
|
+
toOffset: data.lastOffset,
|
|
80
|
+
fromTime: data.lastUpdateTime,
|
|
81
|
+
toTime: data.lastUpdateTime,
|
|
82
|
+
};
|
|
83
|
+
} else if (systemTime < data.updateTime) {
|
|
84
|
+
// Smear between lastOffset and offset
|
|
85
|
+
return {
|
|
86
|
+
systemTime,
|
|
87
|
+
fromOffset: data.lastOffset,
|
|
88
|
+
toOffset: data.offset,
|
|
89
|
+
fromTime: data.lastUpdateTime,
|
|
90
|
+
toTime: data.updateTime,
|
|
91
|
+
};
|
|
92
|
+
} else if (systemTime < data.nextUpdateTime) {
|
|
93
|
+
// Smear between offset and nextOffset
|
|
94
|
+
return {
|
|
95
|
+
systemTime,
|
|
96
|
+
fromOffset: data.offset,
|
|
97
|
+
toOffset: data.nextOffset,
|
|
98
|
+
fromTime: data.updateTime,
|
|
99
|
+
toTime: data.nextUpdateTime,
|
|
100
|
+
};
|
|
101
|
+
} else {
|
|
102
|
+
// Past everything (shouldn't happen often)
|
|
103
|
+
if (THROW_ON_ERROR) throw new Error(`systemTime ${systemTime} is past nextUpdateTime ${data.nextUpdateTime}`);
|
|
104
|
+
return {
|
|
105
|
+
systemTime,
|
|
106
|
+
fromOffset: data.nextOffset,
|
|
107
|
+
toOffset: data.nextOffset,
|
|
108
|
+
fromTime: data.nextUpdateTime,
|
|
109
|
+
toTime: data.nextUpdateTime,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getTimeComponents(): { systemTime: number; offset: number } {
|
|
115
|
+
const detailed = getTimeComponentsDetailed();
|
|
116
|
+
const elapsed = detailed.systemTime - detailed.fromTime;
|
|
117
|
+
const duration = detailed.toTime - detailed.fromTime;
|
|
118
|
+
const fraction = duration > 0 ? Math.min(1, elapsed / duration) : 0;
|
|
119
|
+
const offset = detailed.fromOffset + (detailed.toOffset - detailed.fromOffset) * fraction;
|
|
120
|
+
return { systemTime: detailed.systemTime, offset };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getTrueTime() {
|
|
124
|
+
const { systemTime, offset } = getTimeComponents();
|
|
125
|
+
let time = systemTime + offset;
|
|
126
|
+
|
|
127
|
+
// Only adjust time once we have a time offset. Otherwise systems with a really bad clock
|
|
128
|
+
// might take days be correct. It is better for the time to jump once at startup, rather
|
|
129
|
+
// than be off by days, for days at a time.
|
|
130
|
+
if (lastTime && offset) {
|
|
131
|
+
if (time < lastTime) {
|
|
132
|
+
let diff = systemTime - lastBaseTime;
|
|
133
|
+
if (diff >= 0) {
|
|
134
|
+
// Some time passed, so we have a baseline for how much to increase the time by.
|
|
135
|
+
// This allows the real time to catch up with our time naturally.
|
|
136
|
+
time = lastTime + diff * MINIMUM_TIME_RATE;
|
|
137
|
+
} else {
|
|
138
|
+
// The issue is the system time going backwards. In this case, allow the time to change
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
lastTime = time;
|
|
143
|
+
lastBaseTime = systemTime;
|
|
144
|
+
return time;
|
|
145
|
+
}
|
|
146
|
+
export function getTrueTimeOffset() {
|
|
147
|
+
const { offset } = getTimeComponents();
|
|
148
|
+
return offset;
|
|
149
|
+
}
|
|
150
|
+
export function waitForFirstTimeSync(): Promise<void> | undefined {
|
|
151
|
+
if (didFirstTimeSync) return undefined;
|
|
152
|
+
return firstTimeSyncPromise;
|
|
153
|
+
}
|
|
154
|
+
let shimmed = false;
|
|
155
|
+
export function shimDateNow() {
|
|
156
|
+
if (shimmed) return;
|
|
157
|
+
shimmed = true;
|
|
158
|
+
Date.now = getTrueTime;
|
|
159
|
+
}
|
|
160
|
+
export function getBrowserTime() {
|
|
161
|
+
return baseGetTime();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function setGetTimeOffsetBase(base: () => Promise<number>) {
|
|
165
|
+
getTimeOffsetBase = base;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function defaultGetTimeOffset(): Promise<number> {
|
|
169
|
+
if (!isNode()) {
|
|
170
|
+
let sendTime = baseGetTime();
|
|
171
|
+
let serverTrueTime = await TimeController.nodes[SocketFunction.browserNodeId()].getTrueTime();
|
|
172
|
+
let systemTime = baseGetTime();
|
|
173
|
+
let predictedServerToClientLatency = (systemTime - sendTime) / 2;
|
|
174
|
+
let trueTimeRightNow = serverTrueTime + predictedServerToClientLatency;
|
|
175
|
+
return trueTimeRightNow - systemTime;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const dgram = await import("dgram");
|
|
179
|
+
const NTP_SERVER = "time.google.com";
|
|
180
|
+
const NTP_PORT = 123;
|
|
181
|
+
const NTP_PACKET_SIZE = 48;
|
|
182
|
+
const NTP_EPOCH_OFFSET = 2208988800000; // Number of milliseconds between 1900-01-01 and 1970-01-01
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const client = dgram.createSocket("udp4");
|
|
185
|
+
const message = Buffer.alloc(NTP_PACKET_SIZE);
|
|
186
|
+
|
|
187
|
+
// Set the first byte to represent NTP client request (LI = 0, VN = 3, Mode = 3)
|
|
188
|
+
message[0] = 0x1B;
|
|
189
|
+
|
|
190
|
+
const sendTime = baseGetTime();
|
|
191
|
+
|
|
192
|
+
client.send(message, 0, message.length, NTP_PORT, NTP_SERVER);
|
|
193
|
+
client.on("error", (err) => {
|
|
194
|
+
client.close();
|
|
195
|
+
reject(err);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
client.on("message", (msg) => {
|
|
199
|
+
const receiveTime = baseGetTime();
|
|
200
|
+
|
|
201
|
+
// Extract the transmit timestamp from the server response
|
|
202
|
+
const transmitTimestampSeconds = msg.readUInt32BE(40);
|
|
203
|
+
const transmitTimestampFraction = msg.readUInt32BE(44);
|
|
204
|
+
const transmitTimestamp = (transmitTimestampSeconds * 1000) + (transmitTimestampFraction * 1000 / 0x100000000) - NTP_EPOCH_OFFSET;
|
|
205
|
+
|
|
206
|
+
const predictedServerToClientLatency = (receiveTime - sendTime) / 2;
|
|
207
|
+
|
|
208
|
+
// Calculate the offset
|
|
209
|
+
const systemTime = baseGetTime();
|
|
210
|
+
const actualTime = transmitTimestamp + predictedServerToClientLatency;
|
|
211
|
+
const offset = actualTime - systemTime;
|
|
212
|
+
|
|
213
|
+
client.close();
|
|
214
|
+
resolve(offset);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let timeOffsetDb: import("lmdb").RootDatabase<TimeOffsetData, string> | undefined = undefined;
|
|
220
|
+
async function getTimeOffsetDb() {
|
|
221
|
+
if (!USE_LMDB_PROCESS_SYNC) return undefined;
|
|
222
|
+
if (timeOffsetDb) return timeOffsetDb;
|
|
223
|
+
if (!isNode()) return undefined;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const lmdb = await import("lmdb");
|
|
227
|
+
const path = await import("path");
|
|
228
|
+
const os = await import("os");
|
|
229
|
+
|
|
230
|
+
const dbPath = path.join(os.tmpdir(), "socket-function-time-offset-2");
|
|
231
|
+
timeOffsetDb = lmdb.open<TimeOffsetData, string>({
|
|
232
|
+
path: dbPath,
|
|
233
|
+
// Enable versioning for conditional writes
|
|
234
|
+
useVersions: true,
|
|
235
|
+
});
|
|
236
|
+
return timeOffsetDb;
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.error("Error opening LMDB database:", e);
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function getTimeOffsetFromLmdb(): Promise<{
|
|
244
|
+
data: TimeOffsetData;
|
|
245
|
+
version: number;
|
|
246
|
+
} | undefined> {
|
|
247
|
+
if (!isNode() || !USE_LMDB_PROCESS_SYNC) {
|
|
248
|
+
// Skip LMDB for browsers or if disabled
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const db = await getTimeOffsetDb();
|
|
254
|
+
if (!db) return undefined;
|
|
255
|
+
|
|
256
|
+
const entry = await db.getEntry("timeOffset"); // Gets {value, version} atomically
|
|
257
|
+
if (!entry) return undefined;
|
|
258
|
+
|
|
259
|
+
const data = entry.value;
|
|
260
|
+
const version = entry.version;
|
|
261
|
+
|
|
262
|
+
if (data &&
|
|
263
|
+
typeof version === "number" &&
|
|
264
|
+
typeof data.lastOffset === "number" &&
|
|
265
|
+
typeof data.lastUpdateTime === "number" &&
|
|
266
|
+
typeof data.offset === "number" &&
|
|
267
|
+
typeof data.updateTime === "number" &&
|
|
268
|
+
typeof data.nextOffset === "number" &&
|
|
269
|
+
typeof data.nextUpdateTime === "number") {
|
|
270
|
+
return { data, version };
|
|
271
|
+
}
|
|
272
|
+
return undefined;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
console.error("Error reading from LMDB database:", e);
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function setTimeOffsetInLmdb(
|
|
280
|
+
data: TimeOffsetData,
|
|
281
|
+
expectedVersion: number
|
|
282
|
+
): Promise<boolean> {
|
|
283
|
+
try {
|
|
284
|
+
const db = await getTimeOffsetDb();
|
|
285
|
+
if (!db) return false;
|
|
286
|
+
|
|
287
|
+
// Atomic conditional write - only succeeds if version matches expectedVersion
|
|
288
|
+
// Use random version to minimize collision probability on retries
|
|
289
|
+
const newVersion = Math.random();
|
|
290
|
+
|
|
291
|
+
// Conditional write with version check
|
|
292
|
+
const success = await db.ifVersion("timeOffset", expectedVersion, () => {
|
|
293
|
+
return db.put("timeOffset", data, newVersion);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return success !== undefined;
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.error("Error writing to LMDB database:", e);
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let getTimeOffsetBase: () => Promise<number> = defaultGetTimeOffset;
|
|
304
|
+
|
|
305
|
+
async function fetchNewOffset(): Promise<number> {
|
|
306
|
+
let offsets: number[] = [];
|
|
307
|
+
for (let i = 0; i < UPDATE_VERIFY_COUNT; i++) {
|
|
308
|
+
try {
|
|
309
|
+
offsets.push(await getTimeOffsetBase());
|
|
310
|
+
} catch (e) {
|
|
311
|
+
console.error("Error getting time offset:", e);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (offsets.length === 0) {
|
|
316
|
+
// All calls failed, return 0 as fallback
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Pick the middle offset
|
|
321
|
+
offsets.sort((a, b) => a - b);
|
|
322
|
+
let offset = offsets[Math.floor(offsets.length / 2)];
|
|
323
|
+
|
|
324
|
+
// Log if offset is significant
|
|
325
|
+
let offsetRound = Math.abs(Math.round(offset));
|
|
326
|
+
let offsetColored = (
|
|
327
|
+
Math.abs(offset) > 600 && red(offsetRound + "ms")
|
|
328
|
+
|| Math.abs(offset) > 300 && yellow(offsetRound + "ms")
|
|
329
|
+
|| green(offsetRound + "ms")
|
|
330
|
+
);
|
|
331
|
+
if (Math.abs(offset) > 500) {
|
|
332
|
+
console.log(`${blue("Synchronized time")}, local clock was ${offset > 0 ? "behind" : "ahead"} by ${offsetColored} @ ${blue(Date.now() + "")}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return offset;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let updatingOffset = false;
|
|
339
|
+
async function updateTimeOffset() {
|
|
340
|
+
if (updatingOffset) return;
|
|
341
|
+
updatingOffset = true;
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const db = await getTimeOffsetDb();
|
|
345
|
+
if (!db) {
|
|
346
|
+
// IMPORTANT: Always use baseGetTime() for scheduling, never getTrueTime().
|
|
347
|
+
// Our update schedule must be based on the stable system clock, not the
|
|
348
|
+
// offset-adjusted time which changes as we synchronize.
|
|
349
|
+
const currentTime = baseGetTime();
|
|
350
|
+
let cachedData = cachedTimeOffsetData;
|
|
351
|
+
if (cachedData && currentTime >= cachedData.nextUpdateTime) {
|
|
352
|
+
// Past the end - reset and reinitialize
|
|
353
|
+
cachedData = undefined;
|
|
354
|
+
debugLog("Past nextUpdateTime, resetting");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!cachedData) {
|
|
358
|
+
// First time initialization
|
|
359
|
+
const offset = await fetchNewOffset();
|
|
360
|
+
cachedTimeOffsetData = {
|
|
361
|
+
lastOffset: offset,
|
|
362
|
+
lastUpdateTime: currentTime,
|
|
363
|
+
offset: offset,
|
|
364
|
+
updateTime: currentTime + UPDATE_TRANSITION_GAP,
|
|
365
|
+
nextOffset: offset,
|
|
366
|
+
nextUpdateTime: currentTime + UPDATE_TRANSITION_GAP * 2,
|
|
367
|
+
};
|
|
368
|
+
debugLog("Initialized - time offset:", offset, "ms, next update in", UPDATE_TRANSITION_GAP, "ms");
|
|
369
|
+
} else if (currentTime >= cachedData.updateTime) {
|
|
370
|
+
// Time to rotate
|
|
371
|
+
const newOffset = await fetchNewOffset();
|
|
372
|
+
cachedTimeOffsetData = {
|
|
373
|
+
lastOffset: cachedData.offset,
|
|
374
|
+
lastUpdateTime: cachedData.updateTime,
|
|
375
|
+
offset: cachedData.nextOffset,
|
|
376
|
+
updateTime: cachedData.nextUpdateTime,
|
|
377
|
+
nextOffset: newOffset,
|
|
378
|
+
nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
|
|
379
|
+
};
|
|
380
|
+
const timeUntilNext = cachedTimeOffsetData.nextUpdateTime - baseGetTime();
|
|
381
|
+
debugLog("Advancing time offset - current:", cachedTimeOffsetData.offset, "ms, next:", cachedTimeOffsetData.nextOffset, "ms, next update in", timeUntilNext, "ms");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!didFirstTimeSync) {
|
|
385
|
+
didFirstTimeSync = true;
|
|
386
|
+
onFirstTimeSync();
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// At this point: Node.js, LMDB enabled and working
|
|
392
|
+
// Main LMDB path with atomic synchronization
|
|
393
|
+
while (true) {
|
|
394
|
+
const entry = await getTimeOffsetFromLmdb();
|
|
395
|
+
// IMPORTANT: Always use baseGetTime() for scheduling, never getTrueTime().
|
|
396
|
+
// Our update schedule must be based on the stable system clock, not the
|
|
397
|
+
// offset-adjusted time which changes as we synchronize.
|
|
398
|
+
const currentTime = baseGetTime();
|
|
399
|
+
|
|
400
|
+
let cachedData = entry?.data;
|
|
401
|
+
const readVersion = entry?.version;
|
|
402
|
+
|
|
403
|
+
if (cachedData && currentTime >= cachedData.nextUpdateTime) {
|
|
404
|
+
// Past the end - reset and reinitialize
|
|
405
|
+
cachedData = undefined;
|
|
406
|
+
debugLog("Past nextUpdateTime, resetting");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!cachedData || !readVersion) {
|
|
410
|
+
// First time initialization - use conditional write to handle race
|
|
411
|
+
const offset = await fetchNewOffset();
|
|
412
|
+
const initData: TimeOffsetData = {
|
|
413
|
+
lastOffset: offset,
|
|
414
|
+
lastUpdateTime: currentTime,
|
|
415
|
+
offset: offset,
|
|
416
|
+
updateTime: currentTime + UPDATE_TRANSITION_GAP,
|
|
417
|
+
nextOffset: offset,
|
|
418
|
+
nextUpdateTime: currentTime + UPDATE_TRANSITION_GAP * 2,
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const newVersion = Math.random();
|
|
422
|
+
|
|
423
|
+
if (readVersion) {
|
|
424
|
+
const success = await setTimeOffsetInLmdb(initData, readVersion);
|
|
425
|
+
if (!success) {
|
|
426
|
+
debugLog("Lost the race, retrying");
|
|
427
|
+
// Lost the race, retry
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
console.log("Successfully wrote atomic reset");
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Try to write our data
|
|
435
|
+
await db.put("timeOffset", initData, newVersion);
|
|
436
|
+
|
|
437
|
+
// Read back to see what actually got written
|
|
438
|
+
const actualEntry = await db.getEntry("timeOffset");
|
|
439
|
+
if (actualEntry?.version !== newVersion) {
|
|
440
|
+
// Lost the race, another process wrote after us
|
|
441
|
+
// Retry from the top to read their data
|
|
442
|
+
debugLog("Value was changed by another process, retrying");
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// We won the race, use our data
|
|
447
|
+
cachedTimeOffsetData = initData;
|
|
448
|
+
debugLog("Initialized - time offset:", offset, "ms, next update in", UPDATE_TRANSITION_GAP, "ms");
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (currentTime >= cachedData.updateTime) {
|
|
453
|
+
// Time to rotate
|
|
454
|
+
const newOffset = await fetchNewOffset();
|
|
455
|
+
const newData: TimeOffsetData = {
|
|
456
|
+
lastOffset: cachedData.offset,
|
|
457
|
+
lastUpdateTime: cachedData.updateTime,
|
|
458
|
+
offset: cachedData.nextOffset,
|
|
459
|
+
updateTime: cachedData.nextUpdateTime,
|
|
460
|
+
nextOffset: newOffset,
|
|
461
|
+
nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const success = await setTimeOffsetInLmdb(newData, readVersion);
|
|
465
|
+
if (!success) {
|
|
466
|
+
// Lost the race, retry
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const timeUntilNext = newData.nextUpdateTime - baseGetTime();
|
|
471
|
+
debugLog("Advancing time offset - current:", newData.offset, "ms, next:", newData.nextOffset, "ms, next update in", timeUntilNext, "ms");
|
|
472
|
+
cachedTimeOffsetData = newData;
|
|
473
|
+
break;
|
|
474
|
+
} else {
|
|
475
|
+
cachedTimeOffsetData = cachedData;
|
|
476
|
+
const timeUntilNext = cachedData.updateTime - baseGetTime();
|
|
477
|
+
debugLog("Loaded from LMDB - current:", cachedData.offset, "ms, next:", cachedData.nextOffset, "ms, next update in", timeUntilNext, "ms");
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (!didFirstTimeSync) {
|
|
483
|
+
didFirstTimeSync = true;
|
|
484
|
+
onFirstTimeSync();
|
|
485
|
+
}
|
|
486
|
+
} finally {
|
|
487
|
+
updatingOffset = false;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
setInterval(() => {
|
|
492
|
+
updateTimeOffset().catch((e) => {
|
|
493
|
+
console.warn("Error updating time offset:", e);
|
|
494
|
+
});
|
|
495
|
+
}, UPDATE_CHECK_INTERVAL);
|
|
496
|
+
setImmediate(() => {
|
|
497
|
+
updateTimeOffset().catch((e) => {
|
|
498
|
+
console.error("Error updating initial offset:", e);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class TimeControllerBase {
|
|
504
|
+
public async getTrueTime() {
|
|
505
|
+
await waitForFirstTimeSync();
|
|
506
|
+
return getTrueTime();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const TimeController = SocketFunction.register(
|
|
511
|
+
"TimeController-ddf4753e-fc8a-413f-8cc2-b927dd449976",
|
|
512
|
+
new TimeControllerBase(),
|
|
513
|
+
() => ({
|
|
514
|
+
getTrueTime: {
|
|
515
|
+
// No hooks, as this needs to run very early on. Also, it is basically just a ping,
|
|
516
|
+
// so it should be safe for anyone to use (we might even make it just a regular HTTPS endpoint,
|
|
517
|
+
// or even just set up a dedicated domain for this).
|
|
518
|
+
noDefaultHooks: true,
|
|
519
|
+
noClientHooks: true,
|
|
520
|
+
},
|
|
521
|
+
}),
|
|
522
|
+
() => ({}),
|
|
523
|
+
{
|
|
524
|
+
// NOTE: Autoexpose, because our exposed endpoints are incredibly lightweight
|
|
525
|
+
// (just a ping), and don't expose really expose any data.
|
|
526
|
+
// noAutoExpose: true
|
|
527
|
+
}
|
|
225
528
|
);
|