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
package/srpc/client.ts CHANGED
@@ -117,13 +117,29 @@ export class Client implements ProtoRpc {
117
117
  const stream = await openStreamFn()
118
118
  const call = new ClientRPC(rpcService, rpcMethod)
119
119
  const onAbort = () => {
120
+ if (call.isClosed) return
121
+ const error = new Error(ERR_RPC_ABORT)
120
122
  void call.writeCallCancel().catch(() => undefined)
121
- void call.close(new Error(ERR_RPC_ABORT)).catch(() => undefined)
123
+ stream.abort(error)
124
+ void call.close(error).catch(() => undefined)
122
125
  }
123
126
  abortSignal?.addEventListener('abort', onAbort, { once: true })
124
- pipe(stream, decodePacketSource, call, encodePacketSource, stream)
125
- .catch((err) => call.close(err))
126
- .then(() => call.close())
127
+ void pipe(stream, decodePacketSource, call, encodePacketSource, stream)
128
+ .then(
129
+ async () => {
130
+ if (call.isClosed instanceof Error) {
131
+ stream.abort(call.isClosed)
132
+ return
133
+ }
134
+ await stream.close()
135
+ await call.close()
136
+ },
137
+ async (err: unknown) => {
138
+ const error = err instanceof Error ? err : new Error(String(err))
139
+ stream.abort(error)
140
+ await call.close(error)
141
+ },
142
+ )
127
143
  .finally(() => {
128
144
  abortSignal?.removeEventListener('abort', onAbort)
129
145
  })
package/srpc/codec.rs CHANGED
@@ -76,6 +76,9 @@ impl Encoder<Packet> for PacketCodec {
76
76
  let msg_size = item.encoded_len();
77
77
 
78
78
  // Validate message size.
79
+ if msg_size == 0 {
80
+ return Err(Error::MessageSizeZero);
81
+ }
79
82
  if msg_size > MAX_MESSAGE_SIZE {
80
83
  return Err(Error::MessageTooLarge(msg_size, MAX_MESSAGE_SIZE));
81
84
  }
@@ -96,6 +99,9 @@ impl Encoder<Packet> for PacketCodec {
96
99
  /// Encode a packet to bytes with length prefix.
97
100
  pub fn encode_packet(packet: &Packet) -> Result<Vec<u8>> {
98
101
  let msg_size = packet.encoded_len();
102
+ if msg_size == 0 {
103
+ return Err(Error::MessageSizeZero);
104
+ }
99
105
  if msg_size > MAX_MESSAGE_SIZE {
100
106
  return Err(Error::MessageTooLarge(msg_size, MAX_MESSAGE_SIZE));
101
107
  }
@@ -152,6 +152,8 @@ describe('CommonRPC', () => {
152
152
  const responseGate = deferred()
153
153
  const response = new Uint8Array([7])
154
154
  const client = new Client(async () => ({
155
+ close: async () => {},
156
+ abort: () => {},
155
157
  source: (async function* () {
156
158
  await responseGate.promise
157
159
  yield Packet.toBinary({
@@ -0,0 +1,195 @@
1
+ package srpc
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/binary"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "io"
10
+ "os"
11
+ "testing"
12
+ )
13
+
14
+ type packetCodecVector struct {
15
+ Name string `json:"name"`
16
+ PacketHex string `json:"packet_hex"`
17
+ FrameHex string `json:"frame_hex"`
18
+ }
19
+
20
+ func TestPacketCodecGoldenVectors(t *testing.T) {
21
+ data, err := os.ReadFile("../testdata/packet-codec-vectors.json")
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ var document struct {
26
+ Cases []packetCodecVector `json:"cases"`
27
+ }
28
+ if err := json.Unmarshal(data, &document); err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ for _, tc := range document.Cases {
32
+ if tc.PacketHex == "" || tc.FrameHex == "" {
33
+ continue
34
+ }
35
+ t.Run(tc.Name, func(t *testing.T) {
36
+ packet := goldenPacket(t, tc.Name)
37
+ packetData, err := packet.MarshalVT()
38
+ if err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ if want := decodeHex(t, tc.PacketHex); !bytes.Equal(packetData, want) {
42
+ t.Fatalf("packet = %x, want %x", packetData, want)
43
+ }
44
+ stream := &packetTestStream{}
45
+ if err := NewPacketReadWriter(stream).WritePacket(packet); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ if want := decodeHex(t, tc.FrameHex); !bytes.Equal(stream.writes, want) {
49
+ t.Fatalf("frame = %x, want %x", stream.writes, want)
50
+ }
51
+ })
52
+ }
53
+ }
54
+
55
+ func goldenPacket(t *testing.T, name string) *Packet {
56
+ t.Helper()
57
+ switch name {
58
+ case "call_start_data":
59
+ return NewCallStartPacket("svc", "method", []byte("abc"), false)
60
+ case "call_start_absent_empty":
61
+ return NewCallStartPacket("svc", "method", nil, false)
62
+ case "call_start_present_empty":
63
+ return NewCallStartPacket("svc", "method", nil, true)
64
+ case "call_data_terminal":
65
+ return NewCallDataPacket([]byte("out"), false, true, nil)
66
+ case "call_data_error":
67
+ return NewCallDataPacket(nil, false, false, errors.New("failed"))
68
+ case "call_cancel":
69
+ return NewCallCancelPacket()
70
+ default:
71
+ t.Fatalf("unknown golden packet %q", name)
72
+ return nil
73
+ }
74
+ }
75
+
76
+ func decodeHex(t *testing.T, value string) []byte {
77
+ t.Helper()
78
+ data, err := hex.DecodeString(value)
79
+ if err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ return data
83
+ }
84
+
85
+ func framePacket(t *testing.T, packet *Packet) []byte {
86
+ t.Helper()
87
+ packetSize := packet.SizeVT()
88
+ if packetSize > maxMessageSize {
89
+ t.Fatalf("packet size %d exceeds maximum %d", packetSize, maxMessageSize)
90
+ }
91
+ frame := make([]byte, 4+packetSize)
92
+ binary.LittleEndian.PutUint32(frame, uint32(packetSize)) //nolint:gosec // bounded by maxMessageSize
93
+ if _, err := packet.MarshalToSizedBufferVT(frame[4:]); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ return frame
97
+ }
98
+
99
+ type packetTestStream struct {
100
+ reads bytes.Buffer
101
+ writes []byte
102
+ maxRead int
103
+ }
104
+
105
+ func (s *packetTestStream) Read(data []byte) (int, error) {
106
+ if s.maxRead > 0 && len(data) > s.maxRead {
107
+ data = data[:s.maxRead]
108
+ }
109
+ return s.reads.Read(data)
110
+ }
111
+
112
+ func (s *packetTestStream) Write(data []byte) (int, error) {
113
+ s.writes = append(s.writes, data...)
114
+ return len(data), nil
115
+ }
116
+
117
+ func (s *packetTestStream) Close() error { return nil }
118
+
119
+ func TestPacketCodecReadFragmentsAndCoalesces(t *testing.T) {
120
+ first := framePacket(t, NewCallCancelPacket())
121
+ second := framePacket(t, NewCallDataPacket([]byte("x"), false, true, nil))
122
+ input := append(first, second...)
123
+ stream := &packetTestStream{reads: *bytes.NewBuffer(input)}
124
+ decode := NewPacketDataHandler(func(*Packet) error { return nil })
125
+ var count int
126
+ err := NewPacketReadWriter(stream).ReadToHandler(func(data []byte) error {
127
+ count++
128
+ return decode(data)
129
+ })
130
+ if err != nil || count != 2 {
131
+ t.Fatalf("ReadToHandler() = %v, packets=%d", err, count)
132
+ }
133
+ }
134
+
135
+ func TestPacketCodecReadEveryFragmentBoundary(t *testing.T) {
136
+ frame := framePacket(t, NewCallCancelPacket())
137
+ decode := NewPacketDataHandler(func(*Packet) error { return nil })
138
+ for size := 1; size <= len(frame); size++ {
139
+ stream := &packetTestStream{reads: *bytes.NewBuffer(frame), maxRead: size}
140
+ var count int
141
+ err := NewPacketReadWriter(stream).ReadToHandler(func(data []byte) error {
142
+ count++
143
+ return decode(data)
144
+ })
145
+ if err != nil || count != 1 {
146
+ t.Fatalf("chunk %d: err=%v count=%d", size, err, count)
147
+ }
148
+ }
149
+ }
150
+
151
+ func TestPacketCodecRejectsInvalidPrefixesAndMalformedPacket(t *testing.T) {
152
+ for name, frame := range map[string][]byte{
153
+ "zero": {0, 0, 0, 0},
154
+ "oversized": {0x81, 0x96, 0x98, 0x00},
155
+ } {
156
+ t.Run(name, func(t *testing.T) {
157
+ stream := &packetTestStream{reads: *bytes.NewBuffer(frame)}
158
+ if err := NewPacketReadWriter(stream).ReadToHandler(func([]byte) error { return nil }); err == nil {
159
+ t.Fatal("accepted invalid prefix")
160
+ }
161
+ })
162
+ }
163
+ frame := []byte{3, 0, 0, 0, 0x0a, 0x01, 0xff}
164
+ stream := &packetTestStream{reads: *bytes.NewBuffer(frame)}
165
+ decode := NewPacketDataHandler(func(*Packet) error { return nil })
166
+ if err := NewPacketReadWriter(stream).ReadToHandler(decode); err == nil {
167
+ t.Fatal("accepted malformed protobuf")
168
+ }
169
+ }
170
+
171
+ func TestPacketCodecCleanEOFReturnsNil(t *testing.T) {
172
+ stream := &packetTestStream{reads: *bytes.NewBuffer(nil)}
173
+ if err := NewPacketReadWriter(stream).ReadToHandler(func([]byte) error { return nil }); err != nil {
174
+ t.Fatalf("clean EOF: %v", err)
175
+ }
176
+ }
177
+
178
+ func TestPacketCodecTruncatedBodyAtEOF(t *testing.T) {
179
+ frame := []byte{4, 0, 0, 0, 0x0a, 0x01}
180
+ stream := &packetTestStream{reads: *bytes.NewBuffer(frame)}
181
+ var count int
182
+ err := NewPacketReadWriter(stream).ReadToHandler(func([]byte) error {
183
+ count++
184
+ return nil
185
+ })
186
+ if err != io.ErrUnexpectedEOF || count != 0 {
187
+ t.Fatalf("err=%v count=%d", err, count)
188
+ }
189
+ }
190
+
191
+ func TestPacketCodecWriteRejectsZeroPacket(t *testing.T) {
192
+ if err := NewPacketReadWriter(&packetTestStream{}).WritePacket(&Packet{}); err == nil {
193
+ t.Fatal("accepted zero-size packet")
194
+ }
195
+ }
@@ -0,0 +1,139 @@
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 {
5
+ decodePacketSource,
6
+ encodePacketSource,
7
+ lengthPrefixDecode,
8
+ prependLengthPrefixTransform,
9
+ uint32LEDecode,
10
+ } from './packet.js'
11
+
12
+ const bytes = (hex: string) =>
13
+ Uint8Array.from(hex.match(/../g) ?? [], (b) => parseInt(b, 16))
14
+ const hex = (
15
+ data: Uint8Array | { subarray: (start?: number, end?: number) => Uint8Array },
16
+ ) => Buffer.from(data.subarray()).toString('hex')
17
+ const collect = async <T>(source: AsyncIterable<T> | Iterable<T>) => {
18
+ const out: T[] = []
19
+ for await (const value of source) out.push(value)
20
+ return out
21
+ }
22
+
23
+ type ValidVector = (typeof vectors.cases)[number] & {
24
+ packet_hex: string
25
+ frame_hex: string
26
+ }
27
+ const validCases: ValidVector[] = vectors.cases.filter(
28
+ (entry): entry is ValidVector => Boolean(entry.packet_hex && entry.frame_hex),
29
+ )
30
+
31
+ describe('packet codec golden vectors', () => {
32
+ it.each(validCases)(
33
+ '$name has exact protobuf and frame bytes',
34
+ async (entry) => {
35
+ const packet = Packet.fromBinary(bytes(entry.packet_hex))
36
+ const encoded = (
37
+ await collect(
38
+ encodePacketSource(
39
+ (async function* () {
40
+ yield packet
41
+ })(),
42
+ ),
43
+ )
44
+ )[0]
45
+ expect(hex(encoded)).toBe(entry.packet_hex)
46
+
47
+ const framed = (
48
+ await collect(
49
+ prependLengthPrefixTransform()(
50
+ (async function* () {
51
+ yield encoded
52
+ })(),
53
+ ),
54
+ )
55
+ )[0]
56
+ expect(hex(framed)).toBe(entry.frame_hex)
57
+ },
58
+ )
59
+
60
+ it('rejects zero and oversized encoded chunks', async () => {
61
+ const zero = (async function* () {
62
+ yield new Uint8Array()
63
+ })()
64
+ await expect(collect(prependLengthPrefixTransform()(zero))).rejects.toThrow(
65
+ 'invalid packet length',
66
+ )
67
+ const oversized = (async function* () {
68
+ yield new Uint8Array(10_000_001)
69
+ })()
70
+ await expect(
71
+ collect(prependLengthPrefixTransform()(oversized)),
72
+ ).rejects.toThrow('invalid packet length')
73
+ })
74
+
75
+ it('rejects zero and oversized lengths, and truncated bodies', async () => {
76
+ const zero = (async function* () {
77
+ yield bytes('00000000')
78
+ })()
79
+ await expect(
80
+ collect(lengthPrefixDecode(zero, uint32LEDecode)),
81
+ ).rejects.toThrow('invalid packet length')
82
+ const oversized = (async function* () {
83
+ yield bytes('81969800')
84
+ })()
85
+ await expect(
86
+ collect(lengthPrefixDecode(oversized, uint32LEDecode)),
87
+ ).rejects.toThrow('invalid packet length')
88
+ const truncated = (async function* () {
89
+ yield bytes('040000000a01')
90
+ })()
91
+ await expect(
92
+ collect(lengthPrefixDecode(truncated, uint32LEDecode)),
93
+ ).rejects.toThrow('truncated packet frame')
94
+ })
95
+
96
+ it('decodes fragmented and coalesced frames', async () => {
97
+ const frames = validCases.map((entry) => bytes(entry.frame_hex))
98
+ const combined = new Uint8Array(
99
+ frames.reduce((n, frame) => n + frame.length, 0),
100
+ )
101
+ let offset = 0
102
+ for (const frame of frames) {
103
+ combined.set(frame, offset)
104
+ offset += frame.length
105
+ }
106
+ const payloads = await collect(
107
+ lengthPrefixDecode(
108
+ (async function* () {
109
+ yield combined.subarray(0, 3)
110
+ yield combined.subarray(3, 11)
111
+ yield combined.subarray(11)
112
+ })(),
113
+ uint32LEDecode,
114
+ ),
115
+ )
116
+ expect(payloads.map(hex)).toEqual(
117
+ validCases.map((entry) => entry.packet_hex),
118
+ )
119
+ const decoded = await collect(
120
+ decodePacketSource(payloads.map((payload) => payload.slice())),
121
+ )
122
+ expect(decoded).toHaveLength(validCases.length)
123
+ })
124
+
125
+ it('rejects malformed protobuf and incomplete frame prefix', async () => {
126
+ const malformed = vectors.cases.find(
127
+ (entry) => entry.name === 'malformed_complete',
128
+ )!
129
+ expect(() =>
130
+ Packet.fromBinary(bytes(malformed.frame_hex!.slice(8))),
131
+ ).toThrow()
132
+ const incomplete = (async function* () {
133
+ yield bytes('010203')
134
+ })()
135
+ await expect(
136
+ collect(lengthPrefixDecode(incomplete, uint32LEDecode)),
137
+ ).rejects.toThrow('truncated packet frame')
138
+ })
139
+ })
package/srpc/packet-rw.go CHANGED
@@ -65,7 +65,10 @@ func (r *PacketReadWriter) WritePacket(p *Packet) error {
65
65
  defer r.writeMtx.Unlock()
66
66
 
67
67
  msgSize := p.SizeVT()
68
- if msgSize < 0 || msgSize > maxMessageSize {
68
+ if msgSize <= 0 {
69
+ return errors.New("unexpected zero message size")
70
+ }
71
+ if msgSize > maxMessageSize {
69
72
  return errors.Errorf("message size %v greater than maximum %v", msgSize, maxMessageSize)
70
73
  }
71
74
 
@@ -163,7 +166,11 @@ func (r *PacketReadWriter) ReadToHandler(cb PacketDataHandler) error {
163
166
  }
164
167
  }
165
168
 
166
- // closed
169
+ // closed: a clean frame boundary has no buffered bytes; otherwise EOF
170
+ // ended a prefix or packet body before completion.
171
+ if currLen != 0 || r.buf.Len() != 0 {
172
+ return io.ErrUnexpectedEOF
173
+ }
167
174
  return nil
168
175
  }
169
176
 
package/srpc/packet.ts CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  buildEncodeMessageTransform,
8
8
  } from './message.js'
9
9
 
10
+ const MAX_MESSAGE_SIZE = 10_000_000
11
+
10
12
  // decodePacketSource decodes packets from a binary data stream.
11
13
  export const decodePacketSource = buildDecodeMessageTransform<Packet>(Packet)
12
14
 
@@ -39,6 +41,9 @@ export async function* lengthPrefixEncode(
39
41
  for await (const chunk of source) {
40
42
  // Encode the length of the chunk.
41
43
  const length = chunk instanceof Uint8Array ? chunk.length : chunk.byteLength
44
+ if (length === 0 || length > MAX_MESSAGE_SIZE) {
45
+ throw RangeError(`invalid packet length: ${length}`)
46
+ }
42
47
  const lengthEncoded = lengthEncoder(length)
43
48
 
44
49
  // Concatenate the length prefix and the data.
@@ -50,7 +55,7 @@ export async function* lengthPrefixEncode(
50
55
  export async function* lengthPrefixDecode(
51
56
  source: Source<Uint8Array | Uint8ArrayList>,
52
57
  lengthDecoder: typeof uint32LEDecode,
53
- ) {
58
+ ): AsyncGenerator<Uint8ArrayList> {
54
59
  const buffer = new Uint8ArrayList()
55
60
 
56
61
  for await (const chunk of source) {
@@ -59,18 +64,26 @@ export async function* lengthPrefixDecode(
59
64
  // Continue extracting messages while buffer contains enough data for decoding.
60
65
  while (buffer.length >= lengthDecoder.bytes) {
61
66
  const messageLength = lengthDecoder(buffer)
67
+ if (messageLength === 0 || messageLength > MAX_MESSAGE_SIZE) {
68
+ throw RangeError(`invalid packet length: ${messageLength}`)
69
+ }
62
70
  const totalLength = lengthDecoder.bytes + messageLength
63
71
 
64
72
  if (buffer.length < totalLength) break // Wait for more data if the full message hasn't arrived.
65
73
 
66
74
  // Extract the message excluding the length prefix.
67
- const message = buffer.sublist(lengthDecoder.bytes, totalLength)
75
+ const message = new Uint8ArrayList(
76
+ buffer.slice(lengthDecoder.bytes, totalLength),
77
+ )
68
78
  yield message
69
79
 
70
80
  // Remove the processed message from the buffer.
71
81
  buffer.consume(totalLength)
72
82
  }
73
83
  }
84
+ if (buffer.length !== 0) {
85
+ throw new RangeError('truncated packet frame')
86
+ }
74
87
  }
75
88
 
76
89
  // prependLengthPrefixTransform adds a length prefix to a message source.
package/srpc/py.typed ADDED
File without changes
@@ -0,0 +1,40 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: github.com/aperturerobotics/starpc/srpc/rpcproto.proto
5
+ # Protobuf Python Version: 6.33.4
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 6,
15
+ 33,
16
+ 4,
17
+ '',
18
+ 'github.com/aperturerobotics/starpc/srpc/rpcproto.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+
26
+
27
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n6github.com/aperturerobotics/starpc/srpc/rpcproto.proto\x12\x04srpc\"s\n\x06Packet\x12%\n\ncall_start\x18\x01 \x01(\x0b\x32\x0f.srpc.CallStartH\x00\x12#\n\tcall_data\x18\x02 \x01(\x0b\x32\x0e.srpc.CallDataH\x00\x12\x15\n\x0b\x63\x61ll_cancel\x18\x03 \x01(\x08H\x00\x42\x06\n\x04\x62ody\"X\n\tCallStart\x12\x13\n\x0brpc_service\x18\x01 \x01(\t\x12\x12\n\nrpc_method\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x14\n\x0c\x64\x61ta_is_zero\x18\x04 \x01(\x08\"O\n\x08\x43\x61llData\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x12\x14\n\x0c\x64\x61ta_is_zero\x18\x02 \x01(\x08\x12\x10\n\x08\x63omplete\x18\x03 \x01(\x08\x12\r\n\x05\x65rror\x18\x04 \x01(\tb\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'github.com.aperturerobotics.starpc.srpc.rpcproto_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ DESCRIPTOR._loaded_options = None
34
+ _globals['_PACKET']._serialized_start=64
35
+ _globals['_PACKET']._serialized_end=179
36
+ _globals['_CALLSTART']._serialized_start=181
37
+ _globals['_CALLSTART']._serialized_end=269
38
+ _globals['_CALLDATA']._serialized_start=271
39
+ _globals['_CALLDATA']._serialized_end=350
40
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,40 @@
1
+ from google.protobuf import descriptor as _descriptor
2
+ from google.protobuf import message as _message
3
+ from collections.abc import Mapping as _Mapping
4
+ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
5
+
6
+ DESCRIPTOR: _descriptor.FileDescriptor
7
+
8
+ class Packet(_message.Message):
9
+ __slots__ = ("call_start", "call_data", "call_cancel")
10
+ CALL_START_FIELD_NUMBER: _ClassVar[int]
11
+ CALL_DATA_FIELD_NUMBER: _ClassVar[int]
12
+ CALL_CANCEL_FIELD_NUMBER: _ClassVar[int]
13
+ call_start: CallStart
14
+ call_data: CallData
15
+ call_cancel: bool
16
+ def __init__(self, call_start: _Optional[_Union[CallStart, _Mapping]] = ..., call_data: _Optional[_Union[CallData, _Mapping]] = ..., call_cancel: _Optional[bool] = ...) -> None: ...
17
+
18
+ class CallStart(_message.Message):
19
+ __slots__ = ("rpc_service", "rpc_method", "data", "data_is_zero")
20
+ RPC_SERVICE_FIELD_NUMBER: _ClassVar[int]
21
+ RPC_METHOD_FIELD_NUMBER: _ClassVar[int]
22
+ DATA_FIELD_NUMBER: _ClassVar[int]
23
+ DATA_IS_ZERO_FIELD_NUMBER: _ClassVar[int]
24
+ rpc_service: str
25
+ rpc_method: str
26
+ data: bytes
27
+ data_is_zero: bool
28
+ def __init__(self, rpc_service: _Optional[str] = ..., rpc_method: _Optional[str] = ..., data: _Optional[bytes] = ..., data_is_zero: _Optional[bool] = ...) -> None: ...
29
+
30
+ class CallData(_message.Message):
31
+ __slots__ = ("data", "data_is_zero", "complete", "error")
32
+ DATA_FIELD_NUMBER: _ClassVar[int]
33
+ DATA_IS_ZERO_FIELD_NUMBER: _ClassVar[int]
34
+ COMPLETE_FIELD_NUMBER: _ClassVar[int]
35
+ ERROR_FIELD_NUMBER: _ClassVar[int]
36
+ data: bytes
37
+ data_is_zero: bool
38
+ complete: bool
39
+ error: str
40
+ def __init__(self, data: _Optional[bytes] = ..., data_is_zero: _Optional[bool] = ..., complete: _Optional[bool] = ..., error: _Optional[str] = ...) -> None: ...
@@ -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,
@@ -175,6 +177,8 @@ describe('srpc server', () => {
175
177
  const server = new Server(mux.lookupMethod)
176
178
  const firstResponse = new Promise<Packet>((resolve, reject) => {
177
179
  server.handlePacketStream({
180
+ close: async () => {},
181
+ abort: () => {},
178
182
  source: (async function* () {
179
183
  yield Packet.toBinary({
180
184
  body: {
@@ -342,6 +346,52 @@ describe('srpc server', () => {
342
346
  await Promise.resolve()
343
347
  })
344
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
+
345
395
  it('tears down passive channel close state', async () => {
346
396
  const { port1, port2 } = new MessageChannel()
347
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
+ }