socket-function 1.1.47 → 1.1.49
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 +27 -1
- package/package.json +1 -1
- package/test.ts +5 -11
- package/time/trueTimeShim.d.ts +27 -1
- package/time/trueTimeShim.ts +345 -144
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;
|
|
@@ -1550,18 +1560,34 @@ declare module "socket-function/time/trueTimeShim" {
|
|
|
1550
1560
|
fromTime: number;
|
|
1551
1561
|
toTime: number;
|
|
1552
1562
|
};
|
|
1563
|
+
export declare function computeTweenedOffset(components: {
|
|
1564
|
+
systemTime: number;
|
|
1565
|
+
fromOffset: number;
|
|
1566
|
+
toOffset: number;
|
|
1567
|
+
fromTime: number;
|
|
1568
|
+
}): number;
|
|
1553
1569
|
export declare function getTimeComponents(): {
|
|
1554
1570
|
systemTime: number;
|
|
1555
1571
|
offset: number;
|
|
1556
1572
|
};
|
|
1557
1573
|
export declare function getTrueTime(): number;
|
|
1558
1574
|
export declare function getTrueTimeOffset(): number;
|
|
1575
|
+
export type TrueTimeProof = {
|
|
1576
|
+
systemTime: number;
|
|
1577
|
+
fromOffset: number;
|
|
1578
|
+
toOffset: number;
|
|
1579
|
+
fromTime: number;
|
|
1580
|
+
toTime: number;
|
|
1581
|
+
offset: number;
|
|
1582
|
+
measurement?: TimeOffsetProof;
|
|
1583
|
+
};
|
|
1584
|
+
export declare function getTrueTimeProof(): TrueTimeProof | undefined;
|
|
1559
1585
|
export declare function waitForFirstTimeSync(): Promise<void> | undefined;
|
|
1560
1586
|
declare global {
|
|
1561
1587
|
var TRUE_TIME_ALREADY_SHIMMED: boolean;
|
|
1562
1588
|
}
|
|
1563
1589
|
export declare function shimDateNow(): void;
|
|
1564
1590
|
export declare function getBrowserTime(): number;
|
|
1565
|
-
export declare function setGetTimeOffsetBase(base: () => Promise<number>): void;
|
|
1591
|
+
export declare function setGetTimeOffsetBase(base: () => Promise<number | TimeOffsetMeasurement>): void;
|
|
1566
1592
|
|
|
1567
1593
|
}
|
package/package.json
CHANGED
package/test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getTimeComponentsDetailed, waitForFirstTimeSync } from "./time/trueTimeShim";
|
|
1
|
+
import { computeTweenedOffset, getTimeComponentsDetailed, waitForFirstTimeSync } from "./time/trueTimeShim";
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
|
|
@@ -19,7 +19,6 @@ async function sampleMode() {
|
|
|
19
19
|
toOffset: number;
|
|
20
20
|
fromTime: number;
|
|
21
21
|
toTime: number;
|
|
22
|
-
fraction: number;
|
|
23
22
|
};
|
|
24
23
|
|
|
25
24
|
const samples: Sample[] = [];
|
|
@@ -28,11 +27,8 @@ async function sampleMode() {
|
|
|
28
27
|
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
|
29
28
|
const detailed = getTimeComponentsDetailed();
|
|
30
29
|
|
|
31
|
-
// Calculate
|
|
32
|
-
const
|
|
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;
|
|
30
|
+
// Calculate the tween using the same systemTime
|
|
31
|
+
const offset = computeTweenedOffset(detailed);
|
|
36
32
|
|
|
37
33
|
samples.push({
|
|
38
34
|
id: TEST_RUN_ID,
|
|
@@ -42,7 +38,6 @@ async function sampleMode() {
|
|
|
42
38
|
toOffset: detailed.toOffset,
|
|
43
39
|
fromTime: detailed.fromTime,
|
|
44
40
|
toTime: detailed.toTime,
|
|
45
|
-
fraction,
|
|
46
41
|
});
|
|
47
42
|
|
|
48
43
|
if (SAMPLE_INTERVAL_MS > 0) {
|
|
@@ -88,7 +83,6 @@ async function verifyMode() {
|
|
|
88
83
|
toOffset: number;
|
|
89
84
|
fromTime: number;
|
|
90
85
|
toTime: number;
|
|
91
|
-
fraction: number;
|
|
92
86
|
file: string;
|
|
93
87
|
};
|
|
94
88
|
|
|
@@ -126,13 +120,13 @@ async function verifyMode() {
|
|
|
126
120
|
console.error(` systemTime: ${prev.systemTime}`);
|
|
127
121
|
console.error(` offset: ${prev.offset}`);
|
|
128
122
|
console.error(` trueTime: ${prevTrueTime}`);
|
|
129
|
-
console.error(`
|
|
123
|
+
console.error(` tween: ${prev.fromOffset} -> ${prev.toOffset}`);
|
|
130
124
|
console.error(` timeWindow: ${prev.fromTime} -> ${prev.toTime}`);
|
|
131
125
|
console.error(` Current [ID: ${sample.id}, file: ${sample.file}]:`);
|
|
132
126
|
console.error(` systemTime: ${sample.systemTime}`);
|
|
133
127
|
console.error(` offset: ${sample.offset}`);
|
|
134
128
|
console.error(` trueTime: ${trueTime}`);
|
|
135
|
-
console.error(`
|
|
129
|
+
console.error(` tween: ${sample.fromOffset} -> ${sample.toOffset}`);
|
|
136
130
|
console.error(` timeWindow: ${sample.fromTime} -> ${sample.toTime}`);
|
|
137
131
|
console.error(` Difference: ${trueTime - lastTrueTime}ms`);
|
|
138
132
|
}
|
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;
|
|
@@ -5,16 +15,32 @@ export declare function getTimeComponentsDetailed(): {
|
|
|
5
15
|
fromTime: number;
|
|
6
16
|
toTime: number;
|
|
7
17
|
};
|
|
18
|
+
export declare function computeTweenedOffset(components: {
|
|
19
|
+
systemTime: number;
|
|
20
|
+
fromOffset: number;
|
|
21
|
+
toOffset: number;
|
|
22
|
+
fromTime: number;
|
|
23
|
+
}): number;
|
|
8
24
|
export declare function getTimeComponents(): {
|
|
9
25
|
systemTime: number;
|
|
10
26
|
offset: number;
|
|
11
27
|
};
|
|
12
28
|
export declare function getTrueTime(): number;
|
|
13
29
|
export declare function getTrueTimeOffset(): number;
|
|
30
|
+
export type TrueTimeProof = {
|
|
31
|
+
systemTime: number;
|
|
32
|
+
fromOffset: number;
|
|
33
|
+
toOffset: number;
|
|
34
|
+
fromTime: number;
|
|
35
|
+
toTime: number;
|
|
36
|
+
offset: number;
|
|
37
|
+
measurement?: TimeOffsetProof;
|
|
38
|
+
};
|
|
39
|
+
export declare function getTrueTimeProof(): TrueTimeProof | undefined;
|
|
14
40
|
export declare function waitForFirstTimeSync(): Promise<void> | undefined;
|
|
15
41
|
declare global {
|
|
16
42
|
var TRUE_TIME_ALREADY_SHIMMED: boolean;
|
|
17
43
|
}
|
|
18
44
|
export declare function shimDateNow(): void;
|
|
19
45
|
export declare function getBrowserTime(): number;
|
|
20
|
-
export declare function setGetTimeOffsetBase(base: () => Promise<number>): void;
|
|
46
|
+
export declare function setGetTimeOffsetBase(base: () => Promise<number | TimeOffsetMeasurement>): void;
|
package/time/trueTimeShim.ts
CHANGED
|
@@ -2,34 +2,57 @@ 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.
|
|
19
23
|
const MINIMUM_TIME_RATE = 0.5;
|
|
20
24
|
|
|
25
|
+
// The offset tweens toward its target at this fraction of elapsed real time, then plateaus once it gets there. Must stay below 1 so output time keeps moving forward even while the offset is shrinking. Tweening across the whole transition window instead would mean a badly-off clock (many seconds) takes the entire window to converge, which breaks transactional consistency with other machines.
|
|
26
|
+
const OFFSET_TWEEN_RATE = 0.5;
|
|
27
|
+
|
|
21
28
|
const THROW_ON_ERROR = false;
|
|
22
29
|
|
|
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.
|
|
30
|
+
// Hugely important as if we don't synchronize between processes, it means our logs are going to be confusing and out of order.
|
|
31
|
+
// - 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
32
|
let USE_LMDB_PROCESS_SYNC = true;
|
|
26
33
|
|
|
34
|
+
// Browser tabs synchronize via localStorage, for the same reason processes synchronize via LMDB.
|
|
35
|
+
const TIME_OFFSET_LOCAL_STORAGE_KEY = "socket-function-time-offset";
|
|
36
|
+
|
|
27
37
|
function debugLog(...args: any[]) {
|
|
28
38
|
if (DEBUG_TIME_SYNC) {
|
|
29
39
|
console.log("[TimeSync]", ...args);
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
|
|
43
|
+
// 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.
|
|
44
|
+
export type TimeOffsetProof = {
|
|
45
|
+
sendTime: number;
|
|
46
|
+
receiveTime: number;
|
|
47
|
+
serverTime: number;
|
|
48
|
+
offset: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type TimeOffsetMeasurement = {
|
|
52
|
+
offset: number;
|
|
53
|
+
proof?: TimeOffsetProof;
|
|
54
|
+
};
|
|
55
|
+
|
|
33
56
|
type TimeOffsetData = {
|
|
34
57
|
lastOffset: number;
|
|
35
58
|
lastUpdateTime: number;
|
|
@@ -37,6 +60,8 @@ type TimeOffsetData = {
|
|
|
37
60
|
updateTime: number;
|
|
38
61
|
nextOffset: number;
|
|
39
62
|
nextUpdateTime: number;
|
|
63
|
+
// Proof of the most recent measurement (the one that produced nextOffset).
|
|
64
|
+
proof?: TimeOffsetProof;
|
|
40
65
|
};
|
|
41
66
|
|
|
42
67
|
let cachedTimeOffsetData: TimeOffsetData | undefined = undefined;
|
|
@@ -45,6 +70,11 @@ let onFirstTimeSync!: () => void;
|
|
|
45
70
|
let firstTimeSyncPromise = new Promise<void>((resolve) => {
|
|
46
71
|
onFirstTimeSync = resolve;
|
|
47
72
|
});
|
|
73
|
+
function markFirstTimeSync() {
|
|
74
|
+
if (didFirstTimeSync) return;
|
|
75
|
+
didFirstTimeSync = true;
|
|
76
|
+
onFirstTimeSync();
|
|
77
|
+
}
|
|
48
78
|
|
|
49
79
|
const baseGetTime = Date.now;
|
|
50
80
|
let lastTime = 0;
|
|
@@ -111,13 +141,24 @@ export function getTimeComponentsDetailed(): {
|
|
|
111
141
|
}
|
|
112
142
|
}
|
|
113
143
|
|
|
144
|
+
export function computeTweenedOffset(components: {
|
|
145
|
+
systemTime: number;
|
|
146
|
+
fromOffset: number;
|
|
147
|
+
toOffset: number;
|
|
148
|
+
fromTime: number;
|
|
149
|
+
}): number {
|
|
150
|
+
const elapsed = Math.max(0, components.systemTime - components.fromTime);
|
|
151
|
+
const delta = components.toOffset - components.fromOffset;
|
|
152
|
+
const maxChange = elapsed * OFFSET_TWEEN_RATE;
|
|
153
|
+
if (Math.abs(delta) <= maxChange) {
|
|
154
|
+
return components.toOffset;
|
|
155
|
+
}
|
|
156
|
+
return components.fromOffset + Math.sign(delta) * maxChange;
|
|
157
|
+
}
|
|
158
|
+
|
|
114
159
|
export function getTimeComponents(): { systemTime: number; offset: number } {
|
|
115
160
|
const detailed = getTimeComponentsDetailed();
|
|
116
|
-
|
|
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 };
|
|
161
|
+
return { systemTime: detailed.systemTime, offset: computeTweenedOffset(detailed) };
|
|
121
162
|
}
|
|
122
163
|
|
|
123
164
|
export function getTrueTime() {
|
|
@@ -147,6 +188,31 @@ export function getTrueTimeOffset() {
|
|
|
147
188
|
const { offset } = getTimeComponents();
|
|
148
189
|
return offset;
|
|
149
190
|
}
|
|
191
|
+
export type TrueTimeProof = {
|
|
192
|
+
systemTime: number;
|
|
193
|
+
// The tween endpoints the current offset is moving between. When offset !== toOffset we are still converging.
|
|
194
|
+
fromOffset: number;
|
|
195
|
+
toOffset: number;
|
|
196
|
+
fromTime: number;
|
|
197
|
+
toTime: number;
|
|
198
|
+
offset: number;
|
|
199
|
+
// The raw measurement that produced the newest offset. Undefined if the offset came from a caller-provided base that only returns numbers.
|
|
200
|
+
measurement?: TimeOffsetProof;
|
|
201
|
+
};
|
|
202
|
+
export function getTrueTimeProof(): TrueTimeProof | undefined {
|
|
203
|
+
const data = cachedTimeOffsetData;
|
|
204
|
+
if (!data) return undefined;
|
|
205
|
+
const detailed = getTimeComponentsDetailed();
|
|
206
|
+
return {
|
|
207
|
+
systemTime: detailed.systemTime,
|
|
208
|
+
fromOffset: detailed.fromOffset,
|
|
209
|
+
toOffset: detailed.toOffset,
|
|
210
|
+
fromTime: detailed.fromTime,
|
|
211
|
+
toTime: detailed.toTime,
|
|
212
|
+
offset: computeTweenedOffset(detailed),
|
|
213
|
+
measurement: data.proof,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
150
216
|
export function waitForFirstTimeSync(): Promise<void> | undefined {
|
|
151
217
|
if (didFirstTimeSync) return undefined;
|
|
152
218
|
return firstTimeSyncPromise;
|
|
@@ -166,18 +232,22 @@ export function getBrowserTime() {
|
|
|
166
232
|
return baseGetTime();
|
|
167
233
|
}
|
|
168
234
|
|
|
169
|
-
export function setGetTimeOffsetBase(base: () => Promise<number>) {
|
|
235
|
+
export function setGetTimeOffsetBase(base: () => Promise<number | TimeOffsetMeasurement>) {
|
|
170
236
|
getTimeOffsetBase = base;
|
|
171
237
|
}
|
|
172
238
|
|
|
173
|
-
|
|
239
|
+
function makeMeasurement(sendTime: number, receiveTime: number, serverTime: number): TimeOffsetMeasurement {
|
|
240
|
+
const predictedServerToClientLatency = (receiveTime - sendTime) / 2;
|
|
241
|
+
const offset = serverTime + predictedServerToClientLatency - receiveTime;
|
|
242
|
+
return { offset, proof: { sendTime, receiveTime, serverTime, offset } };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function defaultGetTimeOffset(): Promise<TimeOffsetMeasurement> {
|
|
174
246
|
if (!isNode()) {
|
|
175
247
|
let sendTime = baseGetTime();
|
|
176
248
|
let serverTrueTime = await TimeController.nodes[SocketFunction.browserNodeId()].getTrueTime();
|
|
177
|
-
let
|
|
178
|
-
|
|
179
|
-
let trueTimeRightNow = serverTrueTime + predictedServerToClientLatency;
|
|
180
|
-
return trueTimeRightNow - systemTime;
|
|
249
|
+
let receiveTime = baseGetTime();
|
|
250
|
+
return makeMeasurement(sendTime, receiveTime, serverTrueTime);
|
|
181
251
|
}
|
|
182
252
|
|
|
183
253
|
const dgram = await import("dgram");
|
|
@@ -194,33 +264,61 @@ async function defaultGetTimeOffset(): Promise<number> {
|
|
|
194
264
|
|
|
195
265
|
const sendTime = baseGetTime();
|
|
196
266
|
|
|
267
|
+
// 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.
|
|
268
|
+
const timeout = setTimeout(() => {
|
|
269
|
+
client.close();
|
|
270
|
+
reject(new Error(`NTP request to ${NTP_SERVER} timed out`));
|
|
271
|
+
}, NTP_TIMEOUT);
|
|
272
|
+
|
|
197
273
|
client.send(message, 0, message.length, NTP_PORT, NTP_SERVER);
|
|
198
274
|
client.on("error", (err) => {
|
|
275
|
+
clearTimeout(timeout);
|
|
199
276
|
client.close();
|
|
200
277
|
reject(err);
|
|
201
278
|
});
|
|
202
279
|
|
|
203
280
|
client.on("message", (msg) => {
|
|
204
281
|
const receiveTime = baseGetTime();
|
|
282
|
+
// A throw in this handler would be an uncaught exception, not a rejection, so validate before reading fixed offsets.
|
|
283
|
+
if (msg.length < NTP_PACKET_SIZE) return;
|
|
284
|
+
clearTimeout(timeout);
|
|
205
285
|
|
|
206
286
|
// Extract the transmit timestamp from the server response
|
|
207
287
|
const transmitTimestampSeconds = msg.readUInt32BE(40);
|
|
208
288
|
const transmitTimestampFraction = msg.readUInt32BE(44);
|
|
209
289
|
const transmitTimestamp = (transmitTimestampSeconds * 1000) + (transmitTimestampFraction * 1000 / 0x100000000) - NTP_EPOCH_OFFSET;
|
|
210
290
|
|
|
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
291
|
client.close();
|
|
219
|
-
resolve(
|
|
292
|
+
resolve(makeMeasurement(sendTime, receiveTime, transmitTimestamp));
|
|
220
293
|
});
|
|
221
294
|
});
|
|
222
295
|
}
|
|
223
296
|
|
|
297
|
+
function isValidTimeOffsetData(data: unknown): data is TimeOffsetData {
|
|
298
|
+
const d = data as TimeOffsetData | undefined;
|
|
299
|
+
return (
|
|
300
|
+
!!d &&
|
|
301
|
+
typeof d.lastOffset === "number" &&
|
|
302
|
+
typeof d.lastUpdateTime === "number" &&
|
|
303
|
+
typeof d.offset === "number" &&
|
|
304
|
+
typeof d.updateTime === "number" &&
|
|
305
|
+
typeof d.nextOffset === "number" &&
|
|
306
|
+
typeof d.nextUpdateTime === "number"
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
type TimeOffsetStoreEntry = {
|
|
311
|
+
data: TimeOffsetData;
|
|
312
|
+
version: number;
|
|
313
|
+
};
|
|
314
|
+
type TimeOffsetStore = {
|
|
315
|
+
getEntry(): Promise<TimeOffsetStoreEntry | undefined>;
|
|
316
|
+
// Atomic conditional write, only succeeds if the stored version matches expectedVersion.
|
|
317
|
+
putIfVersion(data: TimeOffsetData, expectedVersion: number): Promise<boolean>;
|
|
318
|
+
// 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.
|
|
319
|
+
putInitial(data: TimeOffsetData): Promise<boolean>;
|
|
320
|
+
};
|
|
321
|
+
|
|
224
322
|
let timeOffsetDb: import("lmdb").RootDatabase<TimeOffsetData, string> | undefined = undefined;
|
|
225
323
|
async function getTimeOffsetDb() {
|
|
226
324
|
if (!USE_LMDB_PROCESS_SYNC) return undefined;
|
|
@@ -245,86 +343,145 @@ async function getTimeOffsetDb() {
|
|
|
245
343
|
}
|
|
246
344
|
}
|
|
247
345
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
346
|
+
function getLmdbStore(db: import("lmdb").RootDatabase<TimeOffsetData, string>): TimeOffsetStore {
|
|
347
|
+
return {
|
|
348
|
+
async getEntry() {
|
|
349
|
+
try {
|
|
350
|
+
const entry = await db.getEntry("timeOffset"); // Gets {value, version} atomically
|
|
351
|
+
if (!entry) return undefined;
|
|
352
|
+
if (typeof entry.version !== "number" || !isValidTimeOffsetData(entry.value)) return undefined;
|
|
353
|
+
return { data: entry.value, version: entry.version };
|
|
354
|
+
} catch (e) {
|
|
355
|
+
console.error("Error reading from LMDB database:", e);
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
async putIfVersion(data, expectedVersion) {
|
|
360
|
+
try {
|
|
361
|
+
// Use random version to minimize collision probability on retries
|
|
362
|
+
const newVersion = Math.random();
|
|
363
|
+
const success = await db.ifVersion("timeOffset", expectedVersion, () => {
|
|
364
|
+
return db.put("timeOffset", data, newVersion);
|
|
365
|
+
});
|
|
366
|
+
return success !== undefined;
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.error("Error writing to LMDB database:", e);
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
async putInitial(data) {
|
|
373
|
+
try {
|
|
374
|
+
const newVersion = Math.random();
|
|
375
|
+
await db.put("timeOffset", data, newVersion);
|
|
376
|
+
// Read back to see what actually got written
|
|
377
|
+
const actualEntry = await db.getEntry("timeOffset");
|
|
378
|
+
return actualEntry?.version === newVersion;
|
|
379
|
+
} catch (e) {
|
|
380
|
+
console.error("Error writing to LMDB database:", e);
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
256
386
|
|
|
387
|
+
function getLocalStorageStore(): TimeOffsetStore | undefined {
|
|
257
388
|
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);
|
|
389
|
+
if (typeof localStorage === "undefined") return undefined;
|
|
390
|
+
} catch {
|
|
280
391
|
return undefined;
|
|
281
392
|
}
|
|
393
|
+
type StoredValue = {
|
|
394
|
+
version: number;
|
|
395
|
+
data: TimeOffsetData;
|
|
396
|
+
};
|
|
397
|
+
function readStored(): StoredValue | undefined {
|
|
398
|
+
try {
|
|
399
|
+
const raw = localStorage.getItem(TIME_OFFSET_LOCAL_STORAGE_KEY);
|
|
400
|
+
if (!raw) return undefined;
|
|
401
|
+
const parsed = JSON.parse(raw) as StoredValue;
|
|
402
|
+
if (!parsed || typeof parsed.version !== "number" || !isValidTimeOffsetData(parsed.data)) return undefined;
|
|
403
|
+
return parsed;
|
|
404
|
+
} catch (e) {
|
|
405
|
+
console.error("Error reading time offset from localStorage:", (e as Error).stack ?? e);
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function writeStored(data: TimeOffsetData): number | undefined {
|
|
410
|
+
try {
|
|
411
|
+
const version = Math.random();
|
|
412
|
+
localStorage.setItem(TIME_OFFSET_LOCAL_STORAGE_KEY, JSON.stringify({ version, data }));
|
|
413
|
+
return version;
|
|
414
|
+
} catch (e) {
|
|
415
|
+
console.error("Error writing time offset to localStorage:", (e as Error).stack ?? e);
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
async getEntry() {
|
|
421
|
+
const stored = readStored();
|
|
422
|
+
if (!stored) return undefined;
|
|
423
|
+
return { data: stored.data, version: stored.version };
|
|
424
|
+
},
|
|
425
|
+
// 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.
|
|
426
|
+
async putIfVersion(data, expectedVersion) {
|
|
427
|
+
const stored = readStored();
|
|
428
|
+
if (stored?.version !== expectedVersion) return false;
|
|
429
|
+
return writeStored(data) !== undefined;
|
|
430
|
+
},
|
|
431
|
+
async putInitial(data) {
|
|
432
|
+
const version = writeStored(data);
|
|
433
|
+
if (version === undefined) return false;
|
|
434
|
+
return readStored()?.version === version;
|
|
435
|
+
},
|
|
436
|
+
};
|
|
282
437
|
}
|
|
283
438
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
288
|
-
|
|
439
|
+
let timeOffsetStore: TimeOffsetStore | undefined = undefined;
|
|
440
|
+
let timeOffsetStoreResolved = false;
|
|
441
|
+
async function getTimeOffsetStore(): Promise<TimeOffsetStore | undefined> {
|
|
442
|
+
if (timeOffsetStoreResolved) return timeOffsetStore;
|
|
443
|
+
if (isNode()) {
|
|
289
444
|
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;
|
|
445
|
+
if (db) {
|
|
446
|
+
timeOffsetStore = getLmdbStore(db);
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
timeOffsetStore = getLocalStorageStore();
|
|
305
450
|
}
|
|
451
|
+
timeOffsetStoreResolved = true;
|
|
452
|
+
return timeOffsetStore;
|
|
306
453
|
}
|
|
307
454
|
|
|
308
|
-
let getTimeOffsetBase: () => Promise<number> = defaultGetTimeOffset;
|
|
455
|
+
let getTimeOffsetBase: () => Promise<number | TimeOffsetMeasurement> = defaultGetTimeOffset;
|
|
309
456
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
457
|
+
// 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.
|
|
458
|
+
async function fetchNewOffset(): Promise<TimeOffsetMeasurement | undefined> {
|
|
459
|
+
let measurements: TimeOffsetMeasurement[] = [];
|
|
460
|
+
let failures = 0;
|
|
461
|
+
let retryDelay = FETCH_RETRY_INITIAL_DELAY;
|
|
462
|
+
while (measurements.length < UPDATE_VERIFY_COUNT) {
|
|
463
|
+
// 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.
|
|
464
|
+
if (!canMeasureOffset()) break;
|
|
313
465
|
try {
|
|
314
|
-
|
|
466
|
+
const result = await getTimeOffsetBase();
|
|
467
|
+
measurements.push(typeof result === "number" ? { offset: result } : result);
|
|
315
468
|
} catch (e) {
|
|
316
|
-
console.error("Error getting time offset:", e);
|
|
469
|
+
console.error("Error getting time offset:", (e as Error).stack ?? e);
|
|
470
|
+
failures++;
|
|
471
|
+
if (failures >= FETCH_MAX_FAILURES) break;
|
|
472
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
473
|
+
retryDelay = Math.min(retryDelay * 2, FETCH_RETRY_MAX_DELAY);
|
|
317
474
|
}
|
|
318
475
|
}
|
|
319
476
|
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
return 0;
|
|
477
|
+
if (measurements.length === 0) {
|
|
478
|
+
return undefined;
|
|
323
479
|
}
|
|
324
480
|
|
|
325
481
|
// Pick the middle offset
|
|
326
|
-
|
|
327
|
-
let
|
|
482
|
+
measurements.sort((a, b) => a.offset - b.offset);
|
|
483
|
+
let measurement = measurements[Math.floor(measurements.length / 2)];
|
|
484
|
+
let offset = measurement.offset;
|
|
328
485
|
|
|
329
486
|
// Log if offset is significant
|
|
330
487
|
let offsetRound = Math.abs(Math.round(offset));
|
|
@@ -337,7 +494,14 @@ async function fetchNewOffset(): Promise<number> {
|
|
|
337
494
|
console.log(`${blue("Synchronized time")}, local clock was ${offset > 0 ? "behind" : "ahead"} by ${offsetColored} @ ${blue(Date.now() + "")}`);
|
|
338
495
|
}
|
|
339
496
|
|
|
340
|
-
return
|
|
497
|
+
return measurement;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function canMeasureOffset() {
|
|
501
|
+
if (isNode()) return true;
|
|
502
|
+
if (typeof document === "undefined") return true;
|
|
503
|
+
// 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.
|
|
504
|
+
return document.visibilityState !== "hidden";
|
|
341
505
|
}
|
|
342
506
|
|
|
343
507
|
let updatingOffset = false;
|
|
@@ -346,8 +510,8 @@ async function updateTimeOffset() {
|
|
|
346
510
|
updatingOffset = true;
|
|
347
511
|
|
|
348
512
|
try {
|
|
349
|
-
const
|
|
350
|
-
if (!
|
|
513
|
+
const store = await getTimeOffsetStore();
|
|
514
|
+
if (!store) {
|
|
351
515
|
// IMPORTANT: Always use baseGetTime() for scheduling, never getTrueTime().
|
|
352
516
|
// Our update schedule must be based on the stable system clock, not the
|
|
353
517
|
// offset-adjusted time which changes as we synchronize.
|
|
@@ -359,44 +523,54 @@ async function updateTimeOffset() {
|
|
|
359
523
|
debugLog("Past nextUpdateTime, resetting");
|
|
360
524
|
}
|
|
361
525
|
|
|
362
|
-
|
|
526
|
+
const needsFetch = !cachedData || currentTime >= cachedData.updateTime;
|
|
527
|
+
if (needsFetch && !canMeasureOffset()) {
|
|
528
|
+
// Keep using whatever offset we have (even a stale one is better than a measurement skewed by background throttling).
|
|
529
|
+
} else if (!cachedData) {
|
|
363
530
|
// First time initialization
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
offset
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
531
|
+
const measurement = await fetchNewOffset();
|
|
532
|
+
if (measurement) {
|
|
533
|
+
// Re-read the time, as fetchNewOffset can take minutes when it has to retry.
|
|
534
|
+
const initTime = baseGetTime();
|
|
535
|
+
const offset = measurement.offset;
|
|
536
|
+
cachedTimeOffsetData = {
|
|
537
|
+
lastOffset: offset,
|
|
538
|
+
lastUpdateTime: initTime,
|
|
539
|
+
offset: offset,
|
|
540
|
+
updateTime: initTime + UPDATE_TRANSITION_GAP,
|
|
541
|
+
nextOffset: offset,
|
|
542
|
+
nextUpdateTime: initTime + UPDATE_TRANSITION_GAP * 2,
|
|
543
|
+
proof: measurement.proof,
|
|
544
|
+
};
|
|
545
|
+
debugLog("Initialized - time offset:", offset, "ms, next update in", UPDATE_TRANSITION_GAP, "ms");
|
|
546
|
+
}
|
|
547
|
+
// 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
548
|
} else if (currentTime >= cachedData.updateTime) {
|
|
375
549
|
// Time to rotate
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
550
|
+
const newMeasurement = await fetchNewOffset();
|
|
551
|
+
if (newMeasurement) {
|
|
552
|
+
cachedTimeOffsetData = {
|
|
553
|
+
lastOffset: cachedData.offset,
|
|
554
|
+
lastUpdateTime: cachedData.updateTime,
|
|
555
|
+
offset: cachedData.nextOffset,
|
|
556
|
+
updateTime: cachedData.nextUpdateTime,
|
|
557
|
+
nextOffset: newMeasurement.offset,
|
|
558
|
+
nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
|
|
559
|
+
proof: newMeasurement.proof,
|
|
560
|
+
};
|
|
561
|
+
const timeUntilNext = cachedTimeOffsetData.nextUpdateTime - baseGetTime();
|
|
562
|
+
debugLog("Advancing time offset - current:", cachedTimeOffsetData.offset, "ms, next:", cachedTimeOffsetData.nextOffset, "ms, next update in", timeUntilNext, "ms");
|
|
563
|
+
}
|
|
387
564
|
}
|
|
388
565
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
onFirstTimeSync();
|
|
392
|
-
}
|
|
566
|
+
// 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.
|
|
567
|
+
markFirstTimeSync();
|
|
393
568
|
return;
|
|
394
569
|
}
|
|
395
570
|
|
|
396
|
-
// At this point
|
|
397
|
-
// Main LMDB path with atomic synchronization
|
|
571
|
+
// At this point we have a shared store (LMDB for processes, localStorage for tabs) with atomic-ish versioned writes.
|
|
398
572
|
while (true) {
|
|
399
|
-
const entry = await
|
|
573
|
+
const entry = await store.getEntry();
|
|
400
574
|
// IMPORTANT: Always use baseGetTime() for scheduling, never getTrueTime().
|
|
401
575
|
// Our update schedule must be based on the stable system clock, not the
|
|
402
576
|
// offset-adjusted time which changes as we synchronize.
|
|
@@ -411,38 +585,48 @@ async function updateTimeOffset() {
|
|
|
411
585
|
debugLog("Past nextUpdateTime, resetting");
|
|
412
586
|
}
|
|
413
587
|
|
|
588
|
+
const needsFetch = !cachedData || currentTime >= cachedData.updateTime;
|
|
589
|
+
if (needsFetch && !canMeasureOffset()) {
|
|
590
|
+
// 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.
|
|
591
|
+
cachedTimeOffsetData = entry?.data ?? cachedTimeOffsetData;
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
|
|
414
595
|
if (!cachedData || !readVersion) {
|
|
415
596
|
// First time initialization - use conditional write to handle race
|
|
416
|
-
const
|
|
597
|
+
const measurement = await fetchNewOffset();
|
|
598
|
+
if (!measurement) {
|
|
599
|
+
// Total failure - keep any stale data rather than writing a fabricated offset to the shared store. The next check interval retries.
|
|
600
|
+
cachedTimeOffsetData = entry?.data ?? cachedTimeOffsetData;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
// Re-read the time, as fetchNewOffset can take minutes when it has to retry.
|
|
604
|
+
const initTime = baseGetTime();
|
|
605
|
+
const offset = measurement.offset;
|
|
417
606
|
const initData: TimeOffsetData = {
|
|
418
607
|
lastOffset: offset,
|
|
419
|
-
lastUpdateTime:
|
|
608
|
+
lastUpdateTime: initTime,
|
|
420
609
|
offset: offset,
|
|
421
|
-
updateTime:
|
|
610
|
+
updateTime: initTime + UPDATE_TRANSITION_GAP,
|
|
422
611
|
nextOffset: offset,
|
|
423
|
-
nextUpdateTime:
|
|
612
|
+
nextUpdateTime: initTime + UPDATE_TRANSITION_GAP * 2,
|
|
613
|
+
proof: measurement.proof,
|
|
424
614
|
};
|
|
425
615
|
|
|
426
|
-
const newVersion = Math.random();
|
|
427
|
-
|
|
428
616
|
if (readVersion) {
|
|
429
|
-
const success = await
|
|
617
|
+
const success = await store.putIfVersion(initData, readVersion);
|
|
430
618
|
if (!success) {
|
|
431
619
|
debugLog("Lost the race, retrying");
|
|
432
620
|
// Lost the race, retry
|
|
433
621
|
continue;
|
|
434
622
|
}
|
|
623
|
+
cachedTimeOffsetData = initData;
|
|
435
624
|
console.log("Successfully wrote atomic reset");
|
|
436
625
|
break;
|
|
437
626
|
}
|
|
438
627
|
|
|
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
|
|
628
|
+
if (!await store.putInitial(initData)) {
|
|
629
|
+
// Lost the race, another writer wrote after us
|
|
446
630
|
// Retry from the top to read their data
|
|
447
631
|
debugLog("Value was changed by another process, retrying");
|
|
448
632
|
continue;
|
|
@@ -456,17 +640,23 @@ async function updateTimeOffset() {
|
|
|
456
640
|
|
|
457
641
|
if (currentTime >= cachedData.updateTime) {
|
|
458
642
|
// Time to rotate
|
|
459
|
-
const
|
|
643
|
+
const newMeasurement = await fetchNewOffset();
|
|
644
|
+
if (!newMeasurement) {
|
|
645
|
+
// Total failure - keep the existing data unrotated (the offset just flattens out at nextOffset). The next check interval retries the rotation.
|
|
646
|
+
cachedTimeOffsetData = cachedData;
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
460
649
|
const newData: TimeOffsetData = {
|
|
461
650
|
lastOffset: cachedData.offset,
|
|
462
651
|
lastUpdateTime: cachedData.updateTime,
|
|
463
652
|
offset: cachedData.nextOffset,
|
|
464
653
|
updateTime: cachedData.nextUpdateTime,
|
|
465
|
-
nextOffset:
|
|
654
|
+
nextOffset: newMeasurement.offset,
|
|
466
655
|
nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
|
|
656
|
+
proof: newMeasurement.proof,
|
|
467
657
|
};
|
|
468
658
|
|
|
469
|
-
const success = await
|
|
659
|
+
const success = await store.putIfVersion(newData, readVersion);
|
|
470
660
|
if (!success) {
|
|
471
661
|
// Lost the race, retry
|
|
472
662
|
continue;
|
|
@@ -479,31 +669,42 @@ async function updateTimeOffset() {
|
|
|
479
669
|
} else {
|
|
480
670
|
cachedTimeOffsetData = cachedData;
|
|
481
671
|
const timeUntilNext = cachedData.updateTime - baseGetTime();
|
|
482
|
-
debugLog("Loaded from
|
|
672
|
+
debugLog("Loaded from store - current:", cachedData.offset, "ms, next:", cachedData.nextOffset, "ms, next update in", timeUntilNext, "ms");
|
|
483
673
|
break;
|
|
484
674
|
}
|
|
485
675
|
}
|
|
486
676
|
|
|
487
|
-
|
|
488
|
-
didFirstTimeSync = true;
|
|
489
|
-
onFirstTimeSync();
|
|
490
|
-
}
|
|
677
|
+
markFirstTimeSync();
|
|
491
678
|
} finally {
|
|
492
679
|
updatingOffset = false;
|
|
493
680
|
}
|
|
494
681
|
}
|
|
495
682
|
|
|
496
|
-
|
|
683
|
+
function triggerUpdateTimeOffset() {
|
|
497
684
|
updateTimeOffset().catch((e) => {
|
|
498
685
|
console.warn("Error updating time offset:", e);
|
|
499
686
|
});
|
|
500
|
-
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
setInterval(triggerUpdateTimeOffset, UPDATE_CHECK_INTERVAL);
|
|
501
690
|
setImmediate(() => {
|
|
502
691
|
updateTimeOffset().catch((e) => {
|
|
503
692
|
console.error("Error updating initial offset:", e);
|
|
504
693
|
});
|
|
505
694
|
});
|
|
506
695
|
|
|
696
|
+
if (!isNode() && typeof window !== "undefined" && typeof document !== "undefined") {
|
|
697
|
+
// If we loaded in a hidden tab we skip measuring, so re-check as soon as we become visible.
|
|
698
|
+
document.addEventListener("visibilitychange", triggerUpdateTimeOffset);
|
|
699
|
+
window.addEventListener("focus", triggerUpdateTimeOffset);
|
|
700
|
+
// Pick up offsets other tabs write, so only one tab ever has to measure.
|
|
701
|
+
window.addEventListener("storage", (e) => {
|
|
702
|
+
if (e.key === TIME_OFFSET_LOCAL_STORAGE_KEY) {
|
|
703
|
+
triggerUpdateTimeOffset();
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
507
708
|
|
|
508
709
|
class TimeControllerBase {
|
|
509
710
|
public async getTrueTime() {
|
|
@@ -530,4 +731,4 @@ const TimeController = SocketFunction.register(
|
|
|
530
731
|
// (just a ping), and don't expose really expose any data.
|
|
531
732
|
// noAutoExpose: true
|
|
532
733
|
}
|
|
533
|
-
);
|
|
734
|
+
);
|