@livestore/webmesh 0.4.0-dev.21 → 0.4.0-dev.23

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.
Files changed (37) hide show
  1. package/README.md +5 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/channel/direct-channel-internal.d.ts.map +1 -1
  4. package/dist/channel/direct-channel-internal.js +14 -22
  5. package/dist/channel/direct-channel-internal.js.map +1 -1
  6. package/dist/channel/direct-channel.js +4 -4
  7. package/dist/channel/direct-channel.js.map +1 -1
  8. package/dist/channel/proxy-channel.d.ts +26 -1
  9. package/dist/channel/proxy-channel.d.ts.map +1 -1
  10. package/dist/channel/proxy-channel.js +64 -18
  11. package/dist/channel/proxy-channel.js.map +1 -1
  12. package/dist/common.d.ts.map +1 -1
  13. package/dist/common.js +5 -6
  14. package/dist/common.js.map +1 -1
  15. package/dist/node.d.ts +6 -0
  16. package/dist/node.d.ts.map +1 -1
  17. package/dist/node.js +25 -23
  18. package/dist/node.js.map +1 -1
  19. package/dist/node.test.js +192 -5
  20. package/dist/node.test.js.map +1 -1
  21. package/dist/websocket-edge.d.ts +1 -1
  22. package/dist/websocket-edge.d.ts.map +1 -1
  23. package/dist/websocket-edge.js +5 -7
  24. package/dist/websocket-edge.js.map +1 -1
  25. package/dist/websocket-edge.test.d.ts +7 -0
  26. package/dist/websocket-edge.test.d.ts.map +1 -0
  27. package/dist/websocket-edge.test.js +74 -0
  28. package/dist/websocket-edge.test.js.map +1 -0
  29. package/package.json +65 -12
  30. package/src/channel/direct-channel-internal.ts +13 -27
  31. package/src/channel/direct-channel.ts +4 -4
  32. package/src/channel/proxy-channel.ts +85 -25
  33. package/src/common.ts +5 -6
  34. package/src/node.test.ts +270 -7
  35. package/src/node.ts +31 -23
  36. package/src/websocket-edge.test.ts +98 -0
  37. package/src/websocket-edge.ts +7 -9
@@ -4,7 +4,6 @@ import {
4
4
  Deferred,
5
5
  Effect,
6
6
  Exit,
7
- OtelTracer,
8
7
  Predicate,
9
8
  Queue,
10
9
  Schema,
@@ -89,11 +88,6 @@ export const makeDirectChannelInternal = ({
89
88
 
90
89
  const deferred = yield* makeDeferredResult()
91
90
 
92
- const span = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
93
- // const span = {
94
- // addEvent: (...msg: any[]) => console.log(`${nodeName}→${channelName}→${target}[${channelVersion}]`, ...msg),
95
- // }
96
-
97
91
  const schema = {
98
92
  send: Schema.Union(schema_.send, MeshSchema.DirectChannelPing, MeshSchema.DirectChannelPong),
99
93
  listen: Schema.Union(schema_.listen, MeshSchema.DirectChannelPing, MeshSchema.DirectChannelPong),
@@ -107,11 +101,11 @@ export const makeDirectChannelInternal = ({
107
101
  Effect.gen(function* () {
108
102
  const channelState = channelStateRef.current
109
103
 
110
- span?.addEvent(`process:${packet._tag}`, {
104
+ yield* Effect.spanEvent(`process:${packet._tag}`, {
111
105
  channelState: channelState._tag,
112
106
  packetId: packet.id,
113
107
  packetReqId: packet.reqId,
114
- packetChannelVersion: Predicate.hasProperty('channelVersion')(packet) ? packet.channelVersion : undefined,
108
+ ...(Predicate.hasProperty('channelVersion')(packet) === true ? { packetChannelVersion: packet.channelVersion } : {}),
115
109
  })
116
110
 
117
111
  // const reqIdStr =
@@ -130,7 +124,7 @@ export const makeDirectChannelInternal = ({
130
124
  // If the other side has a higher version, we need to close this channel and
131
125
  // recreate it with the new version
132
126
  if (packet.channelVersion > channelVersion) {
133
- span?.addEvent(`incoming packet has higher version (${packet.channelVersion}), closing channel`)
127
+ yield* Effect.spanEvent(`incoming packet has higher version (${packet.channelVersion}), closing channel`)
134
128
  yield* Scope.close(scope, Exit.succeed('higher-version-expected'))
135
129
  // TODO include expected version in the error so the channel gets recreated with the new version
136
130
  return 'close'
@@ -149,7 +143,7 @@ export const makeDirectChannelInternal = ({
149
143
  remainingHops: packet.hops,
150
144
  reqId: undefined,
151
145
  })
152
- span?.addEvent(
146
+ yield* Effect.spanEvent(
153
147
  `incoming packet has lower version (${packet.channelVersion}), sending request to reconnect (${newPacket.id})`,
154
148
  )
155
149
 
@@ -164,7 +158,7 @@ export const makeDirectChannelInternal = ({
164
158
  } else {
165
159
  // In case the instance of the source has changed, we need to close the channel
166
160
  // and reconnect with a new channel
167
- span?.addEvent(`force-new-channel`)
161
+ yield* Effect.spanEvent(`force-new-channel`)
168
162
  yield* Scope.close(scope, Exit.succeed('force-new-channel'))
169
163
  return 'close'
170
164
  }
@@ -191,15 +185,15 @@ export const makeDirectChannelInternal = ({
191
185
  remainingHops: packet.hops,
192
186
  reqId: packet.id,
193
187
  })
194
- span?.addEvent(`Re-sending new request (${newRequestPacket.id}) for incoming request (${packet.id})`)
188
+ yield* Effect.spanEvent(`Re-sending new request (${newRequestPacket.id}) for incoming request (${packet.id})`)
195
189
 
196
190
  yield* sendPacket(newRequestPacket)
197
191
  }
198
192
 
199
193
  const isWinner = nodeName > target
200
194
 
201
- if (isWinner) {
202
- span?.addEvent(`winner side: creating direct channel and sending response`)
195
+ if (isWinner === true) {
196
+ yield* Effect.spanEvent(`winner side: creating direct channel and sending response`)
203
197
  const mc = new MessageChannel()
204
198
 
205
199
  // We're using a direct channel with acks here to make sure messages are not lost
@@ -227,8 +221,6 @@ export const makeDirectChannelInternal = ({
227
221
 
228
222
  channelStateRef.current = { _tag: 'winner:ResponseSent', channel, otherSourceId: packet.sourceId }
229
223
 
230
- // span?.addEvent(`winner side: waiting for ping`)
231
-
232
224
  // Now we wait for the other side to respond via the channel
233
225
  yield* channel.listen.pipe(
234
226
  Stream.flatten(),
@@ -237,21 +229,19 @@ export const makeDirectChannelInternal = ({
237
229
  Stream.runDrain,
238
230
  )
239
231
 
240
- // span?.addEvent(`winner side: sending pong`)
241
-
242
232
  yield* channel.send(MeshSchema.DirectChannelPong.make({}))
243
233
 
244
- span?.addEvent(`winner side: established`)
234
+ yield* Effect.spanEvent(`winner side: established`)
245
235
  channelStateRef.current = { _tag: 'Established', otherSourceId: packet.sourceId }
246
236
 
247
237
  yield* Deferred.succeed(deferred, channel)
248
238
  } else {
249
- span?.addEvent(`loser side: waiting for response`)
239
+ yield* Effect.spanEvent(`loser side: waiting for response`)
250
240
  // Wait for `DirectChannelResponseSuccess` packet
251
241
  channelStateRef.current = { _tag: 'loser:WaitingForResponse', otherSourceId: packet.sourceId }
252
242
  }
253
243
 
254
- break
244
+ return
255
245
  }
256
246
  case 'DirectChannelResponseSuccess': {
257
247
  if (channelState._tag !== 'loser:WaitingForResponse') {
@@ -275,8 +265,6 @@ export const makeDirectChannelInternal = ({
275
265
  Effect.fork,
276
266
  )
277
267
 
278
- // span?.addEvent(`loser side: sending ping`)
279
-
280
268
  // There seems to be some scenario where the initial ping message is lost.
281
269
  // As a workaround until we find the root cause, we're retrying the ping a few times.
282
270
  // TODO write a test that reproduces this issue and fix the root cause ()
@@ -285,11 +273,9 @@ export const makeDirectChannelInternal = ({
285
273
  .send(MeshSchema.DirectChannelPing.make({}))
286
274
  .pipe(Effect.timeout(10), Effect.retry({ times: 2 }))
287
275
 
288
- // span?.addEvent(`loser side: waiting for pong`)
289
-
290
276
  yield* waitForPongFiber
291
277
 
292
- span?.addEvent(`loser side: established`)
278
+ yield* Effect.spanEvent(`loser side: established`)
293
279
  channelStateRef.current = { _tag: 'Established', otherSourceId: channelState.otherSourceId }
294
280
 
295
281
  yield* Deferred.succeed(deferred, channel)
@@ -345,7 +331,7 @@ export const makeDirectChannelInternal = ({
345
331
  }
346
332
 
347
333
  yield* sendPacket(packet)
348
- span?.addEvent(`initial edge request sent (${packet.id})`)
334
+ yield* Effect.spanEvent(`initial edge request sent (${packet.id})`)
349
335
  })
350
336
 
351
337
  yield* edgeRequest
@@ -61,7 +61,7 @@ export const makeDirectChannel = ({
61
61
  innerChannelRef: { current: undefined as WebChannel.WebChannel<any, any> | undefined },
62
62
  }
63
63
 
64
- // #region reconnect-loop
64
+ //#region reconnect-loop
65
65
  yield* Effect.gen(function* () {
66
66
  const resultDeferred = yield* Deferred.make<{
67
67
  channel: WebChannel.WebChannel<any, any>
@@ -133,8 +133,8 @@ export const makeDirectChannel = ({
133
133
  yield* Scope.close(makeDirectChannelScope, channelExit)
134
134
 
135
135
  if (
136
- Cause.isFailType(channelExit.cause) &&
137
- Schema.is(WebmeshSchema.DirectChannelResponseNoTransferables)(channelExit.cause.error)
136
+ Cause.isFailType(channelExit.cause) === true &&
137
+ Schema.is(WebmeshSchema.DirectChannelResponseNoTransferables)(channelExit.cause.error) === true
138
138
  ) {
139
139
  // Only retry when there is a new edge available
140
140
  yield* waitForNewEdgeFiber.pipe(Effect.exit)
@@ -193,7 +193,7 @@ export const makeDirectChannel = ({
193
193
  Effect.tapCauseLogPretty,
194
194
  Effect.forkScoped,
195
195
  )
196
- // #endregion reconnect-loop
196
+ //#endregion reconnect-loop
197
197
 
198
198
  const parentSpan = yield* Effect.currentSpan.pipe(Effect.orDie)
199
199
 
@@ -26,6 +26,38 @@ import {
26
26
  } from '../common.ts'
27
27
  import * as MeshSchema from '../mesh-schema.ts'
28
28
 
29
+ /**
30
+ * Simulation parameters for proxy channel operations.
31
+ * Used for testing race conditions and timing-sensitive behavior.
32
+ *
33
+ * Each parameter represents a delay (in ms) injected at a specific point in the code.
34
+ * Values are bounded 0-500ms to prevent tests from running too long.
35
+ */
36
+ export const ProxyChannelSimulationParams = Schema.Struct({
37
+ /**
38
+ * Delays related to receiving and processing payload messages
39
+ */
40
+ onPayload: Schema.Struct({
41
+ /** Delay before sending the ACK response (simulates slow ACK send) */
42
+ beforeAckSend: Schema.Int.pipe(Schema.between(0, 500)),
43
+ /** Delay after forking the ACK send, before adding message to listen queue */
44
+ afterAckFork: Schema.Int.pipe(Schema.between(0, 500)),
45
+ /** Delay after adding message to listen queue */
46
+ afterListenQueueOffer: Schema.Int.pipe(Schema.between(0, 500)),
47
+ }),
48
+ })
49
+
50
+ export type ProxyChannelSimulationParams = typeof ProxyChannelSimulationParams.Type
51
+
52
+ /** Default simulation params with no delays */
53
+ export const defaultSimulationParams: ProxyChannelSimulationParams = {
54
+ onPayload: {
55
+ beforeAckSend: 0,
56
+ afterAckFork: 0,
57
+ afterListenQueueOffer: 0,
58
+ },
59
+ }
60
+
29
61
  interface MakeProxyChannelArgs {
30
62
  queue: Queue.Queue<ProxyQueueItem>
31
63
  nodeName: MeshNodeName
@@ -37,6 +69,8 @@ interface MakeProxyChannelArgs {
37
69
  send: Schema.Schema<any, any>
38
70
  listen: Schema.Schema<any, any>
39
71
  }
72
+ /** Optional simulation parameters for testing timing-sensitive behavior */
73
+ simulation?: ProxyChannelSimulationParams
40
74
  }
41
75
 
42
76
  export const makeProxyChannel = ({
@@ -47,9 +81,18 @@ export const makeProxyChannel = ({
47
81
  target,
48
82
  channelName,
49
83
  schema,
84
+ simulation = defaultSimulationParams,
50
85
  }: MakeProxyChannelArgs) =>
51
86
  Effect.scopeWithCloseable((scope) =>
52
87
  Effect.gen(function* () {
88
+ /** Helper to inject simulation delays at specific code points */
89
+ const simSleep = <TKey extends keyof ProxyChannelSimulationParams>(
90
+ key: TKey,
91
+ key2: keyof ProxyChannelSimulationParams[TKey],
92
+ ) => {
93
+ const delay = (simulation[key]?.[key2] ?? 0) as number
94
+ return delay > 0 ? Effect.sleep(delay) : Effect.void
95
+ }
53
96
  type ProxiedChannelState =
54
97
  | {
55
98
  _tag: 'Initial'
@@ -64,7 +107,7 @@ export const makeProxyChannel = ({
64
107
  _tag: 'Established'
65
108
  listenSchema: Schema.Schema<any, any>
66
109
  listenQueue: Queue.Queue<any>
67
- ackMap: Map<string, Deferred.Deferred<void, never>>
110
+ ackMap: Map<string, Deferred.Deferred<void>>
68
111
  combinedChannelId: string
69
112
  }
70
113
 
@@ -117,7 +160,7 @@ export const makeProxyChannel = ({
117
160
  )
118
161
 
119
162
  const getCombinedChannelId = (otherSideChannelIdCandidate: string) =>
120
- [channelIdCandidate, otherSideChannelIdCandidate].sort().join('_')
163
+ [channelIdCandidate, otherSideChannelIdCandidate].toSorted().join('_')
121
164
 
122
165
  const earlyPayloadBuffer = yield* Queue.unbounded<typeof MeshSchema.ProxyChannelPayload.Type>().pipe(
123
166
  Effect.acquireRelease(Queue.shutdown),
@@ -255,34 +298,46 @@ export const makeProxyChannel = ({
255
298
  )
256
299
  }
257
300
 
258
- // yield* Effect.logDebug(`[${nodeName}] Received payload reqId: ${packet.id}. Sending Ack.`)
259
- yield* respondToSender(
260
- MeshSchema.ProxyChannelPayloadAck.make({
261
- reqId: packet.id,
262
- remainingHops: packet.hops,
263
- hops: [],
264
- target,
265
- source: nodeName,
266
- channelName,
267
- combinedChannelId:
268
- channelState._tag === 'Established' ? channelState.combinedChannelId : packet.combinedChannelId,
269
- }),
270
- )
301
+ // Send ACK fire-and-forget to avoid blocking message processing
302
+ // This is critical because blocking ACK sends can prevent messages from reaching the listen queue
303
+ // See test: "ACK forkScoped regression tests" for documentation
304
+
305
+ // The ACK send effect with optional simulation delay INSIDE the fork
306
+ // This is key for testing: with forkScoped, the delay happens in background and doesn't block message processing
307
+ // Without forkScoped (blocking), the delay would block the message from being added to listen queue
308
+ const ackSendEffect = Effect.gen(function* () {
309
+ yield* simSleep('onPayload', 'beforeAckSend')
310
+ yield* respondToSender(
311
+ MeshSchema.ProxyChannelPayloadAck.make({
312
+ reqId: packet.id,
313
+ remainingHops: packet.hops,
314
+ hops: [],
315
+ target,
316
+ source: nodeName,
317
+ channelName,
318
+ combinedChannelId:
319
+ channelState._tag === 'Established' ? channelState.combinedChannelId : packet.combinedChannelId,
320
+ }),
321
+ )
322
+ })
323
+
324
+ yield* ackSendEffect.pipe(Effect.tapCauseLogPretty, Effect.forkScoped)
325
+
326
+ // Simulation point: delay after forking ACK (before processing message)
327
+ yield* simSleep('onPayload', 'afterAckFork')
271
328
 
272
329
  if (channelState._tag === 'Established') {
273
330
  const decodedMessage = yield* Schema.decodeUnknown(channelState.listenSchema)(packet.payload)
274
331
  yield* channelState.listenQueue.pipe(Queue.offer(decodedMessage))
332
+
333
+ // Simulation point: delay after adding to listen queue
334
+ yield* simSleep('onPayload', 'afterListenQueueOffer')
275
335
  } else {
276
- // yield* Effect.logDebug(
277
- // `[${nodeName}] Buffering early payload reqId: ${packet.id} (state: ${channelState._tag})`,
278
- // )
279
336
  yield* Queue.offer(earlyPayloadBuffer, packet)
280
337
  }
281
338
  return
282
339
  }
283
340
  case 'ProxyChannelPayloadAck': {
284
- // yield* Effect.logDebug(`[${nodeName}] Received Ack for reqId: ${packet.reqId}`)
285
-
286
341
  if (channelState._tag !== 'Established') {
287
342
  yield* Effect.spanEvent(`Not yet connected to ${target}. dropping message`)
288
343
  yield* Effect.logWarning(
@@ -291,9 +346,14 @@ export const makeProxyChannel = ({
291
346
  return
292
347
  }
293
348
 
294
- const ack =
295
- channelState.ackMap.get(packet.reqId) ??
296
- shouldNeverHappen(`[ProxyChannel[${channelKey}]] Expected ack for ${packet.reqId}`)
349
+ // Handle missing ACK gracefully - can happen with synthetic ACKs from relay or duplicate ACKs
350
+ const ack = channelState.ackMap.get(packet.reqId)
351
+ if (ack === undefined) {
352
+ yield* Effect.logDebug(
353
+ `Received ACK for unknown reqId: ${packet.reqId} (may be synthetic or duplicate)`,
354
+ )
355
+ return
356
+ }
297
357
  yield* Deferred.succeed(ack, void 0)
298
358
 
299
359
  channelState.ackMap.delete(packet.reqId)
@@ -321,7 +381,7 @@ export const makeProxyChannel = ({
321
381
 
322
382
  yield* Effect.spanEvent(`Connecting`)
323
383
 
324
- const ackMap = new Map<string, Deferred.Deferred<void, never>>()
384
+ const ackMap = new Map<string, Deferred.Deferred<void>>()
325
385
 
326
386
  // check if already established via incoming `ProxyChannelRequest` from other side
327
387
  // which indicates we already have a edge to the target node
@@ -366,7 +426,7 @@ export const makeProxyChannel = ({
366
426
 
367
427
  const innerSend = Effect.gen(function* () {
368
428
  // Note we're re-creating new packets every time otherwise they will be skipped because of `handledIds`
369
- const ack = yield* Deferred.make<void, never>()
429
+ const ack = yield* Deferred.make<void>()
370
430
  const packet = MeshSchema.ProxyChannelPayload.make({
371
431
  channelName,
372
432
  payload,
package/src/common.ts CHANGED
@@ -19,19 +19,18 @@ export type ChannelName = string
19
19
  export type ChannelKey = `target:${MeshNodeName}, channelName:${ChannelName}`
20
20
 
21
21
  // TODO actually use this to avoid timeouts in certain cases
22
- // export class NoConnectionRouteSignal extends Schema.TaggedError<NoConnectionRouteSignal>()(
23
- // 'NoConnectionRouteSignal',
24
- // {},
25
- // ) {}
22
+ // export class NoConnectionRouteSignal extends Schema.TaggedError<NoConnectionRouteSignal>(
23
+ // '~@livestore/webmesh/NoConnectionRouteSignal',
24
+ // )('NoConnectionRouteSignal', {}) {}
26
25
 
27
- export class EdgeAlreadyExistsError extends Schema.TaggedError<EdgeAlreadyExistsError>()('EdgeAlreadyExistsError', {
26
+ export class EdgeAlreadyExistsError extends Schema.TaggedError<EdgeAlreadyExistsError>('~@livestore/webmesh/EdgeAlreadyExistsError')('EdgeAlreadyExistsError', {
28
27
  target: Schema.String,
29
28
  }) {}
30
29
 
31
30
  export const packetAsOtelAttributes = (packet: typeof Packet.Type) => ({
32
31
  packetId: packet.id,
33
32
  'span.label':
34
- packet.id + (Predicate.hasProperty(packet, 'reqId') && packet.reqId !== undefined ? ` for ${packet.reqId}` : ''),
33
+ packet.id + (Predicate.hasProperty(packet, 'reqId') === true && packet.reqId !== undefined ? ` for ${packet.reqId}` : ''),
35
34
  ...omitUndefineds({
36
35
  packet:
37
36
  packet._tag !== 'DirectChannelResponseSuccess' && packet._tag !== 'ProxyChannelPayload' ? packet : undefined,