@tldraw/sync-core 5.5.0-canary.fa8dc460ac73 → 5.5.0-canary.fdb2d37fcb2a

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.
@@ -1,4 +1,4 @@
1
- import { warnOnce } from '@tldraw/utils'
1
+ import { promiseWithResolve, warnOnce } from '@tldraw/utils'
2
2
  import { TLRecord, sleep } from 'tldraw'
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
 
@@ -73,24 +73,38 @@ describe('ClientWebSocketAdapter', () => {
73
73
  connectedServerSocket = socket
74
74
  })
75
75
 
76
+ // Bound per test on an OS-assigned port. A fixed port raced against the previous test's
77
+ // server, which closes asynchronously, and intermittently failed with EADDRINUSE.
78
+ let port: number
79
+
76
80
  let consoleWarnSpy: ReturnType<typeof vi.spyOn>
77
- beforeEach(() => {
81
+ beforeEach(async () => {
78
82
  consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
79
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
80
- wsServer = new WebSocketServer({ port: 2233 })
83
+ wsServer = new WebSocketServer({ port: 0 })
84
+ // Attach before the adapter exists: the server is already listening by the time the
85
+ // adapter is constructed, so a handler added afterwards can miss the first connection.
81
86
  wsServer.on('connection', connectMock as any)
87
+ await new Promise<void>((resolve, reject) => {
88
+ wsServer.once('listening', resolve)
89
+ wsServer.once('error', reject)
90
+ })
91
+ port = (wsServer.address() as { port: number }).port
92
+ adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
82
93
  })
83
94
 
84
95
  afterEach(() => {
85
96
  consoleWarnSpy.mockRestore()
86
97
  adapter.close()
98
+ // Not awaited: the next test binds its own OS-assigned port, so a server still closing
99
+ // here can't collide with it. Awaiting would deliver in-flight messages to the socket
100
+ // adapter.close() just orphaned.
87
101
  wsServer.close()
88
102
  connectMock.mockClear()
89
103
  })
90
104
 
91
105
  describe('connection and initial state (CW1, CW2)', () => {
92
106
  it('[CW2] starts with connectionStatus offline while the internal status is initial', () => {
93
- const newAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
107
+ const newAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
94
108
  try {
95
109
  expect(newAdapter._connectionStatus.get()).toBe('initial')
96
110
  expect(newAdapter.connectionStatus).toBe('offline')
@@ -113,7 +127,7 @@ describe('ClientWebSocketAdapter', () => {
113
127
  let uriCallCount = 0
114
128
  const dynamicAdapter = new ClientWebSocketAdapter(() => {
115
129
  uriCallCount++
116
- return `ws://localhost:2233?attempt=${uriCallCount}`
130
+ return `ws://localhost:${port}?attempt=${uriCallCount}`
117
131
  })
118
132
  try {
119
133
  await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
@@ -143,7 +157,7 @@ describe('ClientWebSocketAdapter', () => {
143
157
  // no socket can be created until the promise resolves
144
158
  expect(asyncAdapter._ws).toBeNull()
145
159
 
146
- resolveUri!('ws://localhost:2233')
160
+ resolveUri!(`ws://localhost:${port}`)
147
161
 
148
162
  await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN)
149
163
  expect(asyncAdapter.connectionStatus).toBe('online')
@@ -301,7 +315,7 @@ describe('ClientWebSocketAdapter', () => {
301
315
 
302
316
  describe('URI conversion (CW1)', () => {
303
317
  it('[CW1] converts http URIs to ws and connects', async () => {
304
- const httpAdapter = new ClientWebSocketAdapter(() => 'http://localhost:2233')
318
+ const httpAdapter = new ClientWebSocketAdapter(() => `http://localhost:${port}`)
305
319
  try {
306
320
  await waitFor(() => httpAdapter._ws?.readyState === WebSocket.OPEN)
307
321
  expect(httpAdapter._ws!.url).toMatch(/^ws:\/\//)
@@ -312,7 +326,7 @@ describe('ClientWebSocketAdapter', () => {
312
326
  })
313
327
 
314
328
  it('[CW1] converts https URIs to wss', async () => {
315
- const httpsAdapter = new ClientWebSocketAdapter(() => 'https://localhost:2233')
329
+ const httpsAdapter = new ClientWebSocketAdapter(() => `https://localhost:${port}`)
316
330
  try {
317
331
  await waitFor(() => httpsAdapter._ws !== null)
318
332
  expect(httpsAdapter._ws!.url).toMatch(/^wss:\/\//)
@@ -527,6 +541,46 @@ describe('ClientWebSocketAdapter', () => {
527
541
  adapter.close()
528
542
  expect(() => adapter.close()).not.toThrow()
529
543
  })
544
+
545
+ it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
546
+ const getUri = vi.fn(() => `ws://localhost:${port}`)
547
+ const testAdapter = new ClientWebSocketAdapter(getUri)
548
+ testAdapter.close()
549
+ const callsAtClose = getUri.mock.calls.length
550
+
551
+ await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
552
+
553
+ expect(getUri).toHaveBeenCalledTimes(callsAtClose)
554
+ expect(testAdapter._ws).toBeNull()
555
+ })
556
+
557
+ it('[CW9][RM5] close reports offline without notifying listeners or reconnecting', async () => {
558
+ let uriCallCount = 0
559
+ const testAdapter = new ClientWebSocketAdapter(() => {
560
+ uriCallCount++
561
+ return `ws://localhost:${port}`
562
+ })
563
+ const onStatusChange = vi.fn()
564
+ testAdapter.onStatusChange(onStatusChange)
565
+ await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
566
+ const callsBeforeClose = uriCallCount
567
+ onStatusChange.mockClear()
568
+
569
+ testAdapter.close()
570
+ expect(testAdapter.connectionStatus).toBe('offline')
571
+ // let the socket's own close event land, then run out any (leaked) reconnect timer
572
+ vi.useRealTimers()
573
+ await sleep(50)
574
+ vi.useFakeTimers()
575
+ vi.advanceTimersByTime(INACTIVE_MAX_DELAY)
576
+ vi.useRealTimers()
577
+ await sleep(20)
578
+ vi.useFakeTimers()
579
+
580
+ expect(onStatusChange).not.toHaveBeenCalled()
581
+ expect(uriCallCount).toBe(callsBeforeClose)
582
+ expect(testAdapter._ws).toBeNull()
583
+ })
530
584
  })
531
585
 
532
586
  describe('orphaned sockets (CW10)', () => {
@@ -566,17 +620,26 @@ describe('ReconnectManager', () => {
566
620
  connectedServerSocket = socket
567
621
  })
568
622
 
623
+ // See the note on the other suite's port: bound per test on an OS-assigned port.
624
+ let port: number
625
+
569
626
  let consoleWarnSpy: ReturnType<typeof vi.spyOn>
570
- beforeEach(() => {
627
+ beforeEach(async () => {
571
628
  consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
572
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
573
- wsServer = new WebSocketServer({ port: 2234 })
629
+ wsServer = new WebSocketServer({ port: 0 })
574
630
  wsServer.on('connection', connectMock as any)
631
+ await new Promise<void>((resolve, reject) => {
632
+ wsServer.once('listening', resolve)
633
+ wsServer.once('error', reject)
634
+ })
635
+ port = (wsServer.address() as { port: number }).port
636
+ adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
575
637
  })
576
638
 
577
639
  afterEach(() => {
578
640
  consoleWarnSpy.mockRestore()
579
641
  adapter.close()
642
+ // Not awaited - see the note on the other suite's teardown.
580
643
  wsServer.close()
581
644
  connectMock.mockClear()
582
645
  })
@@ -591,7 +654,7 @@ describe('ReconnectManager', () => {
591
654
  })
592
655
 
593
656
  it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
594
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
657
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
595
658
  try {
596
659
  const manager = testAdapter._reconnectManager
597
660
  // make every disconnected() call take the "attempt now" branch
@@ -616,7 +679,7 @@ describe('ReconnectManager', () => {
616
679
 
617
680
  it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
618
681
  const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
619
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
682
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
620
683
  try {
621
684
  const manager = testAdapter._reconnectManager
622
685
  ;(manager as any).lastAttemptStart = Date.now() - 1_000_000
@@ -710,7 +773,7 @@ describe('ReconnectManager', () => {
710
773
  // it's necessary to close the socket, as otherwise the websocket might stay half-open
711
774
  connectedServerSocket.close()
712
775
  wsServer.close()
713
- await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
776
+ await waitFor(() => adapter.connectionStatus === 'offline')
714
777
  expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
715
778
 
716
779
  hiddenMock.mockReturnValue(false)
@@ -776,8 +839,34 @@ describe('ReconnectManager', () => {
776
839
  expect(adapter._ws).toBeNull()
777
840
  })
778
841
 
842
+ it.each(['throws', 'rejects'])(
843
+ '[RM1][CW1] getUri that %s is retried on the backoff instead of stranding the connection',
844
+ async (failure) => {
845
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
846
+ let attempts = 0
847
+ const testAdapter = new ClientWebSocketAdapter(() => {
848
+ attempts++
849
+ if (attempts < 3) {
850
+ const error = new Error('token endpoint unavailable')
851
+ if (failure === 'throws') throw error
852
+ return Promise.reject(error)
853
+ }
854
+ return `ws://localhost:${port}`
855
+ })
856
+ try {
857
+ await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
858
+ expect(attempts).toBe(3)
859
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(2)
860
+ expect(testAdapter.connectionStatus).toBe('online')
861
+ } finally {
862
+ testAdapter.close()
863
+ consoleErrorSpy.mockRestore()
864
+ }
865
+ }
866
+ )
867
+
779
868
  it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
780
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
869
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
781
870
  const manager = testAdapter._reconnectManager
782
871
 
783
872
  testAdapter.close()
@@ -794,3 +883,61 @@ describe('ReconnectManager', () => {
794
883
  expect(() => manager.close()).not.toThrow()
795
884
  })
796
885
  })
886
+
887
+ describe('URI failure boundaries', () => {
888
+ // These tests never open a real socket - the adapter is closed first, or the socket is a
889
+ // mock - so the URI only has to parse. No server, hence no port to bind.
890
+ const UNUSED_URI = 'ws://localhost:1'
891
+
892
+ it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
893
+ const uri = promiseWithResolve<string>()
894
+ const getUri = vi.fn(() => uri)
895
+ const testAdapter = new ClientWebSocketAdapter(getUri)
896
+ const onStatusChange = vi.fn()
897
+ testAdapter.onStatusChange(onStatusChange)
898
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
899
+ try {
900
+ await Promise.resolve()
901
+ testAdapter.close()
902
+ if (outcome === 'resolves') uri.resolve(UNUSED_URI)
903
+ else uri.reject(new Error('token endpoint unavailable'))
904
+ await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
905
+
906
+ expect(getUri).toHaveBeenCalledTimes(1)
907
+ expect(testAdapter._ws).toBeNull()
908
+ expect(testAdapter.connectionStatus).toBe('offline')
909
+ expect(onStatusChange).not.toHaveBeenCalled()
910
+ expect(consoleError).not.toHaveBeenCalled()
911
+ } finally {
912
+ testAdapter.close()
913
+ consoleError.mockRestore()
914
+ }
915
+ })
916
+
917
+ it.each([
918
+ { uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
919
+ {
920
+ uri: UNUSED_URI,
921
+ openSocket: true,
922
+ error: 'There should be no connection attempts while already connected',
923
+ },
924
+ ])('does not treat $error as a getUri failure', async ({ uri, openSocket, error }) => {
925
+ let nextUri: string | Promise<string> = new Promise(() => {})
926
+ const testAdapter = new ClientWebSocketAdapter(() => nextUri)
927
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
928
+ try {
929
+ await Promise.resolve()
930
+ nextUri = uri
931
+ if (openSocket) testAdapter._ws = mockSocket(WebSocket.OPEN)
932
+
933
+ const manager = testAdapter._reconnectManager as unknown as {
934
+ scheduleAttempt(): Promise<void>
935
+ }
936
+ await expect(manager.scheduleAttempt()).rejects.toThrow(error)
937
+ expect(consoleError).not.toHaveBeenCalled()
938
+ } finally {
939
+ testAdapter.close()
940
+ consoleError.mockRestore()
941
+ }
942
+ })
943
+ })
@@ -93,8 +93,13 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
93
93
  close() {
94
94
  this.isDisposed = true
95
95
  this._reconnectManager.close()
96
+ // orphan the socket before closing it (as _closeSocket does) so its onclose can't run
97
+ // _handleDisconnect after disposal — that would notify listeners and schedule a reconnect
98
+ const ws = this._ws
99
+ this._ws = null
96
100
  // WebSocket.close() is idempotent
97
- this._ws?.close()
101
+ ws?.close()
102
+ this._connectionStatus.set('offline')
98
103
  }
99
104
 
100
105
  /**
@@ -240,8 +245,6 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
240
245
  this._handleDisconnect('manual')
241
246
  }
242
247
 
243
- // TLPersistentClientSocket stuff
244
-
245
248
  _connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom(
246
249
  'websocket connection status',
247
250
  'initial'
@@ -513,21 +516,33 @@ export class ReconnectManager {
513
516
  }
514
517
  }
515
518
 
516
- private scheduleAttempt() {
519
+ private async scheduleAttempt() {
517
520
  assert(this.state === 'pendingAttempt')
521
+ if (this.isDisposed) return
518
522
  debug('scheduling a connection attempt')
519
- Promise.resolve(this.getUri()).then((uri) => {
520
- // this can happen if the promise gets resolved too late
523
+
524
+ let uri: string
525
+ try {
526
+ uri = await this.getUri()
527
+ } catch (error) {
528
+ // A failed auth-token fetch must retry instead of leaving us with no socket or timer.
521
529
  if (this.state !== 'pendingAttempt' || this.isDisposed) return
522
- assert(
523
- this.socketAdapter._ws?.readyState !== WebSocket.OPEN,
524
- 'There should be no connection attempts while already connected'
525
- )
530
+ console.error('Failed to get the websocket URI, retrying', error)
531
+ this.state = 'delay'
532
+ this.reconnectTimeout = setTimeout(() => this.disconnected(), this.intendedDelay)
533
+ return
534
+ }
526
535
 
527
- this.lastAttemptStart = Date.now()
528
- this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
529
- this.state = 'pendingAttemptResult'
530
- })
536
+ // this can happen if the promise gets resolved too late
537
+ if (this.state !== 'pendingAttempt' || this.isDisposed) return
538
+ assert(
539
+ this.socketAdapter._ws?.readyState !== WebSocket.OPEN,
540
+ 'There should be no connection attempts while already connected'
541
+ )
542
+
543
+ this.lastAttemptStart = Date.now()
544
+ this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
545
+ this.state = 'pendingAttemptResult'
531
546
  }
532
547
 
533
548
  private getMaxDelay() {
@@ -641,6 +656,7 @@ export class ReconnectManager {
641
656
  debug('ReconnectManager.disconnected')
642
657
  // This either means we're freshly disconnected, or the last connection attempt failed;
643
658
  // either way, time to try again.
659
+ if (this.isDisposed) return
644
660
 
645
661
  // Guard against delayed notifications and recheck synchronously
646
662
  if (
@@ -273,13 +273,16 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
273
273
  })
274
274
  )
275
275
  }
276
+ // The default logger belongs to both layers: TLSyncRoom is where authorizer throws are
277
+ // logged, and a host that passes no `log` would otherwise never see them.
278
+ this.log = 'log' in opts ? opts.log : { error: console.error }
276
279
  this.room = new TLSyncRoom<R, SessionMeta>({
277
280
  onPresenceChange: opts.onPresenceChange,
278
281
  onCommittedChanges: opts.onCommittedChanges,
279
282
  objectTypes: opts.objectTypes,
280
283
  authorizeRecord: opts.authorizeRecord,
281
284
  schema: opts.schema ?? (createTLSchema() as any),
282
- log: opts.log,
285
+ log: this.log,
283
286
  storage,
284
287
  clientTimeout: opts.clientTimeout,
285
288
  })
@@ -295,7 +298,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
295
298
  })
296
299
  }
297
300
  })
298
- this.log = 'log' in opts ? opts.log : { error: console.error }
299
301
  }
300
302
 
301
303
  /**
@@ -854,12 +856,11 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
854
856
  if (this.isClosed()) {
855
857
  throw new Error('Cannot update store on a closed room')
856
858
  }
859
+ // read through the transaction rather than getSnapshot(): the document snapshot excludes the
860
+ // object-store lane, which would make object-lane records invisible (and undeletable) here
861
+ const records = this.storage.transaction((txn) => Object.fromEntries(txn.entries())).result
857
862
  // eslint-disable-next-line @typescript-eslint/no-deprecated
858
- const ctx = new StoreUpdateContext<R>(
859
- // eslint-disable-next-line @typescript-eslint/no-deprecated
860
- Object.fromEntries(this.getCurrentSnapshot().documents.map((d) => [d.state.id, d.state])),
861
- this.room.schema
862
- )
863
+ const ctx = new StoreUpdateContext<R>(records, this.room.schema)
863
864
  try {
864
865
  await updater(ctx)
865
866
  } finally {
@@ -210,6 +210,45 @@ describe('28. TLSocketRoom (SR)', () => {
210
210
  consoleSpy.mockRestore()
211
211
  })
212
212
 
213
+ it('[SR3] the default logger also reaches TLSyncRoom, so authorizer failures are reported', () => {
214
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
215
+ try {
216
+ const room = new TLSocketRoom({
217
+ authorizeRecord: {
218
+ page: () => {
219
+ throw new Error('authorizer exploded')
220
+ },
221
+ },
222
+ })
223
+ const socket = createMockSocket()
224
+ connectSession(room, 'test-session', socket)
225
+
226
+ const pageId = PageRecordType.createId('new-page')
227
+ room.handleSocketMessage(
228
+ 'test-session',
229
+ JSON.stringify({
230
+ type: 'push',
231
+ clientClock: 1,
232
+ diff: {
233
+ [pageId]: [
234
+ RecordOpType.Put,
235
+ PageRecordType.create({ id: pageId, name: 'New Page', index: 'a2' as any }),
236
+ ],
237
+ },
238
+ })
239
+ )
240
+
241
+ // the authorizer threw, and the host heard about it without passing a logger
242
+ expect(room.getRecord(pageId)).toBeUndefined()
243
+ expect(consoleSpy).toHaveBeenCalledWith(
244
+ 'record authorizer threw; rejecting the write',
245
+ expect.objectContaining({ message: 'authorizer exploded' })
246
+ )
247
+ } finally {
248
+ consoleSpy.mockRestore()
249
+ }
250
+ })
251
+
213
252
  it('[SR3] uses custom logger when provided', () => {
214
253
  const mockLog: TLSyncLog = {
215
254
  warn: vi.fn(),
@@ -8,6 +8,7 @@ import {
8
8
  TLSocketServerSentEvent,
9
9
  getTlsyncProtocolVersion,
10
10
  } from '../lib/protocol'
11
+ import { TLSocketRoom } from '../lib/TLSocketRoom'
11
12
  import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
12
13
  import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
13
14
 
@@ -354,6 +355,40 @@ describe('object store lane', () => {
354
355
  const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
355
356
  expect(Object.keys(connectMsg.diff).sort()).toEqual([doc.id, note.id].sort())
356
357
  })
358
+
359
+ it('[US1][US3] updateStore sees and can delete object-lane records', async () => {
360
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
361
+ const socketRoom = new TLSocketRoom<R, void>({
362
+ schema,
363
+ storage: new InMemorySyncStorage<R>({
364
+ snapshot: { documents: [], clock: 0, documentClock: 0, schema: schema.serialize() },
365
+ objectTypes: ['note'],
366
+ }),
367
+ objectTypes: ['note'],
368
+ })
369
+ disposables.push(() => socketRoom.close())
370
+ const doc = Doc.create({ title: 'canvas' })
371
+ const note = Note.create({ text: 'annotation' })
372
+ socketRoom.storage.transaction((txn) => {
373
+ txn.set(doc.id, doc)
374
+ txn.set(note.id, note)
375
+ })
376
+
377
+ // the update context reads both lanes, not just the document snapshot
378
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
379
+ await socketRoom.updateStore((store) => {
380
+ expect(store.get(note.id)).toEqual(note)
381
+ expect(
382
+ store
383
+ .getAll()
384
+ .map((r) => r.id)
385
+ .sort()
386
+ ).toEqual([doc.id, note.id].sort())
387
+ store.delete(note.id)
388
+ })
389
+ expect(socketRoom.getRecord(note.id)).toBeUndefined()
390
+ expect(socketRoom.getRecord(doc.id)).toEqual(doc)
391
+ })
357
392
  })
358
393
 
359
394
  describe('connect message', () => {