socket-function 1.1.47 → 1.1.48
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/index.d.ts +12 -1
- package/package.json +1 -1
- package/time/trueTimeShim.d.ts +12 -1
- package/time/trueTimeShim.ts +304 -139
package/index.d.ts
CHANGED
|
@@ -1543,6 +1543,16 @@ declare module "socket-function/test" {
|
|
|
1543
1543
|
}
|
|
1544
1544
|
|
|
1545
1545
|
declare module "socket-function/time/trueTimeShim" {
|
|
1546
|
+
export type TimeOffsetProof = {
|
|
1547
|
+
sendTime: number;
|
|
1548
|
+
receiveTime: number;
|
|
1549
|
+
serverTime: number;
|
|
1550
|
+
offset: number;
|
|
1551
|
+
};
|
|
1552
|
+
export type TimeOffsetMeasurement = {
|
|
1553
|
+
offset: number;
|
|
1554
|
+
proof?: TimeOffsetProof;
|
|
1555
|
+
};
|
|
1546
1556
|
export declare function getTimeComponentsDetailed(): {
|
|
1547
1557
|
systemTime: number;
|
|
1548
1558
|
fromOffset: number;
|
|
@@ -1556,12 +1566,13 @@ declare module "socket-function/time/trueTimeShim" {
|
|
|
1556
1566
|
};
|
|
1557
1567
|
export declare function getTrueTime(): number;
|
|
1558
1568
|
export declare function getTrueTimeOffset(): number;
|
|
1569
|
+
export declare function getTrueTimeProof(): TimeOffsetProof | undefined;
|
|
1559
1570
|
export declare function waitForFirstTimeSync(): Promise<void> | undefined;
|
|
1560
1571
|
declare global {
|
|
1561
1572
|
var TRUE_TIME_ALREADY_SHIMMED: boolean;
|
|
1562
1573
|
}
|
|
1563
1574
|
export declare function shimDateNow(): void;
|
|
1564
1575
|
export declare function getBrowserTime(): number;
|
|
1565
|
-
export declare function setGetTimeOffsetBase(base: () => Promise<number>): void;
|
|
1576
|
+
export declare function setGetTimeOffsetBase(base: () => Promise<number | TimeOffsetMeasurement>): void;
|
|
1566
1577
|
|
|
1567
1578
|
}
|
package/package.json
CHANGED
package/time/trueTimeShim.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
export type TimeOffsetProof = {
|
|
2
|
+
sendTime: number;
|
|
3
|
+
receiveTime: number;
|
|
4
|
+
serverTime: number;
|
|
5
|
+
offset: number;
|
|
6
|
+
};
|
|
7
|
+
export type TimeOffsetMeasurement = {
|
|
8
|
+
offset: number;
|
|
9
|
+
proof?: TimeOffsetProof;
|
|
10
|
+
};
|
|
1
11
|
export declare function getTimeComponentsDetailed(): {
|
|
2
12
|
systemTime: number;
|
|
3
13
|
fromOffset: number;
|
|
@@ -11,10 +21,11 @@ export declare function getTimeComponents(): {
|
|
|
11
21
|
};
|
|
12
22
|
export declare function getTrueTime(): number;
|
|
13
23
|
export declare function getTrueTimeOffset(): number;
|
|
24
|
+
export declare function getTrueTimeProof(): TimeOffsetProof | undefined;
|
|
14
25
|
export declare function waitForFirstTimeSync(): Promise<void> | undefined;
|
|
15
26
|
declare global {
|
|
16
27
|
var TRUE_TIME_ALREADY_SHIMMED: boolean;
|
|
17
28
|
}
|
|
18
29
|
export declare function shimDateNow(): void;
|
|
19
30
|
export declare function getBrowserTime(): number;
|
|
20
|
-
export declare function setGetTimeOffsetBase(base: () => Promise<number>): void;
|
|
31
|
+
export declare function setGetTimeOffsetBase(base: () => Promise<number | TimeOffsetMeasurement>): void;
|
package/time/trueTimeShim.ts
CHANGED
|
@@ -2,17 +2,21 @@ import { SocketFunction } from "../SocketFunction";
|
|
|
2
2
|
import { blue, green, red, yellow } from "../src/formatting/logColors";
|
|
3
3
|
import { isNode } from "../src/misc";
|
|
4
4
|
|
|
5
|
-
//
|
|
5
|
+
// IMPORTANT! 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
6
|
|
|
7
7
|
module.allowclient = true;
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
const UPDATE_VERIFY_COUNT = 3;
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
12
|
+
const UPDATE_TRANSITION_GAP = 1000 * 60 * 20;
|
|
13
|
+
const UPDATE_CHECK_INTERVAL = 1000 * 60 * 5;
|
|
14
|
+
const DEBUG_TIME_SYNC = false;
|
|
15
|
+
|
|
16
|
+
const FETCH_RETRY_INITIAL_DELAY = 1000;
|
|
17
|
+
const FETCH_RETRY_MAX_DELAY = 1000 * 30;
|
|
18
|
+
const FETCH_MAX_FAILURES = 8;
|
|
19
|
+
const NTP_TIMEOUT = 1000 * 10;
|
|
16
20
|
|
|
17
21
|
// Time can never go backwards, but we can run at a slower rate until the output time allows
|
|
18
22
|
// the real time to catch up with it.
|
|
@@ -20,16 +24,32 @@ const MINIMUM_TIME_RATE = 0.5;
|
|
|
20
24
|
|
|
21
25
|
const THROW_ON_ERROR = false;
|
|
22
26
|
|
|
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.
|
|
27
|
+
// Hugely important as if we don't synchronize between processes, it means our logs are going to be confusing and out of order.
|
|
28
|
+
// - 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
29
|
let USE_LMDB_PROCESS_SYNC = true;
|
|
26
30
|
|
|
31
|
+
// Browser tabs synchronize via localStorage, for the same reason processes synchronize via LMDB.
|
|
32
|
+
const TIME_OFFSET_LOCAL_STORAGE_KEY = "socket-function-time-offset";
|
|
33
|
+
|
|
27
34
|
function debugLog(...args: any[]) {
|
|
28
35
|
if (DEBUG_TIME_SYNC) {
|
|
29
36
|
console.log("[TimeSync]", ...args);
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
// The raw evidence behind an offset measurement. sendTime/receiveTime are on the local system clock, serverTime is the remote clock's timestamp, and offset is derived from them by assuming the server timestamped at the midpoint of the round trip.
|
|
41
|
+
export type TimeOffsetProof = {
|
|
42
|
+
sendTime: number;
|
|
43
|
+
receiveTime: number;
|
|
44
|
+
serverTime: number;
|
|
45
|
+
offset: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type TimeOffsetMeasurement = {
|
|
49
|
+
offset: number;
|
|
50
|
+
proof?: TimeOffsetProof;
|
|
51
|
+
};
|
|
52
|
+
|
|
33
53
|
type TimeOffsetData = {
|
|
34
54
|
lastOffset: number;
|
|
35
55
|
lastUpdateTime: number;
|
|
@@ -37,6 +57,8 @@ type TimeOffsetData = {
|
|
|
37
57
|
updateTime: number;
|
|
38
58
|
nextOffset: number;
|
|
39
59
|
nextUpdateTime: number;
|
|
60
|
+
// Proof of the most recent measurement (the one that produced nextOffset).
|
|
61
|
+
proof?: TimeOffsetProof;
|
|
40
62
|
};
|
|
41
63
|
|
|
42
64
|
let cachedTimeOffsetData: TimeOffsetData | undefined = undefined;
|
|
@@ -45,6 +67,11 @@ let onFirstTimeSync!: () => void;
|
|
|
45
67
|
let firstTimeSyncPromise = new Promise<void>((resolve) => {
|
|
46
68
|
onFirstTimeSync = resolve;
|
|
47
69
|
});
|
|
70
|
+
function markFirstTimeSync() {
|
|
71
|
+
if (didFirstTimeSync) return;
|
|
72
|
+
didFirstTimeSync = true;
|
|
73
|
+
onFirstTimeSync();
|
|
74
|
+
}
|
|
48
75
|
|
|
49
76
|
const baseGetTime = Date.now;
|
|
50
77
|
let lastTime = 0;
|
|
@@ -147,6 +174,9 @@ export function getTrueTimeOffset() {
|
|
|
147
174
|
const { offset } = getTimeComponents();
|
|
148
175
|
return offset;
|
|
149
176
|
}
|
|
177
|
+
export function getTrueTimeProof(): TimeOffsetProof | undefined {
|
|
178
|
+
return cachedTimeOffsetData?.proof;
|
|
179
|
+
}
|
|
150
180
|
export function waitForFirstTimeSync(): Promise<void> | undefined {
|
|
151
181
|
if (didFirstTimeSync) return undefined;
|
|
152
182
|
return firstTimeSyncPromise;
|
|
@@ -166,18 +196,22 @@ export function getBrowserTime() {
|
|
|
166
196
|
return baseGetTime();
|
|
167
197
|
}
|
|
168
198
|
|
|
169
|
-
export function setGetTimeOffsetBase(base: () => Promise<number>) {
|
|
199
|
+
export function setGetTimeOffsetBase(base: () => Promise<number | TimeOffsetMeasurement>) {
|
|
170
200
|
getTimeOffsetBase = base;
|
|
171
201
|
}
|
|
172
202
|
|
|
173
|
-
|
|
203
|
+
function makeMeasurement(sendTime: number, receiveTime: number, serverTime: number): TimeOffsetMeasurement {
|
|
204
|
+
const predictedServerToClientLatency = (receiveTime - sendTime) / 2;
|
|
205
|
+
const offset = serverTime + predictedServerToClientLatency - receiveTime;
|
|
206
|
+
return { offset, proof: { sendTime, receiveTime, serverTime, offset } };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function defaultGetTimeOffset(): Promise<TimeOffsetMeasurement> {
|
|
174
210
|
if (!isNode()) {
|
|
175
211
|
let sendTime = baseGetTime();
|
|
176
212
|
let serverTrueTime = await TimeController.nodes[SocketFunction.browserNodeId()].getTrueTime();
|
|
177
|
-
let
|
|
178
|
-
|
|
179
|
-
let trueTimeRightNow = serverTrueTime + predictedServerToClientLatency;
|
|
180
|
-
return trueTimeRightNow - systemTime;
|
|
213
|
+
let receiveTime = baseGetTime();
|
|
214
|
+
return makeMeasurement(sendTime, receiveTime, serverTrueTime);
|
|
181
215
|
}
|
|
182
216
|
|
|
183
217
|
const dgram = await import("dgram");
|
|
@@ -194,33 +228,61 @@ async function defaultGetTimeOffset(): Promise<number> {
|
|
|
194
228
|
|
|
195
229
|
const sendTime = baseGetTime();
|
|
196
230
|
|
|
231
|
+
// NTP is UDP, so a lost packet means the "message" event simply never fires. Without a timeout that leaves updateTimeOffset stuck (updatingOffset stays true forever), permanently stopping all future syncs.
|
|
232
|
+
const timeout = setTimeout(() => {
|
|
233
|
+
client.close();
|
|
234
|
+
reject(new Error(`NTP request to ${NTP_SERVER} timed out`));
|
|
235
|
+
}, NTP_TIMEOUT);
|
|
236
|
+
|
|
197
237
|
client.send(message, 0, message.length, NTP_PORT, NTP_SERVER);
|
|
198
238
|
client.on("error", (err) => {
|
|
239
|
+
clearTimeout(timeout);
|
|
199
240
|
client.close();
|
|
200
241
|
reject(err);
|
|
201
242
|
});
|
|
202
243
|
|
|
203
244
|
client.on("message", (msg) => {
|
|
204
245
|
const receiveTime = baseGetTime();
|
|
246
|
+
// A throw in this handler would be an uncaught exception, not a rejection, so validate before reading fixed offsets.
|
|
247
|
+
if (msg.length < NTP_PACKET_SIZE) return;
|
|
248
|
+
clearTimeout(timeout);
|
|
205
249
|
|
|
206
250
|
// Extract the transmit timestamp from the server response
|
|
207
251
|
const transmitTimestampSeconds = msg.readUInt32BE(40);
|
|
208
252
|
const transmitTimestampFraction = msg.readUInt32BE(44);
|
|
209
253
|
const transmitTimestamp = (transmitTimestampSeconds * 1000) + (transmitTimestampFraction * 1000 / 0x100000000) - NTP_EPOCH_OFFSET;
|
|
210
254
|
|
|
211
|
-
const predictedServerToClientLatency = (receiveTime - sendTime) / 2;
|
|
212
|
-
|
|
213
|
-
// Calculate the offset
|
|
214
|
-
const systemTime = baseGetTime();
|
|
215
|
-
const actualTime = transmitTimestamp + predictedServerToClientLatency;
|
|
216
|
-
const offset = actualTime - systemTime;
|
|
217
|
-
|
|
218
255
|
client.close();
|
|
219
|
-
resolve(
|
|
256
|
+
resolve(makeMeasurement(sendTime, receiveTime, transmitTimestamp));
|
|
220
257
|
});
|
|
221
258
|
});
|
|
222
259
|
}
|
|
223
260
|
|
|
261
|
+
function isValidTimeOffsetData(data: unknown): data is TimeOffsetData {
|
|
262
|
+
const d = data as TimeOffsetData | undefined;
|
|
263
|
+
return (
|
|
264
|
+
!!d &&
|
|
265
|
+
typeof d.lastOffset === "number" &&
|
|
266
|
+
typeof d.lastUpdateTime === "number" &&
|
|
267
|
+
typeof d.offset === "number" &&
|
|
268
|
+
typeof d.updateTime === "number" &&
|
|
269
|
+
typeof d.nextOffset === "number" &&
|
|
270
|
+
typeof d.nextUpdateTime === "number"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
type TimeOffsetStoreEntry = {
|
|
275
|
+
data: TimeOffsetData;
|
|
276
|
+
version: number;
|
|
277
|
+
};
|
|
278
|
+
type TimeOffsetStore = {
|
|
279
|
+
getEntry(): Promise<TimeOffsetStoreEntry | undefined>;
|
|
280
|
+
// Atomic conditional write, only succeeds if the stored version matches expectedVersion.
|
|
281
|
+
putIfVersion(data: TimeOffsetData, expectedVersion: number): Promise<boolean>;
|
|
282
|
+
// Unconditional write when no entry exists yet. Returns false if another writer raced us, in which case the caller should re-read and use their data.
|
|
283
|
+
putInitial(data: TimeOffsetData): Promise<boolean>;
|
|
284
|
+
};
|
|
285
|
+
|
|
224
286
|
let timeOffsetDb: import("lmdb").RootDatabase<TimeOffsetData, string> | undefined = undefined;
|
|
225
287
|
async function getTimeOffsetDb() {
|
|
226
288
|
if (!USE_LMDB_PROCESS_SYNC) return undefined;
|
|
@@ -245,86 +307,145 @@ async function getTimeOffsetDb() {
|
|
|
245
307
|
}
|
|
246
308
|
}
|
|
247
309
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
310
|
+
function getLmdbStore(db: import("lmdb").RootDatabase<TimeOffsetData, string>): TimeOffsetStore {
|
|
311
|
+
return {
|
|
312
|
+
async getEntry() {
|
|
313
|
+
try {
|
|
314
|
+
const entry = await db.getEntry("timeOffset"); // Gets {value, version} atomically
|
|
315
|
+
if (!entry) return undefined;
|
|
316
|
+
if (typeof entry.version !== "number" || !isValidTimeOffsetData(entry.value)) return undefined;
|
|
317
|
+
return { data: entry.value, version: entry.version };
|
|
318
|
+
} catch (e) {
|
|
319
|
+
console.error("Error reading from LMDB database:", e);
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
async putIfVersion(data, expectedVersion) {
|
|
324
|
+
try {
|
|
325
|
+
// Use random version to minimize collision probability on retries
|
|
326
|
+
const newVersion = Math.random();
|
|
327
|
+
const success = await db.ifVersion("timeOffset", expectedVersion, () => {
|
|
328
|
+
return db.put("timeOffset", data, newVersion);
|
|
329
|
+
});
|
|
330
|
+
return success !== undefined;
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error("Error writing to LMDB database:", e);
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
async putInitial(data) {
|
|
337
|
+
try {
|
|
338
|
+
const newVersion = Math.random();
|
|
339
|
+
await db.put("timeOffset", data, newVersion);
|
|
340
|
+
// Read back to see what actually got written
|
|
341
|
+
const actualEntry = await db.getEntry("timeOffset");
|
|
342
|
+
return actualEntry?.version === newVersion;
|
|
343
|
+
} catch (e) {
|
|
344
|
+
console.error("Error writing to LMDB database:", e);
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
256
350
|
|
|
351
|
+
function getLocalStorageStore(): TimeOffsetStore | undefined {
|
|
257
352
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const entry = await db.getEntry("timeOffset"); // Gets {value, version} atomically
|
|
262
|
-
if (!entry) return undefined;
|
|
263
|
-
|
|
264
|
-
const data = entry.value;
|
|
265
|
-
const version = entry.version;
|
|
266
|
-
|
|
267
|
-
if (data &&
|
|
268
|
-
typeof version === "number" &&
|
|
269
|
-
typeof data.lastOffset === "number" &&
|
|
270
|
-
typeof data.lastUpdateTime === "number" &&
|
|
271
|
-
typeof data.offset === "number" &&
|
|
272
|
-
typeof data.updateTime === "number" &&
|
|
273
|
-
typeof data.nextOffset === "number" &&
|
|
274
|
-
typeof data.nextUpdateTime === "number") {
|
|
275
|
-
return { data, version };
|
|
276
|
-
}
|
|
277
|
-
return undefined;
|
|
278
|
-
} catch (e) {
|
|
279
|
-
console.error("Error reading from LMDB database:", e);
|
|
353
|
+
if (typeof localStorage === "undefined") return undefined;
|
|
354
|
+
} catch {
|
|
280
355
|
return undefined;
|
|
281
356
|
}
|
|
357
|
+
type StoredValue = {
|
|
358
|
+
version: number;
|
|
359
|
+
data: TimeOffsetData;
|
|
360
|
+
};
|
|
361
|
+
function readStored(): StoredValue | undefined {
|
|
362
|
+
try {
|
|
363
|
+
const raw = localStorage.getItem(TIME_OFFSET_LOCAL_STORAGE_KEY);
|
|
364
|
+
if (!raw) return undefined;
|
|
365
|
+
const parsed = JSON.parse(raw) as StoredValue;
|
|
366
|
+
if (!parsed || typeof parsed.version !== "number" || !isValidTimeOffsetData(parsed.data)) return undefined;
|
|
367
|
+
return parsed;
|
|
368
|
+
} catch (e) {
|
|
369
|
+
console.error("Error reading time offset from localStorage:", (e as Error).stack ?? e);
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function writeStored(data: TimeOffsetData): number | undefined {
|
|
374
|
+
try {
|
|
375
|
+
const version = Math.random();
|
|
376
|
+
localStorage.setItem(TIME_OFFSET_LOCAL_STORAGE_KEY, JSON.stringify({ version, data }));
|
|
377
|
+
return version;
|
|
378
|
+
} catch (e) {
|
|
379
|
+
console.error("Error writing time offset to localStorage:", (e as Error).stack ?? e);
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
async getEntry() {
|
|
385
|
+
const stored = readStored();
|
|
386
|
+
if (!stored) return undefined;
|
|
387
|
+
return { data: stored.data, version: stored.version };
|
|
388
|
+
},
|
|
389
|
+
// localStorage has no atomic compare-and-swap, but access within a tab is synchronous, so re-reading the version immediately before writing shrinks the race window to effectively nothing. Worst case two tabs both fetch an offset, which is harmless.
|
|
390
|
+
async putIfVersion(data, expectedVersion) {
|
|
391
|
+
const stored = readStored();
|
|
392
|
+
if (stored?.version !== expectedVersion) return false;
|
|
393
|
+
return writeStored(data) !== undefined;
|
|
394
|
+
},
|
|
395
|
+
async putInitial(data) {
|
|
396
|
+
const version = writeStored(data);
|
|
397
|
+
if (version === undefined) return false;
|
|
398
|
+
return readStored()?.version === version;
|
|
399
|
+
},
|
|
400
|
+
};
|
|
282
401
|
}
|
|
283
402
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
288
|
-
|
|
403
|
+
let timeOffsetStore: TimeOffsetStore | undefined = undefined;
|
|
404
|
+
let timeOffsetStoreResolved = false;
|
|
405
|
+
async function getTimeOffsetStore(): Promise<TimeOffsetStore | undefined> {
|
|
406
|
+
if (timeOffsetStoreResolved) return timeOffsetStore;
|
|
407
|
+
if (isNode()) {
|
|
289
408
|
const db = await getTimeOffsetDb();
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// Conditional write with version check
|
|
297
|
-
const success = await db.ifVersion("timeOffset", expectedVersion, () => {
|
|
298
|
-
return db.put("timeOffset", data, newVersion);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
return success !== undefined;
|
|
302
|
-
} catch (e) {
|
|
303
|
-
console.error("Error writing to LMDB database:", e);
|
|
304
|
-
return false;
|
|
409
|
+
if (db) {
|
|
410
|
+
timeOffsetStore = getLmdbStore(db);
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
timeOffsetStore = getLocalStorageStore();
|
|
305
414
|
}
|
|
415
|
+
timeOffsetStoreResolved = true;
|
|
416
|
+
return timeOffsetStore;
|
|
306
417
|
}
|
|
307
418
|
|
|
308
|
-
let getTimeOffsetBase: () => Promise<number> = defaultGetTimeOffset;
|
|
419
|
+
let getTimeOffsetBase: () => Promise<number | TimeOffsetMeasurement> = defaultGetTimeOffset;
|
|
309
420
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
421
|
+
// Returns undefined if we couldn't get any measurements. Callers must NOT treat that as an offset of 0 - a fabricated offset written to the shared store would poison every other process/tab for the full transition window.
|
|
422
|
+
async function fetchNewOffset(): Promise<TimeOffsetMeasurement | undefined> {
|
|
423
|
+
let measurements: TimeOffsetMeasurement[] = [];
|
|
424
|
+
let failures = 0;
|
|
425
|
+
let retryDelay = FETCH_RETRY_INITIAL_DELAY;
|
|
426
|
+
while (measurements.length < UPDATE_VERIFY_COUNT) {
|
|
427
|
+
// The tab may have been hidden mid-fetch. Measurements from a throttled tab are worse than none, so stop and use whatever we already collected.
|
|
428
|
+
if (!canMeasureOffset()) break;
|
|
313
429
|
try {
|
|
314
|
-
|
|
430
|
+
const result = await getTimeOffsetBase();
|
|
431
|
+
measurements.push(typeof result === "number" ? { offset: result } : result);
|
|
315
432
|
} catch (e) {
|
|
316
|
-
console.error("Error getting time offset:", e);
|
|
433
|
+
console.error("Error getting time offset:", (e as Error).stack ?? e);
|
|
434
|
+
failures++;
|
|
435
|
+
if (failures >= FETCH_MAX_FAILURES) break;
|
|
436
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
437
|
+
retryDelay = Math.min(retryDelay * 2, FETCH_RETRY_MAX_DELAY);
|
|
317
438
|
}
|
|
318
439
|
}
|
|
319
440
|
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
return 0;
|
|
441
|
+
if (measurements.length === 0) {
|
|
442
|
+
return undefined;
|
|
323
443
|
}
|
|
324
444
|
|
|
325
445
|
// Pick the middle offset
|
|
326
|
-
|
|
327
|
-
let
|
|
446
|
+
measurements.sort((a, b) => a.offset - b.offset);
|
|
447
|
+
let measurement = measurements[Math.floor(measurements.length / 2)];
|
|
448
|
+
let offset = measurement.offset;
|
|
328
449
|
|
|
329
450
|
// Log if offset is significant
|
|
330
451
|
let offsetRound = Math.abs(Math.round(offset));
|
|
@@ -337,7 +458,14 @@ async function fetchNewOffset(): Promise<number> {
|
|
|
337
458
|
console.log(`${blue("Synchronized time")}, local clock was ${offset > 0 ? "behind" : "ahead"} by ${offsetColored} @ ${blue(Date.now() + "")}`);
|
|
338
459
|
}
|
|
339
460
|
|
|
340
|
-
return
|
|
461
|
+
return measurement;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function canMeasureOffset() {
|
|
465
|
+
if (isNode()) return true;
|
|
466
|
+
if (typeof document === "undefined") return true;
|
|
467
|
+
// Hidden tabs get their timers and message delivery throttled, which corrupts the round-trip latency estimate and therefore the offset. A visible tab will measure and share via localStorage instead.
|
|
468
|
+
return document.visibilityState !== "hidden";
|
|
341
469
|
}
|
|
342
470
|
|
|
343
471
|
let updatingOffset = false;
|
|
@@ -346,8 +474,8 @@ async function updateTimeOffset() {
|
|
|
346
474
|
updatingOffset = true;
|
|
347
475
|
|
|
348
476
|
try {
|
|
349
|
-
const
|
|
350
|
-
if (!
|
|
477
|
+
const store = await getTimeOffsetStore();
|
|
478
|
+
if (!store) {
|
|
351
479
|
// IMPORTANT: Always use baseGetTime() for scheduling, never getTrueTime().
|
|
352
480
|
// Our update schedule must be based on the stable system clock, not the
|
|
353
481
|
// offset-adjusted time which changes as we synchronize.
|
|
@@ -359,44 +487,54 @@ async function updateTimeOffset() {
|
|
|
359
487
|
debugLog("Past nextUpdateTime, resetting");
|
|
360
488
|
}
|
|
361
489
|
|
|
362
|
-
|
|
490
|
+
const needsFetch = !cachedData || currentTime >= cachedData.updateTime;
|
|
491
|
+
if (needsFetch && !canMeasureOffset()) {
|
|
492
|
+
// Keep using whatever offset we have (even a stale one is better than a measurement skewed by background throttling).
|
|
493
|
+
} else if (!cachedData) {
|
|
363
494
|
// First time initialization
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
offset
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
495
|
+
const measurement = await fetchNewOffset();
|
|
496
|
+
if (measurement) {
|
|
497
|
+
// Re-read the time, as fetchNewOffset can take minutes when it has to retry.
|
|
498
|
+
const initTime = baseGetTime();
|
|
499
|
+
const offset = measurement.offset;
|
|
500
|
+
cachedTimeOffsetData = {
|
|
501
|
+
lastOffset: offset,
|
|
502
|
+
lastUpdateTime: initTime,
|
|
503
|
+
offset: offset,
|
|
504
|
+
updateTime: initTime + UPDATE_TRANSITION_GAP,
|
|
505
|
+
nextOffset: offset,
|
|
506
|
+
nextUpdateTime: initTime + UPDATE_TRANSITION_GAP * 2,
|
|
507
|
+
proof: measurement.proof,
|
|
508
|
+
};
|
|
509
|
+
debugLog("Initialized - time offset:", offset, "ms, next update in", UPDATE_TRANSITION_GAP, "ms");
|
|
510
|
+
}
|
|
511
|
+
// On total failure we leave the data unset (getTrueTime then uses the raw system clock) and the next check interval retries. We never fabricate an offset.
|
|
374
512
|
} else if (currentTime >= cachedData.updateTime) {
|
|
375
513
|
// Time to rotate
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
514
|
+
const newMeasurement = await fetchNewOffset();
|
|
515
|
+
if (newMeasurement) {
|
|
516
|
+
cachedTimeOffsetData = {
|
|
517
|
+
lastOffset: cachedData.offset,
|
|
518
|
+
lastUpdateTime: cachedData.updateTime,
|
|
519
|
+
offset: cachedData.nextOffset,
|
|
520
|
+
updateTime: cachedData.nextUpdateTime,
|
|
521
|
+
nextOffset: newMeasurement.offset,
|
|
522
|
+
nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
|
|
523
|
+
proof: newMeasurement.proof,
|
|
524
|
+
};
|
|
525
|
+
const timeUntilNext = cachedTimeOffsetData.nextUpdateTime - baseGetTime();
|
|
526
|
+
debugLog("Advancing time offset - current:", cachedTimeOffsetData.offset, "ms, next:", cachedTimeOffsetData.nextOffset, "ms, next update in", timeUntilNext, "ms");
|
|
527
|
+
}
|
|
387
528
|
}
|
|
388
529
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
onFirstTimeSync();
|
|
392
|
-
}
|
|
530
|
+
// Always resolve the first sync, even on failure - SocketFunction.mount awaits this, and hanging forever is worse than temporarily running on the unadjusted system clock.
|
|
531
|
+
markFirstTimeSync();
|
|
393
532
|
return;
|
|
394
533
|
}
|
|
395
534
|
|
|
396
|
-
// At this point
|
|
397
|
-
// Main LMDB path with atomic synchronization
|
|
535
|
+
// At this point we have a shared store (LMDB for processes, localStorage for tabs) with atomic-ish versioned writes.
|
|
398
536
|
while (true) {
|
|
399
|
-
const entry = await
|
|
537
|
+
const entry = await store.getEntry();
|
|
400
538
|
// IMPORTANT: Always use baseGetTime() for scheduling, never getTrueTime().
|
|
401
539
|
// Our update schedule must be based on the stable system clock, not the
|
|
402
540
|
// offset-adjusted time which changes as we synchronize.
|
|
@@ -411,38 +549,48 @@ async function updateTimeOffset() {
|
|
|
411
549
|
debugLog("Past nextUpdateTime, resetting");
|
|
412
550
|
}
|
|
413
551
|
|
|
552
|
+
const needsFetch = !cachedData || currentTime >= cachedData.updateTime;
|
|
553
|
+
if (needsFetch && !canMeasureOffset()) {
|
|
554
|
+
// Keep using whatever offset we have (even a stale one is better than a measurement skewed by background throttling). If we have nothing, run unsynced until we become visible or another tab writes to the store.
|
|
555
|
+
cachedTimeOffsetData = entry?.data ?? cachedTimeOffsetData;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
|
|
414
559
|
if (!cachedData || !readVersion) {
|
|
415
560
|
// First time initialization - use conditional write to handle race
|
|
416
|
-
const
|
|
561
|
+
const measurement = await fetchNewOffset();
|
|
562
|
+
if (!measurement) {
|
|
563
|
+
// Total failure - keep any stale data rather than writing a fabricated offset to the shared store. The next check interval retries.
|
|
564
|
+
cachedTimeOffsetData = entry?.data ?? cachedTimeOffsetData;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
// Re-read the time, as fetchNewOffset can take minutes when it has to retry.
|
|
568
|
+
const initTime = baseGetTime();
|
|
569
|
+
const offset = measurement.offset;
|
|
417
570
|
const initData: TimeOffsetData = {
|
|
418
571
|
lastOffset: offset,
|
|
419
|
-
lastUpdateTime:
|
|
572
|
+
lastUpdateTime: initTime,
|
|
420
573
|
offset: offset,
|
|
421
|
-
updateTime:
|
|
574
|
+
updateTime: initTime + UPDATE_TRANSITION_GAP,
|
|
422
575
|
nextOffset: offset,
|
|
423
|
-
nextUpdateTime:
|
|
576
|
+
nextUpdateTime: initTime + UPDATE_TRANSITION_GAP * 2,
|
|
577
|
+
proof: measurement.proof,
|
|
424
578
|
};
|
|
425
579
|
|
|
426
|
-
const newVersion = Math.random();
|
|
427
|
-
|
|
428
580
|
if (readVersion) {
|
|
429
|
-
const success = await
|
|
581
|
+
const success = await store.putIfVersion(initData, readVersion);
|
|
430
582
|
if (!success) {
|
|
431
583
|
debugLog("Lost the race, retrying");
|
|
432
584
|
// Lost the race, retry
|
|
433
585
|
continue;
|
|
434
586
|
}
|
|
587
|
+
cachedTimeOffsetData = initData;
|
|
435
588
|
console.log("Successfully wrote atomic reset");
|
|
436
589
|
break;
|
|
437
590
|
}
|
|
438
591
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
// Read back to see what actually got written
|
|
443
|
-
const actualEntry = await db.getEntry("timeOffset");
|
|
444
|
-
if (actualEntry?.version !== newVersion) {
|
|
445
|
-
// Lost the race, another process wrote after us
|
|
592
|
+
if (!await store.putInitial(initData)) {
|
|
593
|
+
// Lost the race, another writer wrote after us
|
|
446
594
|
// Retry from the top to read their data
|
|
447
595
|
debugLog("Value was changed by another process, retrying");
|
|
448
596
|
continue;
|
|
@@ -456,17 +604,23 @@ async function updateTimeOffset() {
|
|
|
456
604
|
|
|
457
605
|
if (currentTime >= cachedData.updateTime) {
|
|
458
606
|
// Time to rotate
|
|
459
|
-
const
|
|
607
|
+
const newMeasurement = await fetchNewOffset();
|
|
608
|
+
if (!newMeasurement) {
|
|
609
|
+
// Total failure - keep the existing data unrotated (the offset just flattens out at nextOffset). The next check interval retries the rotation.
|
|
610
|
+
cachedTimeOffsetData = cachedData;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
460
613
|
const newData: TimeOffsetData = {
|
|
461
614
|
lastOffset: cachedData.offset,
|
|
462
615
|
lastUpdateTime: cachedData.updateTime,
|
|
463
616
|
offset: cachedData.nextOffset,
|
|
464
617
|
updateTime: cachedData.nextUpdateTime,
|
|
465
|
-
nextOffset:
|
|
618
|
+
nextOffset: newMeasurement.offset,
|
|
466
619
|
nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
|
|
620
|
+
proof: newMeasurement.proof,
|
|
467
621
|
};
|
|
468
622
|
|
|
469
|
-
const success = await
|
|
623
|
+
const success = await store.putIfVersion(newData, readVersion);
|
|
470
624
|
if (!success) {
|
|
471
625
|
// Lost the race, retry
|
|
472
626
|
continue;
|
|
@@ -479,31 +633,42 @@ async function updateTimeOffset() {
|
|
|
479
633
|
} else {
|
|
480
634
|
cachedTimeOffsetData = cachedData;
|
|
481
635
|
const timeUntilNext = cachedData.updateTime - baseGetTime();
|
|
482
|
-
debugLog("Loaded from
|
|
636
|
+
debugLog("Loaded from store - current:", cachedData.offset, "ms, next:", cachedData.nextOffset, "ms, next update in", timeUntilNext, "ms");
|
|
483
637
|
break;
|
|
484
638
|
}
|
|
485
639
|
}
|
|
486
640
|
|
|
487
|
-
|
|
488
|
-
didFirstTimeSync = true;
|
|
489
|
-
onFirstTimeSync();
|
|
490
|
-
}
|
|
641
|
+
markFirstTimeSync();
|
|
491
642
|
} finally {
|
|
492
643
|
updatingOffset = false;
|
|
493
644
|
}
|
|
494
645
|
}
|
|
495
646
|
|
|
496
|
-
|
|
647
|
+
function triggerUpdateTimeOffset() {
|
|
497
648
|
updateTimeOffset().catch((e) => {
|
|
498
649
|
console.warn("Error updating time offset:", e);
|
|
499
650
|
});
|
|
500
|
-
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
setInterval(triggerUpdateTimeOffset, UPDATE_CHECK_INTERVAL);
|
|
501
654
|
setImmediate(() => {
|
|
502
655
|
updateTimeOffset().catch((e) => {
|
|
503
656
|
console.error("Error updating initial offset:", e);
|
|
504
657
|
});
|
|
505
658
|
});
|
|
506
659
|
|
|
660
|
+
if (!isNode() && typeof window !== "undefined" && typeof document !== "undefined") {
|
|
661
|
+
// If we loaded in a hidden tab we skip measuring, so re-check as soon as we become visible.
|
|
662
|
+
document.addEventListener("visibilitychange", triggerUpdateTimeOffset);
|
|
663
|
+
window.addEventListener("focus", triggerUpdateTimeOffset);
|
|
664
|
+
// Pick up offsets other tabs write, so only one tab ever has to measure.
|
|
665
|
+
window.addEventListener("storage", (e) => {
|
|
666
|
+
if (e.key === TIME_OFFSET_LOCAL_STORAGE_KEY) {
|
|
667
|
+
triggerUpdateTimeOffset();
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
507
672
|
|
|
508
673
|
class TimeControllerBase {
|
|
509
674
|
public async getTrueTime() {
|
|
@@ -530,4 +695,4 @@ const TimeController = SocketFunction.register(
|
|
|
530
695
|
// (just a ping), and don't expose really expose any data.
|
|
531
696
|
// noAutoExpose: true
|
|
532
697
|
}
|
|
533
|
-
);
|
|
698
|
+
);
|