@livestore/webmesh 0.4.0-dev.22 → 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 +15 -23
  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 +17 -40
  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,
@@ -16,11 +15,6 @@ import {
16
15
  import { type ChannelName, type MeshNodeName, type MessageQueueItem, packetAsOtelAttributes } from '../common.ts'
17
16
  import * as MeshSchema from '../mesh-schema.ts'
18
17
 
19
- // WORKAROUND: @effect/opentelemetry mis-parses `Span.addEvent(name, attributes)` and treats the attributes object as a
20
- // time input, causing `TypeError: {} is not iterable` at runtime.
21
- // Upstream: https://github.com/Effect-TS/effect/pull/5929
22
- // TODO: simplify back to the 2-arg overload once the upstream fix is released and adopted.
23
-
24
18
  export interface MakeDirectChannelArgs {
25
19
  nodeName: MeshNodeName
26
20
  /** Queue of incoming messages for this channel */
@@ -94,11 +88,6 @@ export const makeDirectChannelInternal = ({
94
88
 
95
89
  const deferred = yield* makeDeferredResult()
96
90
 
97
- const span = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
98
- // const span = {
99
- // addEvent: (...msg: any[]) => console.log(`${nodeName}→${channelName}→${target}[${channelVersion}]`, ...msg),
100
- // }
101
-
102
91
  const schema = {
103
92
  send: Schema.Union(schema_.send, MeshSchema.DirectChannelPing, MeshSchema.DirectChannelPong),
104
93
  listen: Schema.Union(schema_.listen, MeshSchema.DirectChannelPing, MeshSchema.DirectChannelPong),
@@ -112,16 +101,12 @@ export const makeDirectChannelInternal = ({
112
101
  Effect.gen(function* () {
113
102
  const channelState = channelStateRef.current
114
103
 
115
- span?.addEvent(
116
- `process:${packet._tag}`,
117
- {
118
- channelState: channelState._tag,
119
- packetId: packet.id,
120
- packetReqId: packet.reqId,
121
- packetChannelVersion: Predicate.hasProperty('channelVersion')(packet) ? packet.channelVersion : undefined,
122
- },
123
- undefined,
124
- )
104
+ yield* Effect.spanEvent(`process:${packet._tag}`, {
105
+ channelState: channelState._tag,
106
+ packetId: packet.id,
107
+ packetReqId: packet.reqId,
108
+ ...(Predicate.hasProperty('channelVersion')(packet) === true ? { packetChannelVersion: packet.channelVersion } : {}),
109
+ })
125
110
 
126
111
  // const reqIdStr =
127
112
  // Predicate.hasProperty('reqId')(packet) && packet.reqId !== undefined ? ` for ${packet.reqId}` : ''
@@ -139,7 +124,7 @@ export const makeDirectChannelInternal = ({
139
124
  // If the other side has a higher version, we need to close this channel and
140
125
  // recreate it with the new version
141
126
  if (packet.channelVersion > channelVersion) {
142
- span?.addEvent(`incoming packet has higher version (${packet.channelVersion}), closing channel`)
127
+ yield* Effect.spanEvent(`incoming packet has higher version (${packet.channelVersion}), closing channel`)
143
128
  yield* Scope.close(scope, Exit.succeed('higher-version-expected'))
144
129
  // TODO include expected version in the error so the channel gets recreated with the new version
145
130
  return 'close'
@@ -158,7 +143,7 @@ export const makeDirectChannelInternal = ({
158
143
  remainingHops: packet.hops,
159
144
  reqId: undefined,
160
145
  })
161
- span?.addEvent(
146
+ yield* Effect.spanEvent(
162
147
  `incoming packet has lower version (${packet.channelVersion}), sending request to reconnect (${newPacket.id})`,
163
148
  )
164
149
 
@@ -173,7 +158,7 @@ export const makeDirectChannelInternal = ({
173
158
  } else {
174
159
  // In case the instance of the source has changed, we need to close the channel
175
160
  // and reconnect with a new channel
176
- span?.addEvent(`force-new-channel`)
161
+ yield* Effect.spanEvent(`force-new-channel`)
177
162
  yield* Scope.close(scope, Exit.succeed('force-new-channel'))
178
163
  return 'close'
179
164
  }
@@ -200,15 +185,15 @@ export const makeDirectChannelInternal = ({
200
185
  remainingHops: packet.hops,
201
186
  reqId: packet.id,
202
187
  })
203
- 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})`)
204
189
 
205
190
  yield* sendPacket(newRequestPacket)
206
191
  }
207
192
 
208
193
  const isWinner = nodeName > target
209
194
 
210
- if (isWinner) {
211
- 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`)
212
197
  const mc = new MessageChannel()
213
198
 
214
199
  // We're using a direct channel with acks here to make sure messages are not lost
@@ -236,8 +221,6 @@ export const makeDirectChannelInternal = ({
236
221
 
237
222
  channelStateRef.current = { _tag: 'winner:ResponseSent', channel, otherSourceId: packet.sourceId }
238
223
 
239
- // span?.addEvent(`winner side: waiting for ping`)
240
-
241
224
  // Now we wait for the other side to respond via the channel
242
225
  yield* channel.listen.pipe(
243
226
  Stream.flatten(),
@@ -246,21 +229,19 @@ export const makeDirectChannelInternal = ({
246
229
  Stream.runDrain,
247
230
  )
248
231
 
249
- // span?.addEvent(`winner side: sending pong`)
250
-
251
232
  yield* channel.send(MeshSchema.DirectChannelPong.make({}))
252
233
 
253
- span?.addEvent(`winner side: established`)
234
+ yield* Effect.spanEvent(`winner side: established`)
254
235
  channelStateRef.current = { _tag: 'Established', otherSourceId: packet.sourceId }
255
236
 
256
237
  yield* Deferred.succeed(deferred, channel)
257
238
  } else {
258
- span?.addEvent(`loser side: waiting for response`)
239
+ yield* Effect.spanEvent(`loser side: waiting for response`)
259
240
  // Wait for `DirectChannelResponseSuccess` packet
260
241
  channelStateRef.current = { _tag: 'loser:WaitingForResponse', otherSourceId: packet.sourceId }
261
242
  }
262
243
 
263
- break
244
+ return
264
245
  }
265
246
  case 'DirectChannelResponseSuccess': {
266
247
  if (channelState._tag !== 'loser:WaitingForResponse') {
@@ -284,8 +265,6 @@ export const makeDirectChannelInternal = ({
284
265
  Effect.fork,
285
266
  )
286
267
 
287
- // span?.addEvent(`loser side: sending ping`)
288
-
289
268
  // There seems to be some scenario where the initial ping message is lost.
290
269
  // As a workaround until we find the root cause, we're retrying the ping a few times.
291
270
  // TODO write a test that reproduces this issue and fix the root cause ()
@@ -294,11 +273,9 @@ export const makeDirectChannelInternal = ({
294
273
  .send(MeshSchema.DirectChannelPing.make({}))
295
274
  .pipe(Effect.timeout(10), Effect.retry({ times: 2 }))
296
275
 
297
- // span?.addEvent(`loser side: waiting for pong`)
298
-
299
276
  yield* waitForPongFiber
300
277
 
301
- span?.addEvent(`loser side: established`)
278
+ yield* Effect.spanEvent(`loser side: established`)
302
279
  channelStateRef.current = { _tag: 'Established', otherSourceId: channelState.otherSourceId }
303
280
 
304
281
  yield* Deferred.succeed(deferred, channel)
@@ -354,7 +331,7 @@ export const makeDirectChannelInternal = ({
354
331
  }
355
332
 
356
333
  yield* sendPacket(packet)
357
- span?.addEvent(`initial edge request sent (${packet.id})`)
334
+ yield* Effect.spanEvent(`initial edge request sent (${packet.id})`)
358
335
  })
359
336
 
360
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,