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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "1.1.47",
3
+ "version": "1.1.49",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "dependencies": {
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 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;
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(` smearing: ${prev.fromOffset} -> ${prev.toOffset} (${(prev.fraction * 100).toFixed(2)}%)`);
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(` smearing: ${sample.fromOffset} -> ${sample.toOffset} (${(sample.fraction * 100).toFixed(2)}%)`);
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
  }
@@ -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;
@@ -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
- // 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.
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
- // 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
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
- 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 };
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
- async function defaultGetTimeOffset(): Promise<number> {
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 systemTime = baseGetTime();
178
- let predictedServerToClientLatency = (systemTime - sendTime) / 2;
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(offset);
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
- async function getTimeOffsetFromLmdb(): Promise<{
249
- data: TimeOffsetData;
250
- version: number;
251
- } | undefined> {
252
- if (!isNode() || !USE_LMDB_PROCESS_SYNC) {
253
- // Skip LMDB for browsers or if disabled
254
- return undefined;
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
- const db = await getTimeOffsetDb();
259
- if (!db) return undefined;
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
- async function setTimeOffsetInLmdb(
285
- data: TimeOffsetData,
286
- expectedVersion: number
287
- ): Promise<boolean> {
288
- try {
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 (!db) return false;
291
-
292
- // Atomic conditional write - only succeeds if version matches expectedVersion
293
- // Use random version to minimize collision probability on retries
294
- const newVersion = Math.random();
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
- async function fetchNewOffset(): Promise<number> {
311
- let offsets: number[] = [];
312
- for (let i = 0; i < UPDATE_VERIFY_COUNT; i++) {
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
- offsets.push(await getTimeOffsetBase());
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 (offsets.length === 0) {
321
- // All calls failed, return 0 as fallback
322
- return 0;
477
+ if (measurements.length === 0) {
478
+ return undefined;
323
479
  }
324
480
 
325
481
  // Pick the middle offset
326
- offsets.sort((a, b) => a - b);
327
- let offset = offsets[Math.floor(offsets.length / 2)];
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 offset;
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 db = await getTimeOffsetDb();
350
- if (!db) {
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
- if (!cachedData) {
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 offset = await fetchNewOffset();
365
- cachedTimeOffsetData = {
366
- lastOffset: offset,
367
- lastUpdateTime: currentTime,
368
- offset: offset,
369
- updateTime: currentTime + UPDATE_TRANSITION_GAP,
370
- nextOffset: offset,
371
- nextUpdateTime: currentTime + UPDATE_TRANSITION_GAP * 2,
372
- };
373
- debugLog("Initialized - time offset:", offset, "ms, next update in", UPDATE_TRANSITION_GAP, "ms");
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 newOffset = await fetchNewOffset();
377
- cachedTimeOffsetData = {
378
- lastOffset: cachedData.offset,
379
- lastUpdateTime: cachedData.updateTime,
380
- offset: cachedData.nextOffset,
381
- updateTime: cachedData.nextUpdateTime,
382
- nextOffset: newOffset,
383
- nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
384
- };
385
- const timeUntilNext = cachedTimeOffsetData.nextUpdateTime - baseGetTime();
386
- debugLog("Advancing time offset - current:", cachedTimeOffsetData.offset, "ms, next:", cachedTimeOffsetData.nextOffset, "ms, next update in", timeUntilNext, "ms");
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
- if (!didFirstTimeSync) {
390
- didFirstTimeSync = true;
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: Node.js, LMDB enabled and working
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 getTimeOffsetFromLmdb();
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 offset = await fetchNewOffset();
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: currentTime,
608
+ lastUpdateTime: initTime,
420
609
  offset: offset,
421
- updateTime: currentTime + UPDATE_TRANSITION_GAP,
610
+ updateTime: initTime + UPDATE_TRANSITION_GAP,
422
611
  nextOffset: offset,
423
- nextUpdateTime: currentTime + UPDATE_TRANSITION_GAP * 2,
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 setTimeOffsetInLmdb(initData, readVersion);
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
- // Try to write our data
440
- await db.put("timeOffset", initData, newVersion);
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 newOffset = await fetchNewOffset();
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: newOffset,
654
+ nextOffset: newMeasurement.offset,
466
655
  nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
656
+ proof: newMeasurement.proof,
467
657
  };
468
658
 
469
- const success = await setTimeOffsetInLmdb(newData, readVersion);
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 LMDB - current:", cachedData.offset, "ms, next:", cachedData.nextOffset, "ms, next update in", timeUntilNext, "ms");
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
- if (!didFirstTimeSync) {
488
- didFirstTimeSync = true;
489
- onFirstTimeSync();
490
- }
677
+ markFirstTimeSync();
491
678
  } finally {
492
679
  updatingOffset = false;
493
680
  }
494
681
  }
495
682
 
496
- setInterval(() => {
683
+ function triggerUpdateTimeOffset() {
497
684
  updateTimeOffset().catch((e) => {
498
685
  console.warn("Error updating time offset:", e);
499
686
  });
500
- }, UPDATE_CHECK_INTERVAL);
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
+ );