starpc 0.52.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 (65) hide show
  1. package/dist/echo/client-test.d.ts +1 -1
  2. package/dist/echo/client-test.js +110 -2
  3. package/dist/integration/cross-language/tcp-packet-stream.d.ts +3 -0
  4. package/dist/integration/cross-language/tcp-packet-stream.js +112 -0
  5. package/dist/integration/cross-language/tcp-packet-stream.test.d.ts +1 -0
  6. package/dist/integration/cross-language/tcp-packet-stream.test.js +121 -0
  7. package/dist/integration/cross-language/ts-client.js +50 -37
  8. package/dist/integration/cross-language/ts-server.js +1 -36
  9. package/dist/rpcstream/rpcstream.d.ts +5 -1
  10. package/dist/rpcstream/rpcstream.js +75 -28
  11. package/dist/rpcstream/rpcstream.test.d.ts +1 -0
  12. package/dist/rpcstream/rpcstream.test.js +92 -0
  13. package/dist/srpc/client.js +18 -4
  14. package/dist/srpc/common-rpc.test.js +2 -0
  15. package/dist/srpc/packet-codec.test.d.ts +1 -0
  16. package/dist/srpc/packet-codec.test.js +75 -0
  17. package/dist/srpc/packet.d.ts +1 -1
  18. package/dist/srpc/packet.js +11 -1
  19. package/dist/srpc/server.js +19 -6
  20. package/dist/srpc/server.test.js +43 -0
  21. package/dist/srpc/stream.d.ts +4 -1
  22. package/dist/srpc/stream.js +62 -3
  23. package/dist/srpc/stream.test.js +110 -1
  24. package/dist/srpc/termination.d.ts +27 -0
  25. package/dist/srpc/termination.js +56 -0
  26. package/dist/srpc/termination.test.d.ts +1 -0
  27. package/dist/srpc/termination.test.js +24 -0
  28. package/dist/testdata/packet-codec-vectors.json +64 -0
  29. package/echo/client-test.ts +124 -2
  30. package/echo/echo_pb2.py +40 -0
  31. package/echo/echo_pb2.pyi +13 -0
  32. package/echo/echo_srpc.py +306 -0
  33. package/echo/echo_srpc.pyi +85 -0
  34. package/go.mod +2 -2
  35. package/go.sum +14 -0
  36. package/integration/cross-language/go-client/main.go +79 -3
  37. package/integration/cross-language/python-client.py +146 -0
  38. package/integration/cross-language/python-server.py +140 -0
  39. package/integration/cross-language/run.bash +190 -65
  40. package/integration/cross-language/tcp-packet-stream.test.ts +154 -0
  41. package/integration/cross-language/tcp-packet-stream.ts +121 -0
  42. package/integration/cross-language/ts-client.ts +62 -40
  43. package/integration/cross-language/ts-server.ts +1 -45
  44. package/mock/mock_pb2.py +38 -0
  45. package/mock/mock_pb2.pyi +11 -0
  46. package/mock/mock_srpc.py +71 -0
  47. package/mock/mock_srpc.pyi +27 -0
  48. package/package.json +19 -5
  49. package/srpc/__init__.py +0 -0
  50. package/srpc/client.ts +20 -4
  51. package/srpc/codec.rs +6 -0
  52. package/srpc/common-rpc.test.ts +2 -0
  53. package/srpc/packet-codec-vectors_test.go +195 -0
  54. package/srpc/packet-codec.test.ts +139 -0
  55. package/srpc/packet-rw.go +9 -2
  56. package/srpc/packet.ts +15 -2
  57. package/srpc/py.typed +0 -0
  58. package/srpc/rpcproto_pb2.py +40 -0
  59. package/srpc/rpcproto_pb2.pyi +40 -0
  60. package/srpc/server.test.ts +50 -0
  61. package/srpc/server.ts +22 -6
  62. package/srpc/stream.test.ts +132 -1
  63. package/srpc/stream.ts +65 -9
  64. package/srpc/termination.test.ts +30 -0
  65. package/srpc/termination.ts +70 -0
@@ -1,4 +1,5 @@
1
1
  import { pushable } from 'it-pushable';
2
+ import { closeIterator, sourceIterator, TerminationGate, } from '../srpc/termination.js';
2
3
  // openRpcStream attempts to open a stream over a RPC call.
3
4
  // if waitAck is set, waits for the remote to ack the stream before returning.
4
5
  export async function openRpcStream(componentId, caller, waitAck) {
@@ -33,7 +34,7 @@ export async function openRpcStream(componentId, caller, waitAck) {
33
34
  }
34
35
  }
35
36
  // build & return the data stream
36
- return new RpcStream(packetTx, packetIt);
37
+ return new RpcStream(packetTx, packetIt, () => closeIterator(packetIt));
37
38
  }
38
39
  // buildRpcStreamOpenStream builds a OpenStream func with a RpcStream.
39
40
  export function buildRpcStreamOpenStream(componentId, caller) {
@@ -87,7 +88,7 @@ export async function* handleRpcStream(packetRx, getter) {
87
88
  // build the outgoing packet sink & the packet source
88
89
  const packetTx = pushable({ objectMode: true });
89
90
  // start the handler
90
- const rpcStream = new RpcStream(packetTx, packetRx);
91
+ const rpcStream = new RpcStream(packetTx, packetRx, () => closeIterator(packetRx));
91
92
  handler(rpcStream)
92
93
  .catch((err) => packetTx.end(err))
93
94
  .then(() => packetTx.end());
@@ -107,54 +108,100 @@ export class RpcStream {
107
108
  _packetRx;
108
109
  // _packetTx writes packets to the remote.
109
110
  _packetTx;
111
+ _termination = new TerminationGate();
112
+ _cancelRpc;
110
113
  // packetTx writes packets to the remote.
111
114
  // packetRx receives packets from the remote.
112
- constructor(packetTx, packetRx) {
115
+ constructor(packetTx, packetRx, cancelRpc = () => closeIterator(packetRx)) {
113
116
  this._packetTx = packetTx;
114
117
  this._packetRx = packetRx;
118
+ this._cancelRpc = cancelRpc;
115
119
  this.sink = this._createSink();
116
120
  this.source = this._createSource();
117
121
  }
122
+ // close cleanly ends both directions of the stream.
123
+ async close() {
124
+ if (this._termination.terminate()) {
125
+ this._packetTx.end();
126
+ this._cancelRpc();
127
+ }
128
+ }
129
+ // abort ends both directions of the stream with err.
130
+ abort(err) {
131
+ if (this._termination.terminate(err)) {
132
+ this._packetTx.end(err);
133
+ this._cancelRpc();
134
+ }
135
+ }
118
136
  // _createSink initializes the sink field.
119
137
  _createSink() {
120
138
  return async (source) => {
139
+ const iterator = sourceIterator(source);
121
140
  try {
122
- for await (const arr of source) {
141
+ while (true) {
142
+ const next = await this._termination.next(iterator);
143
+ if ('terminated' in next) {
144
+ if (next.error)
145
+ throw next.error;
146
+ return;
147
+ }
148
+ if ('error' in next)
149
+ throw next.error;
150
+ if (next.result.done) {
151
+ this._packetTx.end();
152
+ return;
153
+ }
154
+ if (this._termination.terminated)
155
+ return;
123
156
  this._packetTx.push({
124
- body: { case: 'data', value: arr },
157
+ body: { case: 'data', value: next.result.value },
125
158
  });
126
159
  }
127
- this._packetTx.end();
128
160
  }
129
161
  catch (err) {
130
- this._packetTx.end(err);
162
+ const error = err instanceof Error ? err : new Error(String(err));
163
+ this.abort(error);
164
+ throw error;
165
+ }
166
+ finally {
167
+ closeIterator(iterator);
131
168
  }
132
169
  };
133
170
  }
134
171
  // _createSource initializes the source field.
135
172
  _createSource() {
136
- return (async function* (packetRx) {
137
- while (true) {
138
- const msgIt = await packetRx.next();
139
- if (msgIt.done) {
140
- return;
141
- }
142
- const value = msgIt.value;
143
- const body = value?.body;
144
- if (!body) {
145
- continue;
146
- }
147
- switch (body.case) {
148
- case 'ack':
149
- if (body.value.error?.length) {
150
- throw new Error(body.value.error);
151
- }
152
- break;
153
- case 'data':
154
- yield body.value;
155
- break;
173
+ const packetRx = this._packetRx;
174
+ const termination = this._termination;
175
+ return (async function* () {
176
+ try {
177
+ while (true) {
178
+ const next = await termination.next(packetRx);
179
+ if ('terminated' in next) {
180
+ if (next.error)
181
+ throw next.error;
182
+ return;
183
+ }
184
+ if ('error' in next)
185
+ throw next.error;
186
+ if (next.result.done)
187
+ return;
188
+ const body = next.result.value?.body;
189
+ if (!body)
190
+ continue;
191
+ switch (body.case) {
192
+ case 'ack':
193
+ if (body.value.error?.length)
194
+ throw new Error(body.value.error);
195
+ break;
196
+ case 'data':
197
+ yield body.value;
198
+ break;
199
+ }
156
200
  }
157
201
  }
158
- })(this._packetRx);
202
+ finally {
203
+ closeIterator(packetRx);
204
+ }
205
+ })();
159
206
  }
160
207
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { pushable } from 'it-pushable';
3
+ import { RpcStream } from './rpcstream.js';
4
+ describe('RpcStream lifecycle', () => {
5
+ it('closes while its source is blocked', async () => {
6
+ const tx = pushable({ objectMode: true });
7
+ const rx = pushable({ objectMode: true });
8
+ const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
9
+ const pending = stream.source.next();
10
+ await stream.close();
11
+ await expect(pending).resolves.toEqual({ done: true, value: undefined });
12
+ await expect(tx.next()).resolves.toEqual({ done: true, value: undefined });
13
+ });
14
+ it('aborts while its source is blocked', async () => {
15
+ const tx = pushable({ objectMode: true });
16
+ const rx = pushable({ objectMode: true });
17
+ const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
18
+ const pending = stream.source.next();
19
+ const error = new Error('stopped');
20
+ stream.abort(error);
21
+ await expect(pending).rejects.toBe(error);
22
+ await expect(tx.next()).rejects.toBe(error);
23
+ });
24
+ it('closes while its sink source is blocked', async () => {
25
+ const tx = pushable({ objectMode: true });
26
+ const rx = pushable({ objectMode: true });
27
+ const input = pushable({ objectMode: true });
28
+ const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
29
+ const pending = stream.sink(input);
30
+ await stream.close();
31
+ await expect(pending).resolves.toBeUndefined();
32
+ await expect(tx.next()).resolves.toEqual({ done: true, value: undefined });
33
+ });
34
+ it('aborts while its sink source is blocked', async () => {
35
+ const tx = pushable({ objectMode: true });
36
+ const rx = pushable({ objectMode: true });
37
+ const input = pushable({ objectMode: true });
38
+ const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
39
+ const pending = stream.sink(input);
40
+ const error = new Error('stopped');
41
+ stream.abort(error);
42
+ await expect(pending).rejects.toBe(error);
43
+ await expect(tx.next()).rejects.toBe(error);
44
+ });
45
+ it('does not write input that becomes ready as the stream closes', async () => {
46
+ const tx = pushable({ objectMode: true });
47
+ const rx = pushable({ objectMode: true });
48
+ const input = pushable({ objectMode: true });
49
+ const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
50
+ input.push(new Uint8Array([1]));
51
+ const pending = stream.sink(input);
52
+ await stream.close();
53
+ await expect(pending).resolves.toBeUndefined();
54
+ await expect(tx.next()).resolves.toEqual({ done: true, value: undefined });
55
+ });
56
+ it('does not close when its sink completes', async () => {
57
+ const tx = pushable({ objectMode: true });
58
+ const rx = pushable({ objectMode: true });
59
+ const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
60
+ await stream.sink((async function* () { })());
61
+ rx.push({ body: { case: 'data', value: new Uint8Array([1]) } });
62
+ await expect(stream.source.next()).resolves.toMatchObject({ done: false });
63
+ });
64
+ it('finalizes a sink input iterator when closed', async () => {
65
+ const tx = pushable({ objectMode: true });
66
+ const rx = pushable({ objectMode: true });
67
+ const returned = vi.fn(() => Promise.resolve({ done: true, value: undefined }));
68
+ const input = {
69
+ [Symbol.asyncIterator]: () => ({
70
+ next: () => new Promise(() => { }),
71
+ return: returned,
72
+ }),
73
+ };
74
+ const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
75
+ const pending = stream.sink(input);
76
+ await stream.close();
77
+ await pending;
78
+ expect(returned).toHaveBeenCalledOnce();
79
+ });
80
+ it('cancels the outer RPC when aborted after sink completion', async () => {
81
+ const tx = pushable({ objectMode: true });
82
+ const returned = vi.fn(() => Promise.resolve({ done: true, value: undefined }));
83
+ const rx = {
84
+ next: () => new Promise(() => { }),
85
+ return: returned,
86
+ };
87
+ const stream = new RpcStream(tx, rx);
88
+ await stream.sink((async function* () { })());
89
+ stream.abort(new Error('stopped'));
90
+ expect(returned).toHaveBeenCalledOnce();
91
+ });
92
+ });
@@ -82,13 +82,27 @@ export class Client {
82
82
  const stream = await openStreamFn();
83
83
  const call = new ClientRPC(rpcService, rpcMethod);
84
84
  const onAbort = () => {
85
+ if (call.isClosed)
86
+ return;
87
+ const error = new Error(ERR_RPC_ABORT);
85
88
  void call.writeCallCancel().catch(() => undefined);
86
- void call.close(new Error(ERR_RPC_ABORT)).catch(() => undefined);
89
+ stream.abort(error);
90
+ void call.close(error).catch(() => undefined);
87
91
  };
88
92
  abortSignal?.addEventListener('abort', onAbort, { once: true });
89
- pipe(stream, decodePacketSource, call, encodePacketSource, stream)
90
- .catch((err) => call.close(err))
91
- .then(() => call.close())
93
+ void pipe(stream, decodePacketSource, call, encodePacketSource, stream)
94
+ .then(async () => {
95
+ if (call.isClosed instanceof Error) {
96
+ stream.abort(call.isClosed);
97
+ return;
98
+ }
99
+ await stream.close();
100
+ await call.close();
101
+ }, async (err) => {
102
+ const error = err instanceof Error ? err : new Error(String(err));
103
+ stream.abort(error);
104
+ await call.close(error);
105
+ })
92
106
  .finally(() => {
93
107
  abortSignal?.removeEventListener('abort', onAbort);
94
108
  });
@@ -101,6 +101,8 @@ describe('CommonRPC', () => {
101
101
  const responseGate = deferred();
102
102
  const response = new Uint8Array([7]);
103
103
  const client = new Client(async () => ({
104
+ close: async () => { },
105
+ abort: () => { },
104
106
  source: (async function* () {
105
107
  await responseGate.promise;
106
108
  yield Packet.toBinary({
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import vectors from '../testdata/packet-codec-vectors.json';
3
+ import { Packet } from './rpcproto.pb.js';
4
+ import { decodePacketSource, encodePacketSource, lengthPrefixDecode, prependLengthPrefixTransform, uint32LEDecode, } from './packet.js';
5
+ const bytes = (hex) => Uint8Array.from(hex.match(/../g) ?? [], (b) => parseInt(b, 16));
6
+ const hex = (data) => Buffer.from(data.subarray()).toString('hex');
7
+ const collect = async (source) => {
8
+ const out = [];
9
+ for await (const value of source)
10
+ out.push(value);
11
+ return out;
12
+ };
13
+ const validCases = vectors.cases.filter((entry) => Boolean(entry.packet_hex && entry.frame_hex));
14
+ describe('packet codec golden vectors', () => {
15
+ it.each(validCases)('$name has exact protobuf and frame bytes', async (entry) => {
16
+ const packet = Packet.fromBinary(bytes(entry.packet_hex));
17
+ const encoded = (await collect(encodePacketSource((async function* () {
18
+ yield packet;
19
+ })())))[0];
20
+ expect(hex(encoded)).toBe(entry.packet_hex);
21
+ const framed = (await collect(prependLengthPrefixTransform()((async function* () {
22
+ yield encoded;
23
+ })())))[0];
24
+ expect(hex(framed)).toBe(entry.frame_hex);
25
+ });
26
+ it('rejects zero and oversized encoded chunks', async () => {
27
+ const zero = (async function* () {
28
+ yield new Uint8Array();
29
+ })();
30
+ await expect(collect(prependLengthPrefixTransform()(zero))).rejects.toThrow('invalid packet length');
31
+ const oversized = (async function* () {
32
+ yield new Uint8Array(10_000_001);
33
+ })();
34
+ await expect(collect(prependLengthPrefixTransform()(oversized))).rejects.toThrow('invalid packet length');
35
+ });
36
+ it('rejects zero and oversized lengths, and truncated bodies', async () => {
37
+ const zero = (async function* () {
38
+ yield bytes('00000000');
39
+ })();
40
+ await expect(collect(lengthPrefixDecode(zero, uint32LEDecode))).rejects.toThrow('invalid packet length');
41
+ const oversized = (async function* () {
42
+ yield bytes('81969800');
43
+ })();
44
+ await expect(collect(lengthPrefixDecode(oversized, uint32LEDecode))).rejects.toThrow('invalid packet length');
45
+ const truncated = (async function* () {
46
+ yield bytes('040000000a01');
47
+ })();
48
+ await expect(collect(lengthPrefixDecode(truncated, uint32LEDecode))).rejects.toThrow('truncated packet frame');
49
+ });
50
+ it('decodes fragmented and coalesced frames', async () => {
51
+ const frames = validCases.map((entry) => bytes(entry.frame_hex));
52
+ const combined = new Uint8Array(frames.reduce((n, frame) => n + frame.length, 0));
53
+ let offset = 0;
54
+ for (const frame of frames) {
55
+ combined.set(frame, offset);
56
+ offset += frame.length;
57
+ }
58
+ const payloads = await collect(lengthPrefixDecode((async function* () {
59
+ yield combined.subarray(0, 3);
60
+ yield combined.subarray(3, 11);
61
+ yield combined.subarray(11);
62
+ })(), uint32LEDecode));
63
+ expect(payloads.map(hex)).toEqual(validCases.map((entry) => entry.packet_hex));
64
+ const decoded = await collect(decodePacketSource(payloads.map((payload) => payload.slice())));
65
+ expect(decoded).toHaveLength(validCases.length);
66
+ });
67
+ it('rejects malformed protobuf and incomplete frame prefix', async () => {
68
+ const malformed = vectors.cases.find((entry) => entry.name === 'malformed_complete');
69
+ expect(() => Packet.fromBinary(bytes(malformed.frame_hex.slice(8)))).toThrow();
70
+ const incomplete = (async function* () {
71
+ yield bytes('010203');
72
+ })();
73
+ await expect(collect(lengthPrefixDecode(incomplete, uint32LEDecode))).rejects.toThrow('truncated packet frame');
74
+ });
75
+ });
@@ -12,7 +12,7 @@ export declare namespace uint32LEEncode {
12
12
  var bytes: number;
13
13
  }
14
14
  export declare function lengthPrefixEncode(source: Source<Uint8Array | Uint8ArrayList>, lengthEncoder: typeof uint32LEEncode): AsyncGenerator<Uint8ArrayList<ArrayBufferLike>, void, unknown>;
15
- export declare function lengthPrefixDecode(source: Source<Uint8Array | Uint8ArrayList>, lengthDecoder: typeof uint32LEDecode): AsyncGenerator<Uint8ArrayList<ArrayBufferLike>, void, unknown>;
15
+ export declare function lengthPrefixDecode(source: Source<Uint8Array | Uint8ArrayList>, lengthDecoder: typeof uint32LEDecode): AsyncGenerator<Uint8ArrayList>;
16
16
  export declare function prependLengthPrefixTransform(lengthEncoder?: {
17
17
  (value: number): Uint8ArrayList<ArrayBuffer>;
18
18
  bytes: number;
@@ -1,6 +1,7 @@
1
1
  import { Uint8ArrayList } from 'uint8arraylist';
2
2
  import { Packet } from './rpcproto.pb.js';
3
3
  import { buildDecodeMessageTransform, buildEncodeMessageTransform, } from './message.js';
4
+ const MAX_MESSAGE_SIZE = 10_000_000;
4
5
  // decodePacketSource decodes packets from a binary data stream.
5
6
  export const decodePacketSource = buildDecodeMessageTransform(Packet);
6
7
  // encodePacketSource encodes packets from a packet object stream.
@@ -25,6 +26,9 @@ export async function* lengthPrefixEncode(source, lengthEncoder) {
25
26
  for await (const chunk of source) {
26
27
  // Encode the length of the chunk.
27
28
  const length = chunk instanceof Uint8Array ? chunk.length : chunk.byteLength;
29
+ if (length === 0 || length > MAX_MESSAGE_SIZE) {
30
+ throw RangeError(`invalid packet length: ${length}`);
31
+ }
28
32
  const lengthEncoded = lengthEncoder(length);
29
33
  // Concatenate the length prefix and the data.
30
34
  yield new Uint8ArrayList(lengthEncoded, chunk);
@@ -38,16 +42,22 @@ export async function* lengthPrefixDecode(source, lengthDecoder) {
38
42
  // Continue extracting messages while buffer contains enough data for decoding.
39
43
  while (buffer.length >= lengthDecoder.bytes) {
40
44
  const messageLength = lengthDecoder(buffer);
45
+ if (messageLength === 0 || messageLength > MAX_MESSAGE_SIZE) {
46
+ throw RangeError(`invalid packet length: ${messageLength}`);
47
+ }
41
48
  const totalLength = lengthDecoder.bytes + messageLength;
42
49
  if (buffer.length < totalLength)
43
50
  break; // Wait for more data if the full message hasn't arrived.
44
51
  // Extract the message excluding the length prefix.
45
- const message = buffer.sublist(lengthDecoder.bytes, totalLength);
52
+ const message = new Uint8ArrayList(buffer.slice(lengthDecoder.bytes, totalLength));
46
53
  yield message;
47
54
  // Remove the processed message from the buffer.
48
55
  buffer.consume(totalLength);
49
56
  }
50
57
  }
58
+ if (buffer.length !== 0) {
59
+ throw new RangeError('truncated packet frame');
60
+ }
51
61
  }
52
62
  // prependLengthPrefixTransform adds a length prefix to a message source.
53
63
  // little-endian uint32
@@ -13,9 +13,7 @@ export class Server {
13
13
  get rpcStreamHandler() {
14
14
  return async (stream) => {
15
15
  const rpc = this.startRpc();
16
- return pipe(stream, decodePacketSource, rpc, encodePacketSource, stream)
17
- .catch((err) => rpc.close(err))
18
- .then(() => rpc.close());
16
+ return runPacketStream(stream, rpc);
19
17
  };
20
18
  }
21
19
  // startRpc starts a new server-side RPC.
@@ -27,9 +25,24 @@ export class Server {
27
25
  // the stream has one Uint8Array per packet w/o length prefix.
28
26
  handlePacketStream(stream) {
29
27
  const rpc = this.startRpc();
30
- pipe(stream, decodePacketSource, rpc, encodePacketSource, stream)
31
- .catch((err) => rpc.close(err))
32
- .then(() => rpc.close());
28
+ void runPacketStream(stream, rpc).catch(() => undefined);
33
29
  return rpc;
34
30
  }
35
31
  }
32
+ async function runPacketStream(stream, rpc) {
33
+ try {
34
+ await pipe(stream, decodePacketSource, rpc, encodePacketSource, stream);
35
+ if (rpc.isClosed instanceof Error) {
36
+ stream.abort(rpc.isClosed);
37
+ throw rpc.isClosed;
38
+ }
39
+ await stream.close();
40
+ await rpc.close();
41
+ }
42
+ catch (err) {
43
+ const error = err instanceof Error ? err : new Error(String(err));
44
+ stream.abort(error);
45
+ await rpc.close(error);
46
+ throw error;
47
+ }
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, beforeEach, expect, vi } from 'vitest';
2
2
  import { pipe } from 'it-pipe';
3
+ import { pushable } from 'it-pushable';
3
4
  import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, Packet, createContextKey, serverContextValue, withServerContextValue, } from '../srpc/index.js';
4
5
  import { EchoerDefinition, EchoerServer, EchoerServiceName, EchoMsg, runClientTest, } from '../echo/index.js';
5
6
  import { runAbortControllerTest, runRpcStreamTest, } from '../echo/client-test.js';
@@ -112,6 +113,8 @@ describe('srpc server', () => {
112
113
  const server = new Server(mux.lookupMethod);
113
114
  const firstResponse = new Promise((resolve, reject) => {
114
115
  server.handlePacketStream({
116
+ close: async () => { },
117
+ abort: () => { },
115
118
  source: (async function* () {
116
119
  yield Packet.toBinary({
117
120
  body: {
@@ -229,6 +232,46 @@ describe('srpc server', () => {
229
232
  controller.abort();
230
233
  await Promise.resolve();
231
234
  });
235
+ it('closes the packet stream when the server pipeline completes', async () => {
236
+ const server = new Server(createMux().lookupMethod);
237
+ const close = vi.fn(async () => { });
238
+ const source = pushable({ objectMode: true });
239
+ const stream = {
240
+ close,
241
+ abort: vi.fn(),
242
+ source,
243
+ sink: async (output) => {
244
+ for await (const _packet of output) {
245
+ // Drain the response pipeline.
246
+ }
247
+ },
248
+ };
249
+ const rpc = server.handlePacketStream(stream);
250
+ await rpc.close();
251
+ source.end();
252
+ await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
253
+ expect(stream.abort).not.toHaveBeenCalled();
254
+ });
255
+ it('aborts the packet stream when the server pipeline fails', async () => {
256
+ const server = new Server(createMux().lookupMethod);
257
+ const error = new Error('input failed');
258
+ const abort = vi.fn();
259
+ const source = pushable({ objectMode: true });
260
+ const stream = {
261
+ close: vi.fn(async () => { }),
262
+ abort,
263
+ source,
264
+ sink: async (output) => {
265
+ for await (const _packet of output) {
266
+ // Drain the response pipeline.
267
+ }
268
+ },
269
+ };
270
+ server.handlePacketStream(stream);
271
+ source.end(error);
272
+ await vi.waitFor(() => expect(abort).toHaveBeenCalledWith(error));
273
+ expect(stream.close).not.toHaveBeenCalled();
274
+ });
232
275
  it('tears down passive channel close state', async () => {
233
276
  const { port1, port2 } = new MessageChannel();
234
277
  const opts = { idleTimeoutMs: 1000, keepAliveMs: 1000 };
@@ -2,7 +2,10 @@ import type { Duplex, Source } from 'it-stream-types';
2
2
  import type { Stream } from './stream-muxer.js';
3
3
  import type { Packet } from './rpcproto.pb.js';
4
4
  export type PacketHandler = (packet: Packet) => Promise<void>;
5
- export type PacketStream = Duplex<AsyncGenerator<Uint8Array>, Source<Uint8Array>, Promise<void>>;
5
+ export interface PacketStream extends Duplex<AsyncGenerator<Uint8Array>, Source<Uint8Array>, Promise<void>> {
6
+ close(): Promise<void>;
7
+ abort(err: Error): void;
8
+ }
6
9
  export type OpenStreamFunc = () => Promise<PacketStream>;
7
10
  export type HandleStreamFunc = (ch: PacketStream) => Promise<void>;
8
11
  export declare function streamToPacketStream(stream: Stream): PacketStream;
@@ -1,20 +1,79 @@
1
1
  import { pipe } from 'it-pipe';
2
2
  import { combineUint8ArrayListTransform } from './array-list.js';
3
3
  import { parseLengthPrefixTransform, prependLengthPrefixTransform, } from './packet.js';
4
+ import { closeIterator, sourceIterator, TerminationGate, } from './termination.js';
4
5
  // streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
5
6
  export function streamToPacketStream(stream) {
7
+ const termination = new TerminationGate();
6
8
  return {
7
- source: pipe(stream, parseLengthPrefixTransform(), combineUint8ArrayListTransform()),
9
+ close: async () => {
10
+ if (termination.terminate())
11
+ await stream.close();
12
+ },
13
+ abort: (err) => {
14
+ if (termination.terminate(err))
15
+ stream.abort(err);
16
+ },
17
+ source: (async function* () {
18
+ const packets = pipe(stream, parseLengthPrefixTransform(), combineUint8ArrayListTransform())[Symbol.asyncIterator]();
19
+ try {
20
+ while (true) {
21
+ const next = await termination.next(packets);
22
+ if ('terminated' in next) {
23
+ if (next.error)
24
+ throw next.error;
25
+ return;
26
+ }
27
+ if ('error' in next)
28
+ throw next.error;
29
+ if (next.result.done)
30
+ return;
31
+ yield next.result.value;
32
+ }
33
+ }
34
+ finally {
35
+ closeIterator(packets);
36
+ }
37
+ })(),
8
38
  sink: async (source) => {
39
+ const iterator = sourceIterator(pipe(source, prependLengthPrefixTransform()));
40
+ const gatedSource = (async function* () {
41
+ while (true) {
42
+ const next = await termination.next(iterator);
43
+ if ('terminated' in next) {
44
+ if (next.error)
45
+ throw next.error;
46
+ return;
47
+ }
48
+ if ('error' in next)
49
+ throw next.error;
50
+ if (next.result.done)
51
+ return;
52
+ if (termination.terminated)
53
+ return;
54
+ yield next.result.value;
55
+ }
56
+ })();
9
57
  try {
10
- await pipe(source, prependLengthPrefixTransform(), stream);
58
+ const result = await termination.wait(stream.sink(gatedSource));
59
+ if ('terminated' in result) {
60
+ if (result.error)
61
+ throw result.error;
62
+ return;
63
+ }
64
+ if ('error' in result)
65
+ throw result.error;
11
66
  await stream.closeWrite();
12
67
  }
13
68
  catch (err) {
14
69
  const error = err instanceof Error ? err : new Error(String(err));
15
- stream.abort(error);
70
+ if (termination.terminate(error))
71
+ stream.abort(error);
16
72
  throw error;
17
73
  }
74
+ finally {
75
+ closeIterator(iterator);
76
+ }
18
77
  },
19
78
  };
20
79
  }