source-buffer.host.spec.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /** Worker-side source buffer behavior. */
  2. import { MessageChannel } from 'node:worker_threads'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { HostBridgePublisher } from '../src/host/bridge/publisher.ts'
  5. import { inspectorId } from '../src/shared/bridge/ids.ts'
  6. import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../src/shared/bridge/buffer.ts'
  7. import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
  8. const sourceId = inspectorId<'InspectorSourceId'>('source-buffer-test', 'sourceId')
  9. const generation = inspectorId<'InspectorSourceGeneration'>('generation-buffer-test', 'generation')
  10. const source: InspectorSourceDescriptor = {
  11. sourceId,
  12. generation,
  13. kind: 'host',
  14. label: 'Host',
  15. timeOriginMs: performance.timeOrigin,
  16. capabilities: [],
  17. }
  18. function buffer(
  19. maxQueuedRecords = 2,
  20. overrides: Partial<InspectorSourceBufferOptions> = {},
  21. ): InspectorSourceBuffer {
  22. return new InspectorSourceBuffer({
  23. topics: ['*'],
  24. maxQueuedRecords,
  25. maxQueuedBytes: 32_768,
  26. maxRecordsPerFrame: 8,
  27. maxFrameBytes: 32_768,
  28. ...overrides,
  29. })
  30. }
  31. describe('Inspector source buffer', () => {
  32. it('absorbs pre-replacement queue loss exactly once', () => {
  33. const records = buffer(1)
  34. expect(records.replacement(sourceId, generation)).toMatchObject({ nextSequence: 1, records: [] })
  35. records.publish('test/event', { ordinal: 1 }, 1)
  36. records.publish('test/event', { ordinal: 2 }, 2)
  37. expect(records.replacement(sourceId, generation)).toMatchObject({
  38. nextSequence: 2,
  39. records: [],
  40. })
  41. expect(records.takeBatch(sourceId, generation)).toMatchObject({
  42. firstSequence: 2,
  43. droppedBefore: 0,
  44. records: [{ topic: 'test/event', payload: { ordinal: 2 } }],
  45. })
  46. })
  47. it('validates records before either carrier can enqueue them', () => {
  48. const records = buffer()
  49. expect(() => { records.publish('', {}, 1) }).toThrow('topic must contain 1 to 128 characters')
  50. expect(() => { records.publish('x'.repeat(129), {}, 1) }).toThrow('topic must contain 1 to 128 characters')
  51. expect(() => { buffer(2, { topics: ['declared'] }).publish('undeclared', {}, 1) })
  52. .toThrow('source does not declare topic')
  53. expect(() => { records.publish('test/event', {}, Number.NaN) }).toThrow('monotonicMs must be finite')
  54. const cyclic: Record<string, unknown> = {}
  55. cyclic.self = cyclic
  56. expect(() => { records.publish('test/event', cyclic as never, 1) }).toThrow('lossless JSON data')
  57. })
  58. it('rejects oversized retained state without replacing the previous value', () => {
  59. const records = buffer(4, { maxFrameBytes: 4_300 })
  60. records.setState('state', { value: 'kept' }, 1)
  61. expect(() => { records.setState('state', { value: 'x'.repeat(1_000) }, 2) })
  62. .toThrow('source state exceeds the source-frame byte limit')
  63. expect(() => { records.setState('other', { value: 'x'.repeat(1_000) }, 3) })
  64. .toThrow('source state exceeds the source-frame byte limit')
  65. expect(records.replacement(sourceId, generation).records).toEqual([
  66. { topic: 'state', payload: { value: 'kept' }, monotonicMs: 1 },
  67. ])
  68. })
  69. it('splits frames at record, byte, and sequence gaps and discards pending records', () => {
  70. const records = buffer(10, { maxRecordsPerFrame: 2, maxFrameBytes: 4_300 })
  71. expect(records.hasPending).toBe(false)
  72. records.publish('test/event', { value: 'a'.repeat(40) }, 1)
  73. records.publish('test/event', { value: 'x'.repeat(1_000) }, 2)
  74. records.publish('test/event', { value: 'b'.repeat(40) }, 3)
  75. expect(records.hasPending).toBe(true)
  76. expect(records.takeBatch(sourceId, generation)).toMatchObject({ firstSequence: 1, records: [{ monotonicMs: 1 }] })
  77. expect(records.takeBatch(sourceId, generation)).toMatchObject({
  78. firstSequence: 3,
  79. droppedBefore: 1,
  80. records: [{ monotonicMs: 3 }],
  81. })
  82. expect(records.takeBatch(sourceId, generation)).toBeUndefined()
  83. records.publish('test/event', { ordinal: 4 }, 4)
  84. records.discardPending()
  85. expect(records.hasPending).toBe(false)
  86. const byteSplit = buffer(10, { maxFrameBytes: 4_300 })
  87. byteSplit.publish('test/event', { value: 'a'.repeat(100) }, 1)
  88. byteSplit.publish('test/event', { value: 'b'.repeat(100) }, 2)
  89. expect(byteSplit.takeBatch(sourceId, generation)?.records).toHaveLength(1)
  90. expect(byteSplit.takeBatch(sourceId, generation)?.records).toHaveLength(1)
  91. })
  92. it('drops queued records against the byte limit independently of the item limit', () => {
  93. const records = buffer(10, { maxQueuedBytes: 120 })
  94. records.publish('test/event', { value: 'a'.repeat(40) }, 1)
  95. records.publish('test/event', { value: 'b'.repeat(40) }, 2)
  96. expect(records.takeBatch(sourceId, generation)).toMatchObject({
  97. firstSequence: 2,
  98. droppedBefore: 1,
  99. records: [{ monotonicMs: 2 }],
  100. })
  101. })
  102. it('keeps at most one Host MessagePort observation batch in flight', async () => {
  103. const channel = new MessageChannel()
  104. const messages: unknown[] = []
  105. channel.port2.on('message', (message) => { messages.push(message) })
  106. channel.port2.start()
  107. const publisher = new HostBridgePublisher(channel.port1, source, {
  108. topics: ['*'],
  109. maxQueuedRecords: 2,
  110. maxQueuedBytes: 32_768,
  111. maxRecordsPerFrame: 1,
  112. maxFrameBytes: 32_768,
  113. })
  114. try {
  115. publisher.publish('test/event', { ordinal: 1 })
  116. publisher.flush()
  117. publisher.publish('test/event', { ordinal: 2 })
  118. publisher.publish('test/event', { ordinal: 3 })
  119. await vi.waitFor(() => { expect(messages).toHaveLength(1) })
  120. const first = messages[0] as { firstSequence: number; records: Array<{ payload: unknown }> }
  121. expect(first.records).toHaveLength(1)
  122. expect(first.records[0]?.payload).toEqual({ ordinal: 1 })
  123. publisher.acknowledge(first.firstSequence + first.records.length)
  124. await vi.waitFor(() => { expect(messages).toHaveLength(2) })
  125. const second = messages[1] as { firstSequence: number; droppedBefore: number; records: Array<{ payload: unknown }> }
  126. expect(second).toMatchObject({
  127. firstSequence: 2,
  128. droppedBefore: 0,
  129. records: [{ payload: { ordinal: 2 } }],
  130. })
  131. publisher.acknowledge(second.firstSequence + second.records.length)
  132. await vi.waitFor(() => { expect(messages).toHaveLength(3) })
  133. expect(messages[2]).toMatchObject({
  134. firstSequence: 3,
  135. records: [{ payload: { ordinal: 3 } }],
  136. })
  137. } finally {
  138. publisher.close()
  139. channel.port1.close()
  140. channel.port2.close()
  141. }
  142. })
  143. })