@tldraw/sync-core 5.4.0 → 5.5.0-canary.05a393565877

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.
@@ -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
- const tombstoneCount = this.stmts.countTombstones.all()[0].count as number
517
- if (tombstoneCount > MAX_TOMBSTONES) {
518
- // Get all tombstones sorted by clock ascending (oldest first)
519
- const tombstones = this.stmts.iterateTombstones.all()
520
-
521
- const result = computeTombstonePruning({ tombstones, documentClock: this.getClock() })
522
- if (result) {
523
- this.stmts.setTombstoneHistoryStartsAtClock.run(result.newTombstoneHistoryStartsAtClock)
524
- // Delete all tombstones with clock < newTombstoneHistoryStartsAtClock in one operation.
525
- // This works because computeTombstonePruning ensures we never split a clock value.
526
- this.stmts.deleteTombstonesBefore.run(result.newTombstoneHistoryStartsAtClock)
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,
@@ -258,7 +258,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
258
258
  }, 1000)
259
259
 
260
260
  private scheduleFollowUpPrune() {
261
- if (this.pruneTimer) return
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
- session.socket.close()
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
- this._isClosed = true
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
 
@@ -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
- const chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)
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
@@ -1949,6 +1949,111 @@ describe('25. Messaging and broadcast (RB)', () => {
1949
1949
  expect(socketB.sendMessage).not.toHaveBeenCalled()
1950
1950
  })
1951
1951
 
1952
+ it('[RB3] a debounced flush into a socket that closed meanwhile cancels the session instead of sending', () => {
1953
+ vi.useFakeTimers()
1954
+ const { room, socketB } = setupTwoSessions()
1955
+ const newPage = makePage('page_3', 'v1')
1956
+
1957
+ room.handleMessage('a', {
1958
+ type: 'push',
1959
+ clientClock: 1,
1960
+ diff: { [newPage.id]: ['put', newPage] },
1961
+ } as TLPushRequest<TLRecord>)
1962
+ room.handleMessage('a', {
1963
+ type: 'push',
1964
+ clientClock: 2,
1965
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1966
+ } as TLPushRequest<TLRecord>)
1967
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1968
+
1969
+ // the socket closes while the second message sits in the debounce buffer
1970
+ socketB.isOpen = false
1971
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1972
+
1973
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1974
+ expect(room.sessions.get('b')?.state).toBe(RoomSessionState.AwaitingRemoval)
1975
+ })
1976
+
1977
+ it('[RC7] close() drops pending debounced flushes so nothing is sent into closed sockets afterwards', () => {
1978
+ vi.useFakeTimers()
1979
+ const { room, socketB } = setupTwoSessions()
1980
+ const newPage = makePage('page_3', 'v1')
1981
+
1982
+ room.handleMessage('a', {
1983
+ type: 'push',
1984
+ clientClock: 1,
1985
+ diff: { [newPage.id]: ['put', newPage] },
1986
+ } as TLPushRequest<TLRecord>)
1987
+ room.handleMessage('a', {
1988
+ type: 'push',
1989
+ clientClock: 2,
1990
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1991
+ } as TLPushRequest<TLRecord>)
1992
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1993
+
1994
+ room.close()
1995
+ expect(room.isClosed()).toBe(true)
1996
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1997
+
1998
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1999
+ expect(socketB.close).toHaveBeenCalled()
2000
+ })
2001
+
2002
+ it('[RC7] close() closes every socket even when one of them throws on close', () => {
2003
+ const { room, socketA, socketB } = setupTwoSessions()
2004
+ socketA.close.mockImplementationOnce(() => {
2005
+ throw new Error('already closing')
2006
+ })
2007
+
2008
+ expect(() => room.close()).not.toThrow()
2009
+ expect(room.isClosed()).toBe(true)
2010
+ expect(socketB.close).toHaveBeenCalled()
2011
+ })
2012
+
2013
+ it('[RC7] close() forgets its sessions, and late socket close events emit no lifecycle events', () => {
2014
+ vi.useFakeTimers()
2015
+ const { room, socketA } = setupTwoSessions()
2016
+ const removed = vi.fn()
2017
+ const becameEmpty = vi.fn()
2018
+ room.events.on('session_removed', removed)
2019
+ room.events.on('room_became_empty', becameEmpty)
2020
+
2021
+ // b's socket closes before close(), a's closes after: both must stay silent
2022
+ room.handleClose('b')
2023
+ room.close()
2024
+ expect(room.sessions.size).toBe(0)
2025
+ room.handleClose('a')
2026
+
2027
+ vi.advanceTimersByTime(SESSION_REMOVAL_WAIT_TIME + 2001)
2028
+ expect(removed).not.toHaveBeenCalled()
2029
+ expect(becameEmpty).not.toHaveBeenCalled()
2030
+ expect(socketA.sendMessage).not.toHaveBeenCalled()
2031
+ })
2032
+
2033
+ it('[RC7] sessions added after close() are rejected and their sockets closed', () => {
2034
+ const { room } = makeRoom()
2035
+ room.close()
2036
+
2037
+ const late = makeSocket()
2038
+ room.handleNewSession({ sessionId: 'late', socket: late, meta: undefined, isReadonly: false })
2039
+ expect(late.close).toHaveBeenCalled()
2040
+
2041
+ const resumed = makeSocket()
2042
+ room.handleResumedSession({
2043
+ sessionId: 'resumed',
2044
+ socket: resumed,
2045
+ meta: undefined,
2046
+ isReadonly: false,
2047
+ serializedSchema: room.serializedSchema,
2048
+ presenceId: null,
2049
+ presenceRecord: null,
2050
+ requiresLegacyRejection: false,
2051
+ supportsStringAppend: true,
2052
+ })
2053
+ expect(resumed.close).toHaveBeenCalled()
2054
+ expect(room.sessions.size).toBe(0)
2055
+ })
2056
+
1952
2057
  it('[RB4] a per-session migration failure during broadcast rejects only the affected session', () => {
1953
2058
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1954
2059
  try {