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.
- package/dist/echo/client-test.d.ts +1 -1
- package/dist/echo/client-test.js +110 -2
- package/dist/integration/cross-language/tcp-packet-stream.d.ts +3 -0
- package/dist/integration/cross-language/tcp-packet-stream.js +112 -0
- package/dist/integration/cross-language/tcp-packet-stream.test.d.ts +1 -0
- package/dist/integration/cross-language/tcp-packet-stream.test.js +121 -0
- package/dist/integration/cross-language/ts-client.js +50 -37
- package/dist/integration/cross-language/ts-server.js +1 -36
- package/dist/rpcstream/rpcstream.d.ts +5 -1
- package/dist/rpcstream/rpcstream.js +75 -28
- package/dist/rpcstream/rpcstream.test.d.ts +1 -0
- package/dist/rpcstream/rpcstream.test.js +92 -0
- package/dist/srpc/client.js +18 -4
- package/dist/srpc/common-rpc.test.js +2 -0
- package/dist/srpc/packet-codec.test.d.ts +1 -0
- package/dist/srpc/packet-codec.test.js +75 -0
- package/dist/srpc/packet.d.ts +1 -1
- package/dist/srpc/packet.js +11 -1
- package/dist/srpc/server.js +19 -6
- package/dist/srpc/server.test.js +43 -0
- package/dist/srpc/stream.d.ts +4 -1
- package/dist/srpc/stream.js +62 -3
- package/dist/srpc/stream.test.js +110 -1
- package/dist/srpc/termination.d.ts +27 -0
- package/dist/srpc/termination.js +56 -0
- package/dist/srpc/termination.test.d.ts +1 -0
- package/dist/srpc/termination.test.js +24 -0
- package/dist/testdata/packet-codec-vectors.json +64 -0
- package/echo/client-test.ts +124 -2
- package/echo/echo_pb2.py +40 -0
- package/echo/echo_pb2.pyi +13 -0
- package/echo/echo_srpc.py +306 -0
- package/echo/echo_srpc.pyi +85 -0
- package/go.mod +2 -2
- package/go.sum +14 -0
- package/integration/cross-language/go-client/main.go +79 -3
- package/integration/cross-language/python-client.py +146 -0
- package/integration/cross-language/python-server.py +140 -0
- package/integration/cross-language/run.bash +190 -65
- package/integration/cross-language/tcp-packet-stream.test.ts +154 -0
- package/integration/cross-language/tcp-packet-stream.ts +121 -0
- package/integration/cross-language/ts-client.ts +62 -40
- package/integration/cross-language/ts-server.ts +1 -45
- package/mock/mock_pb2.py +38 -0
- package/mock/mock_pb2.pyi +11 -0
- package/mock/mock_srpc.py +71 -0
- package/mock/mock_srpc.pyi +27 -0
- package/package.json +19 -5
- package/srpc/__init__.py +0 -0
- package/srpc/client.ts +20 -4
- package/srpc/codec.rs +6 -0
- package/srpc/common-rpc.test.ts +2 -0
- package/srpc/packet-codec-vectors_test.go +195 -0
- package/srpc/packet-codec.test.ts +139 -0
- package/srpc/packet-rw.go +9 -2
- package/srpc/packet.ts +15 -2
- package/srpc/py.typed +0 -0
- package/srpc/rpcproto_pb2.py +40 -0
- package/srpc/rpcproto_pb2.pyi +40 -0
- package/srpc/server.test.ts +50 -0
- package/srpc/server.ts +22 -6
- package/srpc/stream.test.ts +132 -1
- package/srpc/stream.ts +65 -9
- package/srpc/termination.test.ts +30 -0
- 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
|
-
|
|
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
|
-
.
|
|
126
|
-
|
|
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
|
}
|
package/srpc/common-rpc.test.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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: ...
|
package/srpc/server.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|