@tldraw/sync-core 5.4.0-next.ef99ab47e5ce → 5.5.0-canary.04d1e6c4daff
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/dist-cjs/index.d.ts +17 -13
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/NodeSqliteWrapper.js +1 -1
- package/dist-cjs/lib/NodeSqliteWrapper.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +20 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +30 -3
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js +1 -1
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/chunk.js +10 -1
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-esm/index.d.mts +17 -13
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/NodeSqliteWrapper.mjs +1 -1
- package/dist-esm/lib/NodeSqliteWrapper.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +20 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +30 -3
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs +1 -1
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +10 -1
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/package.json +14 -7
- package/src/lib/NodeSqliteWrapper.test.ts +29 -0
- package/src/lib/NodeSqliteWrapper.ts +4 -1
- package/src/lib/SQLiteSyncStorage.test.ts +60 -1
- package/src/lib/SQLiteSyncStorage.ts +22 -14
- package/src/lib/TLSyncClient.test.ts +64 -6
- package/src/lib/TLSyncClient.ts +38 -2
- package/src/lib/TLSyncRoom.ts +39 -3
- package/src/lib/TLSyncStorage.test.ts +37 -0
- package/src/lib/TLSyncStorage.ts +4 -2
- package/src/lib/chunk.test.ts +34 -0
- package/src/lib/chunk.ts +20 -1
- package/src/test/TLSyncRoom.test.ts +105 -0
|
@@ -272,5 +272,34 @@ describe('NodeSqliteWrapper', () => {
|
|
|
272
272
|
// the write was rolled back
|
|
273
273
|
expect(wrapper.prepare('SELECT * FROM test').all()).toEqual([])
|
|
274
274
|
})
|
|
275
|
+
|
|
276
|
+
it('[NW2] rolls back when COMMIT itself fails, so the connection is not left inside a transaction', () => {
|
|
277
|
+
// simulate a COMMIT failure (SQLITE_BUSY, I/O error) at the driver level
|
|
278
|
+
const failingDb = {
|
|
279
|
+
exec: (sql: string) => {
|
|
280
|
+
if (sql === 'COMMIT') throw new Error('SQLITE_BUSY: database is locked')
|
|
281
|
+
return db.exec(sql)
|
|
282
|
+
},
|
|
283
|
+
prepare: (sql: string) => db.prepare(sql),
|
|
284
|
+
}
|
|
285
|
+
const failingWrapper = new NodeSqliteWrapper(failingDb as any)
|
|
286
|
+
|
|
287
|
+
expect(() =>
|
|
288
|
+
failingWrapper.transaction(() => {
|
|
289
|
+
failingWrapper
|
|
290
|
+
.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
|
|
291
|
+
.run(1, 'alice', 100)
|
|
292
|
+
})
|
|
293
|
+
).toThrow('SQLITE_BUSY')
|
|
294
|
+
|
|
295
|
+
// rolled back, and the next transaction can BEGIN normally
|
|
296
|
+
expect(wrapper.prepare('SELECT * FROM test').all()).toEqual([])
|
|
297
|
+
expect(() =>
|
|
298
|
+
wrapper.transaction(() => {
|
|
299
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
|
|
300
|
+
})
|
|
301
|
+
).not.toThrow()
|
|
302
|
+
expect(wrapper.prepare<{ id: number }>('SELECT id FROM test').all()).toEqual([{ id: 2 }])
|
|
303
|
+
})
|
|
275
304
|
})
|
|
276
305
|
})
|
|
@@ -89,11 +89,14 @@ export class NodeSqliteWrapper implements TLSyncSqliteWrapper {
|
|
|
89
89
|
let result: T
|
|
90
90
|
try {
|
|
91
91
|
result = callback()
|
|
92
|
+
// COMMIT itself can fail (SQLITE_BUSY from another connection, I/O errors); without a
|
|
93
|
+
// ROLLBACK the connection would stay inside the transaction and every later BEGIN
|
|
94
|
+
// would throw "cannot start a transaction within a transaction"
|
|
95
|
+
this.db.exec('COMMIT')
|
|
92
96
|
} catch (e) {
|
|
93
97
|
this.db.exec('ROLLBACK')
|
|
94
98
|
throw e
|
|
95
99
|
}
|
|
96
|
-
this.db.exec('COMMIT')
|
|
97
100
|
return result
|
|
98
101
|
}
|
|
99
102
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite'
|
|
2
2
|
import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
|
|
3
3
|
import { ZERO_INDEX_KEY } from '@tldraw/utils'
|
|
4
|
-
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import {
|
|
6
6
|
contractRecords,
|
|
7
7
|
contractSchema,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
makePage,
|
|
10
10
|
registerStorageContractTests,
|
|
11
11
|
} from '../test/storageContractSuite'
|
|
12
|
+
import { MAX_TOMBSTONES } from './InMemorySyncStorage'
|
|
12
13
|
import { NodeSqliteWrapper } from './NodeSqliteWrapper'
|
|
13
14
|
import { SQLiteSyncStorage } from './SQLiteSyncStorage'
|
|
14
15
|
|
|
@@ -263,4 +264,62 @@ describe('SQLiteSyncStorage', () => {
|
|
|
263
264
|
expect(row?.migrationVersion).toBe(3)
|
|
264
265
|
})
|
|
265
266
|
})
|
|
267
|
+
|
|
268
|
+
describe('tombstone pruning robustness', () => {
|
|
269
|
+
const makeTombstones = (count: number) => {
|
|
270
|
+
const tombstones: Record<string, number> = {}
|
|
271
|
+
for (let i = 0; i < count; i++) tombstones[`shape:doc${i}`] = i + 1
|
|
272
|
+
return tombstones
|
|
273
|
+
}
|
|
274
|
+
function makeOverfullStorage() {
|
|
275
|
+
const db = new DatabaseSync(':memory:')
|
|
276
|
+
const storage = new SQLiteSyncStorage<TLRecord>({
|
|
277
|
+
sql: new NodeSqliteWrapper(db),
|
|
278
|
+
snapshot: makeContractSnapshot(contractRecords, {
|
|
279
|
+
tombstones: makeTombstones(MAX_TOMBSTONES + 500),
|
|
280
|
+
documentClock: MAX_TOMBSTONES + 501,
|
|
281
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
282
|
+
}),
|
|
283
|
+
})
|
|
284
|
+
return { db, storage, prune: (storage as any).pruneTombstones as { (): void; flush(): void } }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
it('[SS18] a prune that fires after the database was closed does not throw', () => {
|
|
288
|
+
const { db, storage, prune } = makeOverfullStorage()
|
|
289
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
290
|
+
try {
|
|
291
|
+
// a delete schedules the throttled prune; the host then closes everything
|
|
292
|
+
storage.transaction((txn) => txn.delete(contractRecords[1].id))
|
|
293
|
+
db.close()
|
|
294
|
+
// the throttle timer fires later, from outside any caller that could catch it
|
|
295
|
+
expect(() => prune.flush()).not.toThrow()
|
|
296
|
+
expect(consoleSpy).toHaveBeenCalledWith('Failed to prune tombstones', expect.anything())
|
|
297
|
+
} finally {
|
|
298
|
+
consoleSpy.mockRestore()
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('[SS18] a prune whose delete fails leaves the history clock where it was', () => {
|
|
303
|
+
const { storage, prune } = makeOverfullStorage()
|
|
304
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
305
|
+
try {
|
|
306
|
+
const stmts = (storage as any).stmts
|
|
307
|
+
const originalRun = stmts.deleteTombstonesBefore.run
|
|
308
|
+
stmts.deleteTombstonesBefore.run = () => {
|
|
309
|
+
throw new Error('SQLITE_BUSY: database is locked')
|
|
310
|
+
}
|
|
311
|
+
prune()
|
|
312
|
+
expect(() => prune.flush()).not.toThrow()
|
|
313
|
+
stmts.deleteTombstonesBefore.run = originalRun
|
|
314
|
+
|
|
315
|
+
// neither half of the prune applied: the history start was not advanced past
|
|
316
|
+
// tombstones that still exist
|
|
317
|
+
const snapshot = storage.getSnapshot()
|
|
318
|
+
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(0)
|
|
319
|
+
expect(Object.keys(snapshot.tombstones!).length).toBe(MAX_TOMBSTONES + 500)
|
|
320
|
+
} finally {
|
|
321
|
+
consoleSpy.mockRestore()
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
})
|
|
266
325
|
})
|
|
@@ -394,7 +394,6 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
|
|
|
394
394
|
const documentClock = snapshot.documentClock ?? snapshot.clock ?? 0
|
|
395
395
|
const tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? documentClock
|
|
396
396
|
|
|
397
|
-
// Clear existing data
|
|
398
397
|
this.sql.exec(`
|
|
399
398
|
DELETE FROM ${documentsTable};
|
|
400
399
|
DELETE FROM ${objectsTable};
|
|
@@ -410,14 +409,12 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
|
|
|
410
409
|
table.insert.run(doc.state.id, encodeState(doc.state), doc.lastChangedClock)
|
|
411
410
|
}
|
|
412
411
|
|
|
413
|
-
// Insert tombstones
|
|
414
412
|
if (snapshot.tombstones) {
|
|
415
413
|
for (const [id, clock] of objectMapEntries(snapshot.tombstones)) {
|
|
416
414
|
this.stmts.insertTombstone.run(id, clock)
|
|
417
415
|
}
|
|
418
416
|
}
|
|
419
417
|
|
|
420
|
-
// Insert metadata row
|
|
421
418
|
this.stmts.updateMetadata.run(
|
|
422
419
|
documentClock,
|
|
423
420
|
tombstoneHistoryStartsAtClock,
|
|
@@ -513,18 +510,29 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
|
|
|
513
510
|
/** @internal */
|
|
514
511
|
pruneTombstones = throttle(
|
|
515
512
|
() => {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
this.stmts.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
513
|
+
// Runs from a timer, so a host that closed the database right after the last delete
|
|
514
|
+
// (e.g. `room.close(); db.close()` in onSessionRemoved) would otherwise get an uncaught
|
|
515
|
+
// throw from here; pruning is best-effort and simply runs again on the next delete.
|
|
516
|
+
try {
|
|
517
|
+
const tombstoneCount = this.stmts.countTombstones.all()[0].count as number
|
|
518
|
+
if (tombstoneCount > MAX_TOMBSTONES) {
|
|
519
|
+
// Get all tombstones sorted by clock ascending (oldest first)
|
|
520
|
+
const tombstones = this.stmts.iterateTombstones.all()
|
|
521
|
+
|
|
522
|
+
const result = computeTombstonePruning({ tombstones, documentClock: this.getClock() })
|
|
523
|
+
if (result) {
|
|
524
|
+
this.sql.transaction(() => {
|
|
525
|
+
this.stmts.setTombstoneHistoryStartsAtClock.run(
|
|
526
|
+
result.newTombstoneHistoryStartsAtClock
|
|
527
|
+
)
|
|
528
|
+
// Delete all tombstones with clock < newTombstoneHistoryStartsAtClock in one operation.
|
|
529
|
+
// This works because computeTombstonePruning ensures we never split a clock value.
|
|
530
|
+
this.stmts.deleteTombstonesBefore.run(result.newTombstoneHistoryStartsAtClock)
|
|
531
|
+
})
|
|
532
|
+
}
|
|
527
533
|
}
|
|
534
|
+
} catch (e) {
|
|
535
|
+
console.error('Failed to prune tombstones', e)
|
|
528
536
|
}
|
|
529
537
|
},
|
|
530
538
|
1000,
|
|
@@ -598,19 +598,55 @@ describe('TLSyncClient', () => {
|
|
|
598
598
|
expect(socket.getSentMessages().filter((m) => m.type === 'ping')).toHaveLength(2)
|
|
599
599
|
})
|
|
600
600
|
|
|
601
|
-
it('[CL9]
|
|
601
|
+
it('[CL9][CL14] resets when pings keep being sent but nothing comes back (half-open socket)', () => {
|
|
602
602
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
603
603
|
client = createClient()
|
|
604
604
|
socket.mockServerMessage(createConnectMessage())
|
|
605
605
|
|
|
606
|
-
//
|
|
607
|
-
vi.advanceTimersByTime(
|
|
606
|
+
// pings at 5s/10s go unanswered but the sends still "succeed"
|
|
607
|
+
vi.advanceTimersByTime(10_000)
|
|
608
|
+
// health tick at 10s: the oldest unanswered ping (t=5s) is only 5s old — not yet overdue
|
|
609
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
608
610
|
|
|
611
|
+
vi.advanceTimersByTime(10_000)
|
|
612
|
+
// health tick at 20s: the t=5s ping is 15s old, past PONG_TIMEOUT — evidence of a dead socket
|
|
613
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
609
614
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
610
|
-
expect.stringContaining("Haven't heard from the server
|
|
615
|
+
expect.stringContaining("Haven't heard from the server"),
|
|
616
|
+
expect.objectContaining({ pingOutstandingMs: expect.any(Number) })
|
|
611
617
|
)
|
|
618
|
+
consoleSpy.mockRestore()
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it('[CL14] a pong landing in the same millisecond as the ping still counts as an answer', () => {
|
|
622
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
623
|
+
client = createClient()
|
|
624
|
+
socket.mockServerMessage(createConnectMessage())
|
|
625
|
+
|
|
626
|
+
// three ping/pong rounds where the pong arrives in the same ms as the ping send
|
|
627
|
+
for (let i = 0; i < 3; i++) {
|
|
628
|
+
vi.advanceTimersByTime(5000)
|
|
629
|
+
socket.mockServerMessage({ type: 'pong' })
|
|
630
|
+
}
|
|
631
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
632
|
+
|
|
633
|
+
// then the socket dies: the t=20s ping goes unanswered and must still convict.
|
|
634
|
+
// Guards the >= in the marker-advance rule: with > the same-ms pongs above would
|
|
635
|
+
// freeze the marker and the dead socket would never reset.
|
|
636
|
+
vi.advanceTimersByTime(25_000)
|
|
612
637
|
expect(client.isConnectedToRoom).toBe(false)
|
|
638
|
+
consoleSpy.mockRestore()
|
|
639
|
+
})
|
|
613
640
|
|
|
641
|
+
it('[CL14] the unanswered-ping marker clears on connection reset', () => {
|
|
642
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
643
|
+
client = createClient()
|
|
644
|
+
socket.mockServerMessage(createConnectMessage())
|
|
645
|
+
|
|
646
|
+
// drive a reset via a dead socket
|
|
647
|
+
vi.advanceTimersByTime(20_000)
|
|
648
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
649
|
+
expect((client as any).firstUnansweredPingAt).toBeNull()
|
|
614
650
|
consoleSpy.mockRestore()
|
|
615
651
|
})
|
|
616
652
|
|
|
@@ -626,13 +662,35 @@ describe('TLSyncClient', () => {
|
|
|
626
662
|
vi.advanceTimersByTime(4000)
|
|
627
663
|
expect(client.isConnectedToRoom).toBe(true)
|
|
628
664
|
|
|
629
|
-
// at t=
|
|
630
|
-
vi.advanceTimersByTime(
|
|
665
|
+
// at t=15s the oldest unanswered ping (t=10s) is 5s old — not yet overdue
|
|
666
|
+
vi.advanceTimersByTime(5000)
|
|
667
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
668
|
+
|
|
669
|
+
// at t=20s it is exactly PONG_TIMEOUT old — reset
|
|
670
|
+
vi.advanceTimersByTime(5000)
|
|
631
671
|
expect(client.isConnectedToRoom).toBe(false)
|
|
632
672
|
|
|
633
673
|
consoleSpy.mockRestore()
|
|
634
674
|
})
|
|
635
675
|
|
|
676
|
+
it('[CL9] does not reset a healthy connection when timers wake infrequently (hidden-tab throttling)', () => {
|
|
677
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
678
|
+
client = createClient()
|
|
679
|
+
socket.mockServerMessage(createConnectMessage())
|
|
680
|
+
|
|
681
|
+
// Chrome batches a hidden tab's timers to ~1/min wakes: the clock jumps, then the
|
|
682
|
+
// delayed ping + health ticks fire together, and the pong arrives right after.
|
|
683
|
+
for (let i = 0; i < 3; i++) {
|
|
684
|
+
vi.setSystemTime(Date.now() + 60_000)
|
|
685
|
+
vi.advanceTimersByTime(10_000)
|
|
686
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
687
|
+
socket.mockServerMessage({ type: 'pong' })
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
expect(consoleSpy).not.toHaveBeenCalled()
|
|
691
|
+
consoleSpy.mockRestore()
|
|
692
|
+
})
|
|
693
|
+
|
|
636
694
|
it('[CL11] closes instead of processing the next event when didCancel returns true', () => {
|
|
637
695
|
let cancelled = false
|
|
638
696
|
client = createClient({ didCancel: () => cancelled })
|
package/src/lib/TLSyncClient.ts
CHANGED
|
@@ -286,6 +286,12 @@ export interface TLPersistentClientSocket<
|
|
|
286
286
|
|
|
287
287
|
const PING_INTERVAL = 5000
|
|
288
288
|
const MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING_INTERVAL * 2
|
|
289
|
+
/**
|
|
290
|
+
* How long an unanswered ping may stay outstanding before the connection is considered dead.
|
|
291
|
+
* Tuned together with MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION:
|
|
292
|
+
* a reset needs both stale interaction and a ping overdue by this much.
|
|
293
|
+
*/
|
|
294
|
+
const PONG_TIMEOUT = PING_INTERVAL * 2
|
|
289
295
|
|
|
290
296
|
// Should connect support chunking the response to allow for large payloads?
|
|
291
297
|
|
|
@@ -367,6 +373,11 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
367
373
|
/** The last clock time from the most recent server update */
|
|
368
374
|
private lastServerClock = -1
|
|
369
375
|
private lastServerInteractionTimestamp = Date.now()
|
|
376
|
+
/**
|
|
377
|
+
* Send time of the oldest outstanding ping; answered iff `lastServerInteractionTimestamp >= it`
|
|
378
|
+
* (any server message counts). Null until the first ping after (re)connect.
|
|
379
|
+
*/
|
|
380
|
+
private firstUnansweredPingAt: number | null = null
|
|
370
381
|
|
|
371
382
|
/** The queue of in-flight push requests that have not yet been acknowledged by the server */
|
|
372
383
|
private pendingPushRequests: TLPushRequest<R>[] = []
|
|
@@ -600,6 +611,14 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
600
611
|
this.debug('ping loop', { isConnectedToRoom: this.isConnectedToRoom })
|
|
601
612
|
if (!this.isConnectedToRoom) return
|
|
602
613
|
try {
|
|
614
|
+
// advance the marker only when the previous ping was answered: a half-open socket
|
|
615
|
+
// that accepts sends but returns nothing must not keep refreshing its own alibi
|
|
616
|
+
if (
|
|
617
|
+
this.firstUnansweredPingAt === null ||
|
|
618
|
+
this.lastServerInteractionTimestamp >= this.firstUnansweredPingAt
|
|
619
|
+
) {
|
|
620
|
+
this.firstUnansweredPingAt = Date.now()
|
|
621
|
+
}
|
|
603
622
|
this.socket.sendMessage({ type: 'ping' })
|
|
604
623
|
} catch (error) {
|
|
605
624
|
console.warn('ping failed, resetting', error)
|
|
@@ -618,11 +637,27 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
618
637
|
MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION
|
|
619
638
|
) {
|
|
620
639
|
this.debug('health check passed', { timeSinceLastServerInteraction })
|
|
621
|
-
// last ping was recent, so no need to take any action
|
|
622
640
|
return
|
|
623
641
|
}
|
|
624
642
|
|
|
625
|
-
|
|
643
|
+
// Silence alone is not evidence of a dead socket: in a throttled hidden tab our own ping
|
|
644
|
+
// loop stops, so the server had nothing to answer. Only reset on an unanswered ping.
|
|
645
|
+
const firstUnansweredPingAt = this.firstUnansweredPingAt
|
|
646
|
+
const pingOverdue =
|
|
647
|
+
firstUnansweredPingAt !== null &&
|
|
648
|
+
this.lastServerInteractionTimestamp < firstUnansweredPingAt &&
|
|
649
|
+
Date.now() - firstUnansweredPingAt >= PONG_TIMEOUT
|
|
650
|
+
if (!pingOverdue) {
|
|
651
|
+
this.debug('health check stale but no overdue ping', { timeSinceLastServerInteraction })
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// A frozen tab can deliver a queued pong only after these catch-up ticks run, so one
|
|
656
|
+
// spurious reset on unfreeze is still possible — rare, and the reconnect is clean.
|
|
657
|
+
console.warn(`Haven't heard from the server in a while, resetting connection...`, {
|
|
658
|
+
timeSinceLastServerInteraction,
|
|
659
|
+
pingOutstandingMs: Date.now() - firstUnansweredPingAt!,
|
|
660
|
+
})
|
|
626
661
|
this.resetConnection()
|
|
627
662
|
}, PING_INTERVAL * 2)
|
|
628
663
|
)
|
|
@@ -684,6 +719,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
684
719
|
}
|
|
685
720
|
this.lastPushedPresenceState = null
|
|
686
721
|
this.isConnectedToRoom = false
|
|
722
|
+
this.firstUnansweredPingAt = null
|
|
687
723
|
this.pendingPushRequests = []
|
|
688
724
|
this.incomingDiffBuffer = []
|
|
689
725
|
this.unsentChanges.nextDiff = undefined
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -258,7 +258,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
258
258
|
}, 1000)
|
|
259
259
|
|
|
260
260
|
private scheduleFollowUpPrune() {
|
|
261
|
-
|
|
261
|
+
// don't leave a stray timer on a room the host has already torn down
|
|
262
|
+
if (this._isClosed || this.pruneTimer) return
|
|
262
263
|
this.pruneTimer = setTimeout(this.pruneSessions, SESSION_REMOVAL_WAIT_TIME + 100)
|
|
263
264
|
}
|
|
264
265
|
|
|
@@ -273,11 +274,27 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
273
274
|
* and stops background processes.
|
|
274
275
|
*/
|
|
275
276
|
close() {
|
|
277
|
+
this._isClosed = true
|
|
276
278
|
this.disposables.forEach((d) => d())
|
|
277
279
|
this.sessions.forEach((session) => {
|
|
278
|
-
|
|
280
|
+
this.clearDebounceTimer(session)
|
|
281
|
+
try {
|
|
282
|
+
session.socket.close()
|
|
283
|
+
} catch {
|
|
284
|
+
// noop, one bad socket must not leave the rest open
|
|
285
|
+
}
|
|
279
286
|
})
|
|
280
|
-
|
|
287
|
+
// forgetting the sessions is what makes late socket close/error events no-ops, so
|
|
288
|
+
// nothing can emit session_removed / room_became_empty on a closed room
|
|
289
|
+
this.sessions.clear()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private clearDebounceTimer(session: RoomSession<R, SessionMeta>) {
|
|
293
|
+
if (session.state === RoomSessionState.Connected && session.debounceTimer !== null) {
|
|
294
|
+
clearTimeout(session.debounceTimer)
|
|
295
|
+
session.debounceTimer = null
|
|
296
|
+
session.outstandingDataMessages = []
|
|
297
|
+
}
|
|
281
298
|
}
|
|
282
299
|
|
|
283
300
|
/**
|
|
@@ -519,6 +536,12 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
519
536
|
// place, so sockets that defer serialization don't see an emptied array
|
|
520
537
|
const data = session.outstandingDataMessages
|
|
521
538
|
session.outstandingDataMessages = []
|
|
539
|
+
if (!session.socket.isOpen) {
|
|
540
|
+
// same as the immediate-send path: send() into a closed socket throws on
|
|
541
|
+
// some runtimes (Cloudflare), and here that would be from inside a timer
|
|
542
|
+
this.cancelSession(sessionId)
|
|
543
|
+
return
|
|
544
|
+
}
|
|
522
545
|
session.socket.sendMessage({ type: 'data', data })
|
|
523
546
|
}
|
|
524
547
|
}
|
|
@@ -532,6 +555,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
532
555
|
}
|
|
533
556
|
|
|
534
557
|
this.sessions.delete(sessionId)
|
|
558
|
+
this.clearDebounceTimer(session)
|
|
535
559
|
|
|
536
560
|
try {
|
|
537
561
|
if (fatalReason) {
|
|
@@ -570,6 +594,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
570
594
|
return
|
|
571
595
|
}
|
|
572
596
|
|
|
597
|
+
this.clearDebounceTimer(session)
|
|
573
598
|
this.sessions.set(sessionId, {
|
|
574
599
|
state: RoomSessionState.AwaitingRemoval,
|
|
575
600
|
sessionId,
|
|
@@ -692,7 +717,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
692
717
|
objectAccess?: TLObjectStoreAccess
|
|
693
718
|
}) {
|
|
694
719
|
const { sessionId, socket, meta, isReadonly, objectAccess } = opts
|
|
720
|
+
// a connect racing close() would otherwise create a session nothing ever prunes
|
|
721
|
+
if (this._isClosed) {
|
|
722
|
+
socket.close()
|
|
723
|
+
return this
|
|
724
|
+
}
|
|
695
725
|
const existing = this.sessions.get(sessionId)
|
|
726
|
+
if (existing) this.clearDebounceTimer(existing)
|
|
696
727
|
this.sessions.set(sessionId, {
|
|
697
728
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
698
729
|
sessionId,
|
|
@@ -741,6 +772,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
741
772
|
supportsStringAppend,
|
|
742
773
|
} = opts
|
|
743
774
|
|
|
775
|
+
if (this._isClosed) {
|
|
776
|
+
socket.close()
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
|
|
744
780
|
const migrations = this.schema.getMigrationsSince(serializedSchema)
|
|
745
781
|
const requiresDownMigrations = migrations.ok ? migrations.value.length > 0 : false
|
|
746
782
|
|
|
@@ -122,6 +122,43 @@ describe('loadSnapshotIntoStorage', () => {
|
|
|
122
122
|
expect(storage.getClock()).toBe(5)
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
+
it('[SL3] does not delete while a key iterator is open (better-sqlite3 rejects that)', () => {
|
|
126
|
+
const extraPage = makePage('extra', 'Extra')
|
|
127
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
128
|
+
snapshot: makeContractSnapshot([...contractRecords, extraPage]),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
storage.transaction((txn) => {
|
|
132
|
+
// better-sqlite3 throws on any write while a statement iterator is still open; mimic
|
|
133
|
+
// that so a delete issued from inside `for (const id of txn.keys())` fails loudly
|
|
134
|
+
let openIterators = 0
|
|
135
|
+
const guarded: typeof txn = Object.create(txn, {
|
|
136
|
+
keys: {
|
|
137
|
+
value: function* () {
|
|
138
|
+
openIterators++
|
|
139
|
+
try {
|
|
140
|
+
yield* Array.from(txn.keys())
|
|
141
|
+
} finally {
|
|
142
|
+
openIterators--
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
delete: {
|
|
147
|
+
value: (id: string) => {
|
|
148
|
+
if (openIterators > 0) {
|
|
149
|
+
throw new Error('This database connection is busy executing a query')
|
|
150
|
+
}
|
|
151
|
+
return txn.delete(id)
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
loadSnapshotIntoStorage(guarded, contractSchema, makeContractSnapshot(contractRecords))
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
expect(storage.documents.has(extraPage.id)).toBe(false)
|
|
159
|
+
expect(storage.tombstones.has(extraPage.id)).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
125
162
|
it('[SL4] persists the snapshot schema and migrates loaded records to the current version', () => {
|
|
126
163
|
interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
|
|
127
164
|
value: number
|
package/src/lib/TLSyncStorage.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { StoreSchema, SynchronousStorage, UnknownRecord } from '@tldraw/store'
|
|
2
|
+
import { TLStoreSnapshot } from '@tldraw/tlschema'
|
|
2
3
|
import { assert, isEqual, objectMapEntriesIterable, objectMapValues } from '@tldraw/utils'
|
|
3
|
-
import { TLStoreSnapshot } from 'tldraw'
|
|
4
4
|
import { diffRecord, NetworkDiff, RecordOpType } from './diff'
|
|
5
5
|
import { RoomSnapshot } from './TLSyncRoom'
|
|
6
6
|
|
|
@@ -205,7 +205,9 @@ export function loadSnapshotIntoStorage<R extends UnknownRecord>(
|
|
|
205
205
|
if (isEqual(existing, doc.state)) continue
|
|
206
206
|
txn.set(doc.state.id, doc.state as R)
|
|
207
207
|
}
|
|
208
|
-
|
|
208
|
+
// materialize the keys first: some SQLite drivers (better-sqlite3) refuse writes while a
|
|
209
|
+
// statement iterator is open, so deleting during `keys()` would throw
|
|
210
|
+
for (const id of Array.from(txn.keys())) {
|
|
209
211
|
if (!docIds.has(id)) {
|
|
210
212
|
txn.delete(id)
|
|
211
213
|
}
|
package/src/lib/chunk.test.ts
CHANGED
|
@@ -134,6 +134,40 @@ describe('chunk (CH1–CH3)', () => {
|
|
|
134
134
|
`)
|
|
135
135
|
})
|
|
136
136
|
})
|
|
137
|
+
|
|
138
|
+
describe('surrogate pairs (CH9)', () => {
|
|
139
|
+
it('[CH9] never splits a surrogate pair across two chunks', () => {
|
|
140
|
+
// WebSocket.send converts each chunk to a USVString, so a lone surrogate would arrive
|
|
141
|
+
// as U+FFFD and the reassembled message would carry corrupted text
|
|
142
|
+
const emoji = '\u{1F600}'
|
|
143
|
+
const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff
|
|
144
|
+
const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff
|
|
145
|
+
const msgs = [
|
|
146
|
+
...Array.from(
|
|
147
|
+
{ length: 8 },
|
|
148
|
+
(_, padding) => 'x'.repeat(padding) + emoji + 'y'.repeat(6) + emoji
|
|
149
|
+
),
|
|
150
|
+
// adjacent pairs: after a nudge the next boundary lands on another pair
|
|
151
|
+
'x' + emoji.repeat(3),
|
|
152
|
+
]
|
|
153
|
+
for (const msg of msgs) {
|
|
154
|
+
for (const maxSize of [3, 4, 5, 6, 7, 8]) {
|
|
155
|
+
const chunks = chunk(msg, maxSize)
|
|
156
|
+
const bodies = chunks.map((c) => c.slice(c.indexOf('_') + 1))
|
|
157
|
+
expect(bodies.join('')).toBe(msg)
|
|
158
|
+
for (const [i, body] of bodies.entries()) {
|
|
159
|
+
expect(body.length).toBeGreaterThan(0)
|
|
160
|
+
expect(isHigh(body.charCodeAt(body.length - 1))).toBe(false)
|
|
161
|
+
expect(isLow(body.charCodeAt(0))).toBe(false)
|
|
162
|
+
// keeping a pair whole may cost one character over the CH3 bound, never more
|
|
163
|
+
expect(chunks[i].length).toBeLessThanOrEqual(
|
|
164
|
+
Math.max(maxSize, chunks[i].indexOf('_') + 2) + 1
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
})
|
|
137
171
|
})
|
|
138
172
|
|
|
139
173
|
const testObject = {} as any
|
package/src/lib/chunk.ts
CHANGED
|
@@ -38,7 +38,18 @@ export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
|
|
|
38
38
|
let offset = msg.length
|
|
39
39
|
while (offset > 0) {
|
|
40
40
|
const prefix = `${chunkNumber}_`
|
|
41
|
-
|
|
41
|
+
let chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)
|
|
42
|
+
// Never split a surrogate pair across chunks: WebSocket.send converts each chunk to a
|
|
43
|
+
// USVString, turning a lone surrogate into U+FFFD, so the reassembled JSON silently
|
|
44
|
+
// carries corrupted text. Shrink by one so the pair moves whole into the preceding
|
|
45
|
+
// chunk; a single-character chunk grows by one instead (one over the CH3 bound, see CH9).
|
|
46
|
+
if (
|
|
47
|
+
chunkSize < offset &&
|
|
48
|
+
isLowSurrogate(msg.charCodeAt(offset - chunkSize)) &&
|
|
49
|
+
isHighSurrogate(msg.charCodeAt(offset - chunkSize - 1))
|
|
50
|
+
) {
|
|
51
|
+
chunkSize += chunkSize > 1 ? -1 : 1
|
|
52
|
+
}
|
|
42
53
|
chunks.unshift(prefix + msg.slice(offset - chunkSize, offset))
|
|
43
54
|
offset -= chunkSize
|
|
44
55
|
chunkNumber++
|
|
@@ -47,6 +58,14 @@ export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
|
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
|
|
61
|
+
function isHighSurrogate(code: number) {
|
|
62
|
+
return code >= 0xd800 && code <= 0xdbff
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isLowSurrogate(code: number) {
|
|
66
|
+
return code >= 0xdc00 && code <= 0xdfff
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
// The 's' flag (dotAll) makes '.' match any character including line terminators
|
|
51
70
|
// like U+2028 and U+2029, which are commonly introduced via copy/paste from Word
|
|
52
71
|
const chunkRe = /^(\d+)_(.*)$/s
|