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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "1.1.47",
3
+ "version": "1.1.48",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "dependencies": {
@@ -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;
@@ -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
- // 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.
@@ -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
- async function defaultGetTimeOffset(): Promise<number> {
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 systemTime = baseGetTime();
178
- let predictedServerToClientLatency = (systemTime - sendTime) / 2;
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(offset);
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
- 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
- }
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
- 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);
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
- async function setTimeOffsetInLmdb(
285
- data: TimeOffsetData,
286
- expectedVersion: number
287
- ): Promise<boolean> {
288
- try {
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 (!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;
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
- async function fetchNewOffset(): Promise<number> {
311
- let offsets: number[] = [];
312
- for (let i = 0; i < UPDATE_VERIFY_COUNT; i++) {
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
- offsets.push(await getTimeOffsetBase());
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 (offsets.length === 0) {
321
- // All calls failed, return 0 as fallback
322
- return 0;
441
+ if (measurements.length === 0) {
442
+ return undefined;
323
443
  }
324
444
 
325
445
  // Pick the middle offset
326
- offsets.sort((a, b) => a - b);
327
- let offset = offsets[Math.floor(offsets.length / 2)];
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 offset;
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 db = await getTimeOffsetDb();
350
- if (!db) {
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
- if (!cachedData) {
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 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");
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 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");
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
- if (!didFirstTimeSync) {
390
- didFirstTimeSync = true;
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: Node.js, LMDB enabled and working
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 getTimeOffsetFromLmdb();
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 offset = await fetchNewOffset();
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: currentTime,
572
+ lastUpdateTime: initTime,
420
573
  offset: offset,
421
- updateTime: currentTime + UPDATE_TRANSITION_GAP,
574
+ updateTime: initTime + UPDATE_TRANSITION_GAP,
422
575
  nextOffset: offset,
423
- nextUpdateTime: currentTime + UPDATE_TRANSITION_GAP * 2,
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 setTimeOffsetInLmdb(initData, readVersion);
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
- // 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
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 newOffset = await fetchNewOffset();
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: newOffset,
618
+ nextOffset: newMeasurement.offset,
466
619
  nextUpdateTime: cachedData.nextUpdateTime + UPDATE_TRANSITION_GAP,
620
+ proof: newMeasurement.proof,
467
621
  };
468
622
 
469
- const success = await setTimeOffsetInLmdb(newData, readVersion);
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 LMDB - current:", cachedData.offset, "ms, next:", cachedData.nextOffset, "ms, next update in", timeUntilNext, "ms");
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
- if (!didFirstTimeSync) {
488
- didFirstTimeSync = true;
489
- onFirstTimeSync();
490
- }
641
+ markFirstTimeSync();
491
642
  } finally {
492
643
  updatingOffset = false;
493
644
  }
494
645
  }
495
646
 
496
- setInterval(() => {
647
+ function triggerUpdateTimeOffset() {
497
648
  updateTimeOffset().catch((e) => {
498
649
  console.warn("Error updating time offset:", e);
499
650
  });
500
- }, UPDATE_CHECK_INTERVAL);
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
+ );