starpc 0.51.0 → 0.52.1

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 (93) hide show
  1. package/cmd/protoc-gen-es-starpc/typescript.ts +37 -0
  2. package/dist/cmd/protoc-gen-es-starpc/typescript.js +24 -0
  3. package/dist/echo/client-test.d.ts +1 -1
  4. package/dist/echo/client-test.js +110 -2
  5. package/dist/echo/echo_srpc.pb.d.ts +44 -1
  6. package/dist/echo/server.d.ts +9 -8
  7. package/dist/echo/server.js +6 -6
  8. package/dist/integration/cross-language/tcp-packet-stream.d.ts +3 -0
  9. package/dist/integration/cross-language/tcp-packet-stream.js +112 -0
  10. package/dist/integration/cross-language/tcp-packet-stream.test.d.ts +1 -0
  11. package/dist/integration/cross-language/tcp-packet-stream.test.js +121 -0
  12. package/dist/integration/cross-language/ts-client.js +50 -37
  13. package/dist/integration/cross-language/ts-server.js +1 -36
  14. package/dist/mock/mock_srpc.pb.d.ts +14 -1
  15. package/dist/rpcstream/rpcstream.d.ts +5 -1
  16. package/dist/rpcstream/rpcstream.js +75 -28
  17. package/dist/rpcstream/rpcstream.test.d.ts +1 -0
  18. package/dist/rpcstream/rpcstream.test.js +92 -0
  19. package/dist/srpc/channel.js +6 -3
  20. package/dist/srpc/channel.test.js +20 -1
  21. package/dist/srpc/client.js +18 -4
  22. package/dist/srpc/common-rpc.test.js +2 -0
  23. package/dist/srpc/handler.d.ts +12 -3
  24. package/dist/srpc/index.d.ts +2 -0
  25. package/dist/srpc/index.js +1 -0
  26. package/dist/srpc/invoker.d.ts +2 -1
  27. package/dist/srpc/invoker.js +2 -2
  28. package/dist/srpc/packet-codec.test.d.ts +1 -0
  29. package/dist/srpc/packet-codec.test.js +75 -0
  30. package/dist/srpc/packet.d.ts +1 -1
  31. package/dist/srpc/packet.js +11 -1
  32. package/dist/srpc/server-context.d.ts +11 -0
  33. package/dist/srpc/server-context.js +28 -0
  34. package/dist/srpc/server-rpc.js +3 -1
  35. package/dist/srpc/server.js +19 -6
  36. package/dist/srpc/server.test.js +78 -6
  37. package/dist/srpc/stream.d.ts +4 -1
  38. package/dist/srpc/stream.js +62 -3
  39. package/dist/srpc/stream.test.js +110 -1
  40. package/dist/srpc/termination.d.ts +27 -0
  41. package/dist/srpc/termination.js +56 -0
  42. package/dist/srpc/termination.test.d.ts +1 -0
  43. package/dist/srpc/termination.test.js +24 -0
  44. package/dist/srpc/watchdog.test.js +1 -0
  45. package/dist/testdata/packet-codec-vectors.json +64 -0
  46. package/echo/client-test.ts +124 -2
  47. package/echo/echo_pb2.py +40 -0
  48. package/echo/echo_pb2.pyi +13 -0
  49. package/echo/echo_srpc.pb.ts +74 -0
  50. package/echo/echo_srpc.py +306 -0
  51. package/echo/echo_srpc.pyi +85 -0
  52. package/echo/server.ts +24 -5
  53. package/go.mod +2 -2
  54. package/go.sum +14 -0
  55. package/integration/cross-language/go-client/main.go +79 -3
  56. package/integration/cross-language/python-client.py +146 -0
  57. package/integration/cross-language/python-server.py +140 -0
  58. package/integration/cross-language/run.bash +190 -65
  59. package/integration/cross-language/tcp-packet-stream.test.ts +154 -0
  60. package/integration/cross-language/tcp-packet-stream.ts +121 -0
  61. package/integration/cross-language/ts-client.ts +62 -40
  62. package/integration/cross-language/ts-server.ts +1 -45
  63. package/mock/mock_pb2.py +38 -0
  64. package/mock/mock_pb2.pyi +11 -0
  65. package/mock/mock_srpc.pb.ts +19 -1
  66. package/mock/mock_srpc.py +71 -0
  67. package/mock/mock_srpc.pyi +27 -0
  68. package/package.json +20 -6
  69. package/srpc/__init__.py +0 -0
  70. package/srpc/channel.test.ts +21 -1
  71. package/srpc/channel.ts +7 -3
  72. package/srpc/client.ts +20 -4
  73. package/srpc/codec.rs +6 -0
  74. package/srpc/common-rpc.test.ts +2 -0
  75. package/srpc/handler.ts +54 -4
  76. package/srpc/index.ts +7 -0
  77. package/srpc/invoker.ts +23 -6
  78. package/srpc/packet-codec-vectors_test.go +195 -0
  79. package/srpc/packet-codec.test.ts +139 -0
  80. package/srpc/packet-rw.go +9 -2
  81. package/srpc/packet.ts +15 -2
  82. package/srpc/py.typed +0 -0
  83. package/srpc/rpcproto_pb2.py +40 -0
  84. package/srpc/rpcproto_pb2.pyi +40 -0
  85. package/srpc/server-context.ts +55 -0
  86. package/srpc/server-rpc.ts +4 -1
  87. package/srpc/server.test.ts +100 -5
  88. package/srpc/server.ts +22 -6
  89. package/srpc/stream.test.ts +132 -1
  90. package/srpc/stream.ts +65 -9
  91. package/srpc/termination.test.ts +30 -0
  92. package/srpc/termination.ts +70 -0
  93. package/srpc/watchdog.test.ts +1 -0
@@ -1,5 +1,7 @@
1
1
  import { describe, it, beforeEach, expect, vi } from 'vitest'
2
2
  import { pipe } from 'it-pipe'
3
+ import { pushable } from 'it-pushable'
4
+ import type { Source } from 'it-stream-types'
3
5
  import {
4
6
  createHandler,
5
7
  createMux,
@@ -10,6 +12,10 @@ import {
10
12
  combineUint8ArrayListTransform,
11
13
  ChannelStreamOpts,
12
14
  Packet,
15
+ createContextKey,
16
+ serverContextValue,
17
+ withServerContextValue,
18
+ type ServerContext,
13
19
  } from '../srpc/index.js'
14
20
  import {
15
21
  EchoerDefinition,
@@ -78,12 +84,21 @@ describe('srpc server', () => {
78
84
  it('should pass rpc stream tests', async () => {
79
85
  await runRpcStreamTest(client)
80
86
  })
81
- it('passes the exact invocation signal after async request decode', async () => {
87
+ it('passes the exact invocation context after async request decode', async () => {
82
88
  const controller = new AbortController()
83
- let observedSignal: AbortSignal | undefined
89
+ const callerKey = createContextKey<string>()
90
+ let observedAbortSignal: AbortSignal | undefined
91
+ let observedContextSignal: AbortSignal | undefined
92
+ let observedCaller: string | undefined
84
93
  const handler = createHandler(EchoerDefinition, {
85
- Echo: async (request: EchoMsgType, signal?: AbortSignal) => {
86
- observedSignal = signal
94
+ Echo: async (
95
+ request: EchoMsgType,
96
+ abortSignal: AbortSignal,
97
+ context: ServerContext,
98
+ ) => {
99
+ observedAbortSignal = abortSignal
100
+ observedContextSignal = context.signal
101
+ observedCaller = serverContextValue(context, callerKey)
87
102
  return request
88
103
  },
89
104
  })
@@ -104,9 +119,41 @@ describe('srpc server', () => {
104
119
  }
105
120
  drained.resolve()
106
121
  },
107
- controller.signal,
122
+ withServerContextValue(
123
+ { signal: controller.signal },
124
+ callerKey,
125
+ 'caller-1',
126
+ ),
108
127
  )
109
128
  await drained.promise
129
+ expect(observedAbortSignal).toBe(controller.signal)
130
+ expect(observedContextSignal).toBe(controller.signal)
131
+ expect(observedCaller).toBe('caller-1')
132
+ })
133
+
134
+ it('keeps two-argument server handlers compatible', async () => {
135
+ const controller = new AbortController()
136
+ let observedSignal: AbortSignal | undefined
137
+ const handler = createHandler(EchoerDefinition, {
138
+ Echo: async (request: EchoMsgType, abortSignal?: AbortSignal) => {
139
+ observedSignal = abortSignal
140
+ return request
141
+ },
142
+ })
143
+ const invokeFn = await handler.lookupMethod(EchoerServiceName, 'Echo')
144
+ if (!invokeFn) throw new Error('Echo method was not found')
145
+ const request = EchoMsg.create({ body: 'legacy handler' })
146
+ await invokeFn(
147
+ (async function* () {
148
+ yield EchoMsg.toBinary(request)
149
+ })(),
150
+ async (source) => {
151
+ for await (const _data of source) {
152
+ // Drain the response.
153
+ }
154
+ },
155
+ { signal: controller.signal },
156
+ )
110
157
  expect(observedSignal).toBe(controller.signal)
111
158
  })
112
159
 
@@ -130,6 +177,8 @@ describe('srpc server', () => {
130
177
  const server = new Server(mux.lookupMethod)
131
178
  const firstResponse = new Promise<Packet>((resolve, reject) => {
132
179
  server.handlePacketStream({
180
+ close: async () => {},
181
+ abort: () => {},
133
182
  source: (async function* () {
134
183
  yield Packet.toBinary({
135
184
  body: {
@@ -297,6 +346,52 @@ describe('srpc server', () => {
297
346
  await Promise.resolve()
298
347
  })
299
348
 
349
+ it('closes the packet stream when the server pipeline completes', async () => {
350
+ const server = new Server(createMux().lookupMethod)
351
+ const close = vi.fn(async () => {})
352
+ const source = pushable<Uint8Array>({ objectMode: true })
353
+ const stream = {
354
+ close,
355
+ abort: vi.fn(),
356
+ source,
357
+ sink: async (output: Source<Uint8Array>) => {
358
+ for await (const _packet of output) {
359
+ // Drain the response pipeline.
360
+ }
361
+ },
362
+ }
363
+ const rpc = server.handlePacketStream(stream)
364
+
365
+ await rpc.close()
366
+ source.end()
367
+
368
+ await vi.waitFor(() => expect(close).toHaveBeenCalledOnce())
369
+ expect(stream.abort).not.toHaveBeenCalled()
370
+ })
371
+
372
+ it('aborts the packet stream when the server pipeline fails', async () => {
373
+ const server = new Server(createMux().lookupMethod)
374
+ const error = new Error('input failed')
375
+ const abort = vi.fn()
376
+ const source = pushable<Uint8Array>({ objectMode: true })
377
+ const stream = {
378
+ close: vi.fn(async () => {}),
379
+ abort,
380
+ source,
381
+ sink: async (output: Source<Uint8Array>) => {
382
+ for await (const _packet of output) {
383
+ // Drain the response pipeline.
384
+ }
385
+ },
386
+ }
387
+
388
+ server.handlePacketStream(stream)
389
+ source.end(error)
390
+
391
+ await vi.waitFor(() => expect(abort).toHaveBeenCalledWith(error))
392
+ expect(stream.close).not.toHaveBeenCalled()
393
+ })
394
+
300
395
  it('tears down passive channel close state', async () => {
301
396
  const { port1, port2 } = new MessageChannel()
302
397
  const opts: ChannelStreamOpts = { idleTimeoutMs: 1000, keepAliveMs: 1000 }
package/srpc/server.ts CHANGED
@@ -20,9 +20,7 @@ export class Server implements StreamHandler {
20
20
  public get rpcStreamHandler(): HandleStreamFunc {
21
21
  return async (stream: PacketStream) => {
22
22
  const rpc = this.startRpc()
23
- return pipe(stream, decodePacketSource, rpc, encodePacketSource, stream)
24
- .catch((err: Error) => rpc.close(err))
25
- .then(() => rpc.close())
23
+ return runPacketStream(stream, rpc)
26
24
  }
27
25
  }
28
26
 
@@ -36,9 +34,27 @@ export class Server implements StreamHandler {
36
34
  // the stream has one Uint8Array per packet w/o length prefix.
37
35
  public handlePacketStream(stream: PacketStream): ServerRPC {
38
36
  const rpc = this.startRpc()
39
- pipe(stream, decodePacketSource, rpc, encodePacketSource, stream)
40
- .catch((err: Error) => rpc.close(err))
41
- .then(() => rpc.close())
37
+ void runPacketStream(stream, rpc).catch(() => undefined)
42
38
  return rpc
43
39
  }
44
40
  }
41
+
42
+ async function runPacketStream(
43
+ stream: PacketStream,
44
+ rpc: ServerRPC,
45
+ ): Promise<void> {
46
+ try {
47
+ await pipe(stream, decodePacketSource, rpc, encodePacketSource, stream)
48
+ if (rpc.isClosed instanceof Error) {
49
+ stream.abort(rpc.isClosed)
50
+ throw rpc.isClosed
51
+ }
52
+ await stream.close()
53
+ await rpc.close()
54
+ } catch (err) {
55
+ const error = err instanceof Error ? err : new Error(String(err))
56
+ stream.abort(error)
57
+ await rpc.close(error)
58
+ throw error
59
+ }
60
+ }
@@ -1,5 +1,9 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vitest'
2
2
  import { pipe } from 'it-pipe'
3
+ import { pushable } from 'it-pushable'
4
+ import type { Source } from 'it-stream-types'
5
+ import type { Stream } from './stream-muxer.js'
6
+ import { streamToPacketStream } from './stream.js'
3
7
 
4
8
  import {
5
9
  ChannelStream,
@@ -71,6 +75,133 @@ describe('StreamConn packet stream', () => {
71
75
  expect(serverError).toBeUndefined()
72
76
  })
73
77
 
78
+ it('settles a blocked packet source when closed', async () => {
79
+ const { clientConn, cleanup } = connectStreamConns({
80
+ handlePacketStream() {},
81
+ })
82
+
83
+ try {
84
+ const stream = await clientConn.openStream()
85
+ const pending = stream.source.next()
86
+
87
+ await stream.close()
88
+
89
+ await expect(pending).resolves.toEqual({ done: true, value: undefined })
90
+ } finally {
91
+ cleanup()
92
+ }
93
+ })
94
+
95
+ it('settles a blocked packet source when aborted', async () => {
96
+ const { clientConn, cleanup } = connectStreamConns({
97
+ handlePacketStream() {},
98
+ })
99
+
100
+ try {
101
+ const stream = await clientConn.openStream()
102
+ const pending = stream.source.next()
103
+ const error = new Error('stopped')
104
+
105
+ stream.abort(error)
106
+
107
+ await expect(pending).rejects.toBe(error)
108
+ } finally {
109
+ cleanup()
110
+ }
111
+ })
112
+
113
+ it('settles a blocked packet sink when closed', async () => {
114
+ const { clientConn, cleanup } = connectStreamConns({
115
+ handlePacketStream() {},
116
+ })
117
+
118
+ try {
119
+ const stream = await clientConn.openStream()
120
+ const input = pushable<Uint8Array>({ objectMode: true })
121
+ const pending = stream.sink(input)
122
+
123
+ await stream.close()
124
+
125
+ await expect(pending).resolves.toBeUndefined()
126
+ } finally {
127
+ cleanup()
128
+ }
129
+ })
130
+
131
+ it('settles a packet sink blocked in the underlying write', async () => {
132
+ const sinkStarted = Promise.withResolvers<void>()
133
+ const transport = {
134
+ source: (async function* () {})(),
135
+ sink: async (source: Source<Uint8Array>) => {
136
+ for await (const _chunk of source) {
137
+ sinkStarted.resolve()
138
+ await new Promise<void>(() => {})
139
+ }
140
+ },
141
+ close: vi.fn(async () => {}),
142
+ closeRead: vi.fn(async () => {}),
143
+ closeWrite: vi.fn(async () => {}),
144
+ abort: vi.fn(),
145
+ } satisfies Stream
146
+ const stream = streamToPacketStream(transport)
147
+ const input = pushable<Uint8Array>({ objectMode: true })
148
+ input.push(new Uint8Array([1]))
149
+ const pending = stream.sink(input)
150
+ await sinkStarted.promise
151
+
152
+ await stream.close()
153
+
154
+ await expect(pending).resolves.toBeUndefined()
155
+ })
156
+
157
+ it('rejects a blocked packet sink with the abort error', async () => {
158
+ const { clientConn, cleanup } = connectStreamConns({
159
+ handlePacketStream() {},
160
+ })
161
+
162
+ try {
163
+ const stream = await clientConn.openStream()
164
+ const input = pushable<Uint8Array>({ objectMode: true })
165
+ const pending = stream.sink(input)
166
+ const error = new Error('stopped')
167
+
168
+ stream.abort(error)
169
+
170
+ await expect(pending).rejects.toBe(error)
171
+ } finally {
172
+ cleanup()
173
+ }
174
+ })
175
+
176
+ it('does not write ready input after close', async () => {
177
+ const serverStream = Promise.withResolvers<PacketStream>()
178
+ const { clientConn, cleanup } = connectStreamConns({
179
+ handlePacketStream(stream) {
180
+ serverStream.resolve(stream)
181
+ },
182
+ })
183
+
184
+ try {
185
+ const stream = await clientConn.openStream()
186
+ const peer = await serverStream.promise
187
+ const input = pushable<Uint8Array>({ objectMode: true })
188
+ input.push(new Uint8Array([1]))
189
+
190
+ const pending = stream.sink(input)
191
+ await stream.close()
192
+
193
+ await expect(pending).resolves.toBeUndefined()
194
+ await expect(nextWithTimeout(peer.source, 'server eof')).resolves.toEqual(
195
+ {
196
+ done: true,
197
+ value: undefined,
198
+ },
199
+ )
200
+ } finally {
201
+ cleanup()
202
+ }
203
+ })
204
+
74
205
  it('aborts the yamux stream when the packet source errors', async () => {
75
206
  const request = new TextEncoder().encode('request')
76
207
  const sourceError = new Error('source failed')
package/srpc/stream.ts CHANGED
@@ -8,16 +8,26 @@ import {
8
8
  parseLengthPrefixTransform,
9
9
  prependLengthPrefixTransform,
10
10
  } from './packet.js'
11
+ import {
12
+ closeIterator,
13
+ sourceIterator,
14
+ TerminationGate,
15
+ } from './termination.js'
11
16
 
12
17
  // PacketHandler handles incoming packets.
13
18
  export type PacketHandler = (packet: Packet) => Promise<void>
14
19
 
15
20
  // PacketStream represents a stream of packets where each Uint8Array represents one packet.
16
- export type PacketStream = Duplex<
21
+ export interface PacketStream extends Duplex<
17
22
  AsyncGenerator<Uint8Array>,
18
23
  Source<Uint8Array>,
19
24
  Promise<void>
20
- >
25
+ > {
26
+ // close cleanly ends both directions of the stream.
27
+ close(): Promise<void>
28
+ // abort ends both directions of the stream with err.
29
+ abort(err: Error): void
30
+ }
21
31
 
22
32
  // OpenStreamFunc is a function to start a new RPC by opening a Stream.
23
33
  export type OpenStreamFunc = () => Promise<PacketStream>
@@ -29,20 +39,66 @@ export type HandleStreamFunc = (ch: PacketStream) => Promise<void>
29
39
 
30
40
  // streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
31
41
  export function streamToPacketStream(stream: Stream): PacketStream {
42
+ const termination = new TerminationGate()
32
43
  return {
33
- source: pipe(
34
- stream,
35
- parseLengthPrefixTransform(),
36
- combineUint8ArrayListTransform(),
37
- ),
44
+ close: async () => {
45
+ if (termination.terminate()) await stream.close()
46
+ },
47
+ abort: (err: Error) => {
48
+ if (termination.terminate(err)) stream.abort(err)
49
+ },
50
+ source: (async function* () {
51
+ const packets = pipe(
52
+ stream,
53
+ parseLengthPrefixTransform(),
54
+ combineUint8ArrayListTransform(),
55
+ )[Symbol.asyncIterator]()
56
+ try {
57
+ while (true) {
58
+ const next = await termination.next(packets)
59
+ if ('terminated' in next) {
60
+ if (next.error) throw next.error
61
+ return
62
+ }
63
+ if ('error' in next) throw next.error
64
+ if (next.result.done) return
65
+ yield next.result.value
66
+ }
67
+ } finally {
68
+ closeIterator(packets)
69
+ }
70
+ })(),
38
71
  sink: async (source: Source<Uint8Array>): Promise<void> => {
72
+ const iterator = sourceIterator(
73
+ pipe(source, prependLengthPrefixTransform()),
74
+ )
75
+ const gatedSource = (async function* () {
76
+ while (true) {
77
+ const next = await termination.next(iterator)
78
+ if ('terminated' in next) {
79
+ if (next.error) throw next.error
80
+ return
81
+ }
82
+ if ('error' in next) throw next.error
83
+ if (next.result.done) return
84
+ if (termination.terminated) return
85
+ yield next.result.value
86
+ }
87
+ })()
39
88
  try {
40
- await pipe(source, prependLengthPrefixTransform(), stream)
89
+ const result = await termination.wait(stream.sink(gatedSource))
90
+ if ('terminated' in result) {
91
+ if (result.error) throw result.error
92
+ return
93
+ }
94
+ if ('error' in result) throw result.error
41
95
  await stream.closeWrite()
42
96
  } catch (err) {
43
97
  const error = err instanceof Error ? err : new Error(String(err))
44
- stream.abort(error)
98
+ if (termination.terminate(error)) stream.abort(error)
45
99
  throw error
100
+ } finally {
101
+ closeIterator(iterator)
46
102
  }
47
103
  },
48
104
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { TerminationGate } from './termination.js'
4
+
5
+ describe('TerminationGate', () => {
6
+ it('prioritizes termination over an already-ready result', async () => {
7
+ const gate = new TerminationGate()
8
+ const iterator = (async function* () {
9
+ yield 1
10
+ })()
11
+ const pending = gate.next(iterator)
12
+ const error = new Error('stopped')
13
+
14
+ gate.terminate(error)
15
+
16
+ await expect(pending).resolves.toEqual({ terminated: true, error })
17
+ })
18
+
19
+ it('keeps the first termination result', async () => {
20
+ const gate = new TerminationGate()
21
+ const error = new Error('stopped')
22
+
23
+ expect(gate.terminate(error)).toBe(true)
24
+ expect(gate.terminate()).toBe(false)
25
+ await expect(gate.wait(new Promise<void>(() => {}))).resolves.toEqual({
26
+ terminated: true,
27
+ error,
28
+ })
29
+ })
30
+ })
@@ -0,0 +1,70 @@
1
+ import type { Source } from 'it-stream-types'
2
+
3
+ type Terminated = { terminated: true; error?: Error }
4
+ type Received<T> = { result: IteratorResult<T> } | { error: unknown }
5
+ type WaitResult<T> = { result: T } | { error: unknown }
6
+
7
+ // TerminationGate makes the first clean close or abort visible to blocked I/O.
8
+ export class TerminationGate {
9
+ private readonly _waiters = new Set<(error?: Error) => void>()
10
+ private _error: Error | undefined
11
+ private _terminated = false
12
+
13
+ public get terminated(): boolean {
14
+ return this._terminated
15
+ }
16
+
17
+ public terminate(error?: Error): boolean {
18
+ if (this._terminated) return false
19
+ this._terminated = true
20
+ this._error = error
21
+ for (const waiter of this._waiters) waiter(error)
22
+ this._waiters.clear()
23
+ return true
24
+ }
25
+
26
+ public async wait<T>(
27
+ promise: Promise<T>,
28
+ ): Promise<WaitResult<T> | Terminated> {
29
+ if (this._terminated) return { terminated: true, error: this._error }
30
+
31
+ let notify!: (error?: Error) => void
32
+ const terminated = new Promise<Terminated>((resolve) => {
33
+ notify = (error) => resolve({ terminated: true, error })
34
+ this._waiters.add(notify)
35
+ })
36
+ const received: Promise<WaitResult<T>> = promise.then(
37
+ (result) => ({ result }),
38
+ (error: unknown) => ({ error }),
39
+ )
40
+
41
+ try {
42
+ const result = await Promise.race([received, terminated])
43
+ if (this._terminated) {
44
+ return { terminated: true, error: this._error }
45
+ }
46
+ return result
47
+ } finally {
48
+ this._waiters.delete(notify)
49
+ }
50
+ }
51
+
52
+ public next<T>(
53
+ iterator: AsyncIterator<T>,
54
+ ): Promise<Received<T> | Terminated> {
55
+ return this.wait(iterator.next())
56
+ }
57
+ }
58
+
59
+ export function sourceIterator<T>(source: Source<T>): AsyncIterator<T> {
60
+ if (Symbol.asyncIterator in source) return source[Symbol.asyncIterator]()
61
+ const iterator = source[Symbol.iterator]()
62
+ return { next: async () => iterator.next() }
63
+ }
64
+ // closeIterator asks an abandoned iterator to release its upstream resources.
65
+ // Async generators may wait for an active next call before running return;
66
+ // rejection is observed here so cleanup cannot create an unhandled promise.
67
+ export function closeIterator<T>(iterator: AsyncIterator<T>): void {
68
+ if (!iterator.return) return
69
+ void Promise.resolve(iterator.return()).catch(() => undefined)
70
+ }
@@ -7,6 +7,7 @@ describe('Watchdog', () => {
7
7
  })
8
8
 
9
9
  afterEach(() => {
10
+ vi.useRealTimers()
10
11
  vi.restoreAllMocks()
11
12
  })
12
13