starpc 0.52.0 → 0.52.2

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 (152) hide show
  1. package/README.md +37 -8
  2. package/dist/echo/client-test.d.ts +1 -1
  3. package/dist/echo/client-test.js +110 -2
  4. package/dist/integration/cross-language/tcp-packet-stream.d.ts +3 -0
  5. package/dist/integration/cross-language/tcp-packet-stream.js +112 -0
  6. package/dist/integration/cross-language/tcp-packet-stream.test.d.ts +1 -0
  7. package/dist/integration/cross-language/tcp-packet-stream.test.js +121 -0
  8. package/dist/integration/cross-language/ts-client.js +50 -37
  9. package/dist/integration/cross-language/ts-server.js +1 -36
  10. package/dist/rpcstream/rpcstream.d.ts +5 -1
  11. package/dist/rpcstream/rpcstream.js +75 -28
  12. package/dist/rpcstream/rpcstream.test.d.ts +1 -0
  13. package/dist/rpcstream/rpcstream.test.js +92 -0
  14. package/dist/srpc/client-rpc.d.ts +1 -0
  15. package/dist/srpc/client-rpc.js +9 -0
  16. package/dist/srpc/client-rpc.test.d.ts +1 -0
  17. package/dist/srpc/client-rpc.test.js +50 -0
  18. package/dist/srpc/client.js +18 -4
  19. package/dist/srpc/common-rpc.d.ts +5 -0
  20. package/dist/srpc/common-rpc.js +37 -8
  21. package/dist/srpc/common-rpc.test.js +2 -0
  22. package/dist/srpc/errors.d.ts +2 -0
  23. package/dist/srpc/errors.js +14 -0
  24. package/dist/srpc/index.d.ts +1 -1
  25. package/dist/srpc/index.js +1 -1
  26. package/dist/srpc/packet-codec.test.d.ts +1 -0
  27. package/dist/srpc/packet-codec.test.js +75 -0
  28. package/dist/srpc/packet.d.ts +1 -1
  29. package/dist/srpc/packet.js +11 -1
  30. package/dist/srpc/server.js +19 -6
  31. package/dist/srpc/server.test.js +43 -0
  32. package/dist/srpc/stream.d.ts +4 -1
  33. package/dist/srpc/stream.js +62 -3
  34. package/dist/srpc/stream.test.js +110 -1
  35. package/dist/srpc/termination.d.ts +27 -0
  36. package/dist/srpc/termination.js +56 -0
  37. package/dist/srpc/termination.test.d.ts +1 -0
  38. package/dist/srpc/termination.test.js +24 -0
  39. package/dist/testdata/packet-codec-vectors.json +64 -0
  40. package/echo/Cargo.toml +8 -0
  41. package/echo/client-test.ts +124 -2
  42. package/echo/echo_e2e_test.cpp +542 -985
  43. package/echo/echo_pb2.py +40 -0
  44. package/echo/echo_pb2.pyi +13 -0
  45. package/echo/echo_srpc.pb.cpp +3 -5
  46. package/echo/echo_srpc.pb.go +6 -9
  47. package/echo/echo_srpc.pb.hpp +23 -3
  48. package/echo/echo_srpc.pb.rs +61 -26
  49. package/echo/echo_srpc.py +309 -0
  50. package/echo/echo_srpc.pyi +85 -0
  51. package/echo/half_close_test.rs +178 -0
  52. package/echo/integration_server.rs +14 -21
  53. package/echo/main.rs +28 -48
  54. package/go.mod +3 -3
  55. package/go.sum +6 -8
  56. package/integration/cross-language/cpp-client.cpp +446 -402
  57. package/integration/cross-language/cpp-pipe-server.cpp +139 -0
  58. package/integration/cross-language/cpp-pipe_test.go +114 -0
  59. package/integration/cross-language/cpp-server.cpp +222 -186
  60. package/integration/cross-language/go-client/main.go +79 -3
  61. package/integration/cross-language/python-client.py +146 -0
  62. package/integration/cross-language/python-server.py +140 -0
  63. package/integration/cross-language/run.bash +190 -65
  64. package/integration/cross-language/tcp-packet-stream.test.ts +154 -0
  65. package/integration/cross-language/tcp-packet-stream.ts +121 -0
  66. package/integration/cross-language/ts-client.ts +62 -40
  67. package/integration/cross-language/ts-server.ts +1 -45
  68. package/mock/mock_pb2.py +38 -0
  69. package/mock/mock_pb2.pyi +11 -0
  70. package/mock/mock_srpc.pb.cpp +1 -1
  71. package/mock/mock_srpc.pb.hpp +1 -1
  72. package/mock/mock_srpc.pb.rs +10 -9
  73. package/mock/mock_srpc.py +71 -0
  74. package/mock/mock_srpc.pyi +27 -0
  75. package/package.json +19 -5
  76. package/srpc/CMakeLists.txt +3 -58
  77. package/srpc/README-cpp.md +69 -0
  78. package/srpc/__init__.py +0 -0
  79. package/srpc/accept.go +10 -4
  80. package/srpc/accept_test.go +25 -0
  81. package/srpc/build.rs +129 -34
  82. package/srpc/client-half-close_test.go +152 -0
  83. package/srpc/client-invoker.go +32 -42
  84. package/srpc/client-invoker_test.go +53 -0
  85. package/srpc/client-rpc.cpp +65 -80
  86. package/srpc/client-rpc.go +32 -33
  87. package/srpc/client-rpc.hpp +52 -38
  88. package/srpc/client-rpc.test.ts +67 -0
  89. package/srpc/client-rpc.ts +10 -0
  90. package/srpc/client-rpc_test.go +76 -0
  91. package/srpc/client.cpp +99 -87
  92. package/srpc/client.hpp +46 -35
  93. package/srpc/client.rs +78 -38
  94. package/srpc/client.ts +20 -4
  95. package/srpc/client_half_close_test.cc +331 -0
  96. package/srpc/client_invoker.rs +75 -0
  97. package/srpc/codec.rs +6 -0
  98. package/srpc/common-rpc.cpp +186 -146
  99. package/srpc/common-rpc.go +5 -4
  100. package/srpc/common-rpc.hpp +119 -89
  101. package/srpc/common-rpc.test.ts +2 -0
  102. package/srpc/common-rpc.ts +43 -8
  103. package/srpc/common-rpc_test.go +23 -0
  104. package/srpc/error.rs +19 -2
  105. package/srpc/errors.hpp +80 -50
  106. package/srpc/errors.ts +16 -0
  107. package/srpc/handler.hpp +9 -7
  108. package/srpc/handler.rs +7 -45
  109. package/srpc/index.ts +2 -0
  110. package/srpc/invoker.hpp +82 -55
  111. package/srpc/invoker.rs +14 -52
  112. package/srpc/lib.rs +2 -0
  113. package/srpc/message.hpp +17 -12
  114. package/srpc/msg-stream.go +1 -0
  115. package/srpc/msg-stream.hpp +108 -61
  116. package/srpc/mux.cpp +84 -84
  117. package/srpc/mux.hpp +48 -38
  118. package/srpc/mux.rs +4 -4
  119. package/srpc/net.go +12 -3
  120. package/srpc/packet-codec-vectors_test.go +195 -0
  121. package/srpc/packet-codec.test.ts +139 -0
  122. package/srpc/packet-rw.go +9 -2
  123. package/srpc/packet.cpp +60 -58
  124. package/srpc/packet.hpp +35 -20
  125. package/srpc/packet.rs +8 -27
  126. package/srpc/packet.ts +15 -2
  127. package/srpc/py.typed +0 -0
  128. package/srpc/rpc.rs +332 -115
  129. package/srpc/rpc_lifecycle_test.cc +333 -0
  130. package/srpc/rpcproto_pb2.py +40 -0
  131. package/srpc/rpcproto_pb2.pyi +40 -0
  132. package/srpc/server-cancel_test.go +39 -0
  133. package/srpc/server-rpc.cpp +83 -73
  134. package/srpc/server-rpc.go +21 -19
  135. package/srpc/server-rpc.hpp +56 -37
  136. package/srpc/server.rs +76 -131
  137. package/srpc/server.test.ts +50 -0
  138. package/srpc/server.ts +22 -6
  139. package/srpc/starpc.hpp +4 -2
  140. package/srpc/stream.go +4 -0
  141. package/srpc/stream.hpp +95 -34
  142. package/srpc/stream.rs +7 -2
  143. package/srpc/stream.test.ts +132 -1
  144. package/srpc/stream.ts +65 -9
  145. package/srpc/termination.test.ts +30 -0
  146. package/srpc/termination.ts +70 -0
  147. package/srpc/testing.rs +11 -66
  148. package/srpc/transport.rs +21 -71
  149. package/srpc/websocket.rs +106 -57
  150. package/srpc/writer.hpp +46 -32
  151. package/srpc/yamux.rs +82 -26
  152. package/srpc/tests/integration_test.rs +0 -495
package/srpc/stream.hpp CHANGED
@@ -1,61 +1,122 @@
1
1
  #pragma once
2
2
 
3
- #include <functional>
4
- #include <memory>
5
-
6
3
  #include "errors.hpp"
7
4
  #include "message.hpp"
8
5
 
6
+ #include <atomic>
7
+ #include <functional>
8
+ #include <memory>
9
+ #include <stop_token>
10
+ #include <string>
11
+
9
12
  namespace starpc {
10
13
 
11
- // Stream is a handle to an on-going bi-directional or one-directional stream
12
- // RPC handle. Matches Go Stream interface in stream.go
14
+ /*
15
+ * Stream is one live RPC stream between the caller and the remote. Matches
16
+ * the Go Stream interface in stream.go.
17
+ */
13
18
  class Stream {
14
19
  public:
15
- virtual ~Stream() = default;
20
+ virtual ~Stream() = default;
21
+
22
+ /* StopToken interrupts work when the RPC ends or is canceled. */
23
+ virtual std::stop_token StopToken() const
24
+ {
25
+ return {};
26
+ }
16
27
 
17
- // MsgSend sends the message to the remote.
18
- virtual Error MsgSend(const Message &msg) = 0;
28
+ /*
29
+ * RemoteErrorMessage returns the diagnostic accompanying
30
+ * Error::RemoteError.
31
+ */
32
+ virtual std::string RemoteErrorMessage() const
33
+ {
34
+ return {};
35
+ }
19
36
 
20
- // MsgRecv receives an incoming message from the remote.
21
- // Parses the message into the object at msg.
22
- virtual Error MsgRecv(Message *msg) = 0;
37
+ /* MsgSend serializes msg and writes it to the remote. */
38
+ virtual Error MsgSend(const Message &msg) = 0;
23
39
 
24
- // CloseSend signals to the remote that we will no longer send any messages.
25
- virtual Error CloseSend() = 0;
40
+ /*
41
+ * MsgRecv reads the next message from the remote and parses it into
42
+ * msg. Returns EOF_ after the call completes.
43
+ */
44
+ virtual Error MsgRecv(Message *msg) = 0;
26
45
 
27
- // Close closes the stream for reading and writing.
28
- virtual Error Close() = 0;
46
+ /*
47
+ * CloseSend half-closes: no further messages will be sent. Once the
48
+ * request is written, the half-close is only a notice: it fails when
49
+ * the call already ended, and MsgRecv then returns the buffered
50
+ * messages followed by the call's outcome.
51
+ */
52
+ virtual Error CloseSend() = 0;
53
+
54
+ /* Close ends the stream for both reading and writing. */
55
+ virtual Error Close() = 0;
29
56
  };
30
57
 
31
- // StreamWithClose wraps a Stream with a close function to call when Close is
32
- // called. Matches streamWithClose in stream.go
58
+ /*
59
+ * StreamWithClose forwards a Stream and runs an extra callback on the first
60
+ * Close. Matches streamWithClose in stream.go.
61
+ */
33
62
  class StreamWithClose : public Stream {
34
63
  public:
35
- StreamWithClose(Stream *inner, std::function<Error()> close_fn)
36
- : inner_(inner), close_fn_(std::move(close_fn)) {}
64
+ /*
65
+ * StreamWithClose borrows inner; it must outlive the wrapper.
66
+ * close_fn runs once, after inner->Close.
67
+ */
68
+ StreamWithClose(Stream *inner, std::function<Error()> close_fn)
69
+ : inner(inner),
70
+ close_fn(std::move(close_fn))
71
+ {
72
+ }
73
+
74
+ Error MsgSend(const Message &msg) override
75
+ {
76
+ return inner->MsgSend(msg);
77
+ }
78
+ Error MsgRecv(Message *msg) override
79
+ {
80
+ return inner->MsgRecv(msg);
81
+ }
82
+ Error CloseSend() override
83
+ {
84
+ return inner->CloseSend();
85
+ }
86
+ std::stop_token StopToken() const override
87
+ {
88
+ return inner->StopToken();
89
+ }
90
+ std::string RemoteErrorMessage() const override
91
+ {
92
+ return inner->RemoteErrorMessage();
93
+ }
37
94
 
38
- Error MsgSend(const Message &msg) override { return inner_->MsgSend(msg); }
39
- Error MsgRecv(Message *msg) override { return inner_->MsgRecv(msg); }
40
- Error CloseSend() override { return inner_->CloseSend(); }
95
+ Error Close() override
96
+ {
97
+ if (closed.exchange(true))
98
+ return Error::OK;
41
99
 
42
- Error Close() override {
43
- Error err = inner_->Close();
44
- Error err2 = close_fn_();
45
- if (err != Error::OK)
46
- return err;
47
- return err2;
48
- }
100
+ /* Report the inner close even when the callback also fails. */
101
+ Error err = inner->Close();
102
+ Error err2 = close_fn();
103
+ if (err != Error::OK)
104
+ return err;
105
+ return err2;
106
+ }
49
107
 
50
108
  private:
51
- Stream *inner_;
52
- std::function<Error()> close_fn_;
109
+ /* inner is borrowed; the caller owns it. */
110
+ Stream *inner;
111
+ std::function<Error()> close_fn;
112
+ std::atomic<bool> closed{false};
53
113
  };
54
114
 
55
- // NewStreamWithClose wraps a Stream with a close function.
115
+ /* NewStreamWithClose wraps strm so close_fn runs on the first Close. */
56
116
  inline std::unique_ptr<Stream>
57
- NewStreamWithClose(Stream *strm, std::function<Error()> close_fn) {
58
- return std::make_unique<StreamWithClose>(strm, std::move(close_fn));
117
+ NewStreamWithClose(Stream *strm, std::function<Error()> close_fn)
118
+ {
119
+ return std::make_unique<StreamWithClose>(strm, std::move(close_fn));
59
120
  }
60
121
 
61
122
  } // namespace starpc
package/srpc/stream.rs CHANGED
@@ -110,7 +110,7 @@ impl Context {
110
110
  ///
111
111
  /// - All methods are async and may block waiting for I/O or messages
112
112
  /// - `send_bytes` may return `Error::Completed` if `close_send` was already called
113
- /// - `recv_bytes` returns `Error::StreamClosed` when the remote closes
113
+ /// - `recv_bytes` returns `Error::StreamClosed` when the remote completes
114
114
  /// - `close` cancels the context and releases all resources
115
115
  #[async_trait]
116
116
  pub trait Stream: Send + Sync {
@@ -133,8 +133,9 @@ pub trait Stream: Send + Sync {
133
133
  ///
134
134
  /// # Errors
135
135
  ///
136
- /// - `Error::StreamClosed` if the remote closed the stream
136
+ /// - `Error::StreamClosed` if the remote completed the call
137
137
  /// - `Error::Remote` if the remote sent an error
138
+ /// - `Error::ClosedBeforeCompletion` if the transport closed first
138
139
  /// - `Error::Cancelled` if the context was cancelled
139
140
  async fn recv_bytes(&self) -> Result<Bytes>;
140
141
 
@@ -142,6 +143,10 @@ pub trait Stream: Send + Sync {
142
143
  ///
143
144
  /// After calling this, `send_bytes` will return `Error::Completed`.
144
145
  /// The receive side remains open until the remote closes.
146
+ ///
147
+ /// Once the request is written, the half-close is only a notice. It fails
148
+ /// when the call already ended, and `recv_bytes` then returns the buffered
149
+ /// messages followed by the call's outcome.
145
150
  async fn close_send(&self) -> Result<()>;
146
151
 
147
152
  /// Closes both sides of the stream.
@@ -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
+ }
package/srpc/testing.rs CHANGED
@@ -13,22 +13,8 @@ use crate::error::{Error, Result};
13
13
  use crate::rpc::PacketWriter;
14
14
  use crate::transport::create_packet_channel;
15
15
 
16
- /// Creates a pair of connected in-memory streams.
17
- ///
18
- /// Returns (client_stream, server_stream) that can be used for testing
19
- /// client-server communication.
20
- ///
21
- /// # Arguments
22
- /// * `buffer_size` - Size of the internal buffer for each direction
23
- ///
24
- /// # Example
25
- ///
26
- /// ```ignore
27
- /// let (client_stream, server_stream) = create_pipe(64 * 1024);
28
- ///
29
- /// // Use client_stream with a client
30
- /// // Use server_stream with a server
31
- /// ```
16
+ /// Creates a pair of connected in-memory streams for testing client-server
17
+ /// communication, with `buffer_size` bytes of buffer in each direction.
32
18
  pub fn create_pipe(buffer_size: usize) -> (DuplexStream, DuplexStream) {
33
19
  duplex(buffer_size)
34
20
  }
@@ -38,21 +24,8 @@ pub fn create_pipe_default() -> (DuplexStream, DuplexStream) {
38
24
  create_pipe(64 * 1024)
39
25
  }
40
26
 
41
- /// In-memory stream opener for testing.
42
- ///
43
- /// This opener can hold multiple streams and returns them one at a time
44
- /// on each call to `open_stream`.
45
- ///
46
- /// # Example
47
- ///
48
- /// ```ignore
49
- /// let (client_stream, server_stream) = create_pipe_default();
50
- ///
51
- /// let opener = InMemoryOpener::new(vec![client_stream]);
52
- /// let client = SrpcClient::new(opener);
53
- ///
54
- /// // The client will use client_stream for its RPC
55
- /// ```
27
+ /// In-memory stream opener for testing that holds multiple streams and
28
+ /// returns them one at a time on each call to `open_stream`.
56
29
  pub struct InMemoryOpener {
57
30
  streams: Arc<Mutex<Vec<DuplexStream>>>,
58
31
  }
@@ -108,40 +81,15 @@ impl SingleInMemoryOpener {
108
81
  #[async_trait]
109
82
  impl OpenStream for SingleInMemoryOpener {
110
83
  async fn open_stream(&self) -> Result<(Arc<dyn PacketWriter>, PacketReceiver)> {
111
- let stream = self
112
- .stream
113
- .lock()
114
- .await
115
- .take()
116
- .ok_or(Error::StreamClosed)?;
84
+ let stream = self.stream.lock().await.take().ok_or(Error::StreamClosed)?;
117
85
 
118
86
  let (read_half, write_half) = tokio::io::split(stream);
119
87
  Ok(create_packet_channel(read_half, write_half))
120
88
  }
121
89
  }
122
90
 
123
- /// Creates a connected client-server test setup.
124
- ///
125
- /// Returns (client_opener, server_stream) where:
126
- /// - `client_opener` can be used to create a `SrpcClient`
127
- /// - `server_stream` can be passed to `Server::handle_stream`
128
- ///
129
- /// # Example
130
- ///
131
- /// ```ignore
132
- /// let (opener, server_stream) = create_test_pair();
133
- ///
134
- /// let client = SrpcClient::new(opener);
135
- /// let server = Server::new(mux);
136
- ///
137
- /// // Spawn server
138
- /// tokio::spawn(async move {
139
- /// server.handle_stream(server_stream).await
140
- /// });
141
- ///
142
- /// // Use client
143
- /// let response = client.exec_call("svc", "method", &request).await?;
144
- /// ```
91
+ /// Creates a connected client-server test setup: an opener for a
92
+ /// `SrpcClient` and the server stream for `Server::handle_stream`.
145
93
  pub fn create_test_pair() -> (SingleInMemoryOpener, DuplexStream) {
146
94
  let (client_stream, server_stream) = create_pipe_default();
147
95
  (SingleInMemoryOpener::new(client_stream), server_stream)
@@ -225,13 +173,10 @@ mod tests {
225
173
  stream.close_send().await.unwrap();
226
174
 
227
175
  // Read response with a timeout to handle potential races.
228
- let response = tokio::time::timeout(
229
- std::time::Duration::from_secs(5),
230
- stream.recv_bytes(),
231
- )
232
- .await
233
- .expect("timeout")
234
- .expect("recv_bytes failed");
176
+ let response = tokio::time::timeout(std::time::Duration::from_secs(5), stream.recv_bytes())
177
+ .await
178
+ .expect("timeout")
179
+ .expect("recv_bytes failed");
235
180
  assert_eq!(&response[..], b"hello");
236
181
 
237
182
  // Wait for server to complete.