starpc 0.49.17 → 0.49.20
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/README.md +1 -3
- package/cmd/protoc-gen-es-starpc/typescript.ts +11 -6
- package/dist/cmd/protoc-gen-es-starpc/typescript.js +6 -5
- package/dist/echo/echo.pb.d.ts +1 -1
- package/dist/echo/echo.pb.js +3 -3
- package/dist/integration/cross-language/ts-client.js +84 -11
- package/dist/integration/cross-language/ts-server.js +84 -4
- package/dist/mock/mock.pb.d.ts +1 -1
- package/dist/mock/mock.pb.js +3 -3
- package/dist/rpcstream/rpcstream.pb.d.ts +1 -1
- package/dist/rpcstream/rpcstream.pb.js +14 -8
- package/dist/srpc/call-receipt.d.ts +17 -0
- package/dist/srpc/call-receipt.js +105 -0
- package/dist/srpc/call-receipt.test.d.ts +1 -0
- package/dist/srpc/call-receipt.test.js +374 -0
- package/dist/srpc/client.d.ts +3 -1
- package/dist/srpc/client.js +20 -0
- package/dist/srpc/common-rpc.d.ts +13 -1
- package/dist/srpc/common-rpc.js +86 -6
- package/dist/srpc/handler.d.ts +2 -1
- package/dist/srpc/index.d.ts +4 -0
- package/dist/srpc/index.js +2 -0
- package/dist/srpc/invoker.d.ts +2 -1
- package/dist/srpc/invoker.js +2 -2
- package/dist/srpc/rpcproto.pb.d.ts +1 -1
- package/dist/srpc/rpcproto.pb.js +7 -7
- package/dist/srpc/server-invocation.d.ts +17 -0
- package/dist/srpc/server-invocation.js +37 -0
- package/dist/srpc/server-rpc.js +3 -1
- package/echo/echo.pb.go +1 -1
- package/echo/echo.pb.ts +6 -5
- package/echo/echo_srpc.pb.cpp +1 -1
- package/echo/echo_srpc.pb.go +1 -1
- package/echo/echo_srpc.pb.hpp +1 -1
- package/echo/echo_srpc.pb.rs +1 -1
- package/integration/cross-language/go-client/main.go +137 -5
- package/integration/cross-language/go-server/main.go +146 -3
- package/integration/cross-language/run.bash +117 -13
- package/integration/cross-language/ts-client.ts +94 -13
- package/integration/cross-language/ts-server.ts +94 -6
- package/mock/mock.pb.go +1 -1
- package/mock/mock.pb.ts +6 -5
- package/mock/mock_srpc.pb.cpp +1 -1
- package/mock/mock_srpc.pb.go +1 -1
- package/mock/mock_srpc.pb.hpp +1 -1
- package/mock/mock_srpc.pb.rs +1 -1
- package/package.json +1 -1
- package/srpc/call-receipt-e2e_test.go +111 -0
- package/srpc/call-receipt.go +112 -0
- package/srpc/call-receipt.test.ts +438 -0
- package/srpc/call-receipt.ts +130 -0
- package/srpc/call-receipt_test.go +536 -0
- package/srpc/client-rpc.go +1 -8
- package/srpc/client.ts +29 -2
- package/srpc/common-rpc.go +134 -29
- package/srpc/common-rpc.ts +98 -8
- package/srpc/common-rpc_test.go +52 -0
- package/srpc/handler.ts +2 -0
- package/srpc/index.ts +4 -0
- package/srpc/invoker.ts +10 -5
- package/srpc/msg-stream.go +8 -0
- package/srpc/muxed-conn.go +8 -3
- package/srpc/muxed-conn_test.go +79 -0
- package/srpc/rpcproto.pb.go +1 -1
- package/srpc/rpcproto.pb.ts +28 -27
- package/srpc/server-invocation.go +58 -0
- package/srpc/server-invocation.ts +82 -0
- package/srpc/server-rpc-invoke.go +7 -0
- package/srpc/server-rpc-invoke_goscript.go +12 -0
- package/srpc/server-rpc-invoke_goscript_test.go +27 -0
- package/srpc/server-rpc.go +9 -2
- package/srpc/server-rpc.ts +6 -2
- package/srpc/stream.go +20 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
package srpc
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"io"
|
|
6
|
+
"sync/atomic"
|
|
7
|
+
|
|
8
|
+
"github.com/pkg/errors"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// ExecCallReceipt executes a unary call over cc.NewStream, reads the single
|
|
12
|
+
// response into out, and returns a held receipt. The caller must dispose it
|
|
13
|
+
// with Commit, Abort, or Close.
|
|
14
|
+
func ExecCallReceipt(
|
|
15
|
+
ctx context.Context,
|
|
16
|
+
cc Client,
|
|
17
|
+
service, method string,
|
|
18
|
+
in, out Message,
|
|
19
|
+
) (*CallReceipt, error) {
|
|
20
|
+
strm, err := cc.NewStream(ctx, service, method, in)
|
|
21
|
+
if err != nil {
|
|
22
|
+
return nil, err
|
|
23
|
+
}
|
|
24
|
+
if err := strm.MsgRecv(out); err != nil {
|
|
25
|
+
_ = strm.Close()
|
|
26
|
+
return nil, err
|
|
27
|
+
}
|
|
28
|
+
return &CallReceipt{strm: strm}, nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// CallReceipt holds a unary call until the server finalizes it.
|
|
32
|
+
type CallReceipt struct {
|
|
33
|
+
strm Stream
|
|
34
|
+
disposed atomic.Bool
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Context returns the context of the held call.
|
|
38
|
+
func (r *CallReceipt) Context() context.Context {
|
|
39
|
+
return r.strm.Context()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Commit sends request completion and waits for the server completion
|
|
43
|
+
// acknowledgment before releasing the stream.
|
|
44
|
+
func (r *CallReceipt) Commit() error {
|
|
45
|
+
if r.disposed.Swap(true) {
|
|
46
|
+
return nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if r.strm.Context().Err() != nil {
|
|
50
|
+
_ = r.strm.Close()
|
|
51
|
+
return context.Canceled
|
|
52
|
+
}
|
|
53
|
+
if err := r.strm.CloseSend(); err != nil {
|
|
54
|
+
_ = r.strm.Close()
|
|
55
|
+
return err
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var done receiptDone
|
|
59
|
+
err := r.strm.MsgRecv(&done)
|
|
60
|
+
_ = r.strm.Close()
|
|
61
|
+
if err == io.EOF {
|
|
62
|
+
receipt, ok := r.strm.(receiptTerminalStream)
|
|
63
|
+
if ok {
|
|
64
|
+
terminal, terminalOK := receipt.receiptTerminalKind()
|
|
65
|
+
if terminalOK && terminal == TerminalCommitted {
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return errors.New("missing trailing completion acknowledgment")
|
|
70
|
+
}
|
|
71
|
+
if err == nil {
|
|
72
|
+
return errors.New("unexpected trailing response data")
|
|
73
|
+
}
|
|
74
|
+
return err
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Abort sends request cancellation and releases the stream.
|
|
78
|
+
func (r *CallReceipt) Abort() error {
|
|
79
|
+
if r.disposed.Swap(true) {
|
|
80
|
+
return nil
|
|
81
|
+
}
|
|
82
|
+
return r.strm.Close()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Close aborts the held call unless it has already reached a terminal.
|
|
86
|
+
func (r *CallReceipt) Close() error {
|
|
87
|
+
return r.Abort()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// receiptDone is a no-op message used to read the trailing completion packet.
|
|
91
|
+
type receiptDone struct{}
|
|
92
|
+
|
|
93
|
+
func (*receiptDone) SizeVT() int {
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func (*receiptDone) MarshalToSizedBufferVT([]byte) (int, error) {
|
|
98
|
+
return 0, nil
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func (*receiptDone) MarshalVT() ([]byte, error) {
|
|
102
|
+
return nil, nil
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func (*receiptDone) UnmarshalVT([]byte) error {
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func (*receiptDone) Reset() {}
|
|
110
|
+
|
|
111
|
+
// _ is a type assertion.
|
|
112
|
+
var _ Message = (*receiptDone)(nil)
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/// <reference lib="es2024.promise" />
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { pushable } from 'it-pushable'
|
|
5
|
+
import { EchoMsg } from '../echo/echo.pb.js'
|
|
6
|
+
import type { Echoer } from '../echo/echo_srpc.pb.js'
|
|
7
|
+
|
|
8
|
+
import { Client } from './client.js'
|
|
9
|
+
import { ClientRPC } from './client-rpc.js'
|
|
10
|
+
import { Server } from './server.js'
|
|
11
|
+
import { CallReceipt } from './call-receipt.js'
|
|
12
|
+
import { Packet } from './rpcproto.pb.js'
|
|
13
|
+
import { ServerRPC } from './server-rpc.js'
|
|
14
|
+
import { ServerInvocation, type TerminalKind } from './server-invocation.js'
|
|
15
|
+
|
|
16
|
+
const response = new Uint8Array([1, 2, 3])
|
|
17
|
+
|
|
18
|
+
describe('generated service compatibility', () => {
|
|
19
|
+
it('accepts plain abort signals and terminal-capable handlers', async () => {
|
|
20
|
+
const server: Pick<Echoer, 'Echo'> = {
|
|
21
|
+
Echo: async (request, invocation?: ServerInvocation) => {
|
|
22
|
+
void invocation
|
|
23
|
+
return request
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
const request = EchoMsg.create({ body: 'compatibility' })
|
|
27
|
+
const signal = new AbortController().signal
|
|
28
|
+
await expect(server.Echo(request, signal)).resolves.toEqual(request)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('held unary receipt', () => {
|
|
33
|
+
it('waits for a server completion acknowledgment', async () => {
|
|
34
|
+
const sent: Packet[] = []
|
|
35
|
+
const terminal = Promise.withResolvers<void>()
|
|
36
|
+
const client = new Client(async () => ({
|
|
37
|
+
source: responseSource(terminal.promise),
|
|
38
|
+
sink: async (source) => {
|
|
39
|
+
for await (const data of source) {
|
|
40
|
+
const packet = Packet.fromBinary(data)
|
|
41
|
+
sent.push(packet)
|
|
42
|
+
if (packet.body?.case === 'callData' && packet.body.value.complete) {
|
|
43
|
+
terminal.resolve()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
const held = await client.requestWithReceipt(
|
|
50
|
+
'test.Service',
|
|
51
|
+
'Unary',
|
|
52
|
+
response,
|
|
53
|
+
)
|
|
54
|
+
const commit = held.receipt.commit()
|
|
55
|
+
expect(held.receipt.settled).toBe(true)
|
|
56
|
+
await expect(commit).resolves.toBeUndefined()
|
|
57
|
+
|
|
58
|
+
const terminalPackets = sent.filter((packet) => {
|
|
59
|
+
const body = packet.body
|
|
60
|
+
return (
|
|
61
|
+
body?.case === 'callCancel' ||
|
|
62
|
+
(body?.case === 'callData' && body.value.complete)
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
expect(terminalPackets).toHaveLength(1)
|
|
66
|
+
expect(terminalPackets[0]?.body?.case).toBe('callData')
|
|
67
|
+
await expect(held.receipt.done).resolves.toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('commits through a real TypeScript server', async () => {
|
|
71
|
+
const terminal = Promise.withResolvers<string>()
|
|
72
|
+
const server = new Server(async () => {
|
|
73
|
+
return async (_source, dataSink, invocation) => {
|
|
74
|
+
await dataSink(
|
|
75
|
+
(async function* () {
|
|
76
|
+
yield response
|
|
77
|
+
if (!invocation) {
|
|
78
|
+
throw new Error('missing invocation')
|
|
79
|
+
}
|
|
80
|
+
terminal.resolve(
|
|
81
|
+
await invocation.waitTerminal(new AbortController().signal),
|
|
82
|
+
)
|
|
83
|
+
})(),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
const client = new Client(async () => {
|
|
88
|
+
const clientToServer = pushable<Uint8Array>({ objectMode: true })
|
|
89
|
+
const serverToClient = pushable<Uint8Array>({ objectMode: true })
|
|
90
|
+
server.handlePacketStream({
|
|
91
|
+
source: clientToServer,
|
|
92
|
+
sink: async (source) => {
|
|
93
|
+
for await (const packet of source) {
|
|
94
|
+
serverToClient.push(packet)
|
|
95
|
+
}
|
|
96
|
+
serverToClient.end()
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
return {
|
|
100
|
+
source: serverToClient,
|
|
101
|
+
sink: async (source) => {
|
|
102
|
+
for await (const packet of source) {
|
|
103
|
+
clientToServer.push(packet)
|
|
104
|
+
}
|
|
105
|
+
clientToServer.end()
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const held = await client.requestWithReceipt(
|
|
111
|
+
'test.Service',
|
|
112
|
+
'Unary',
|
|
113
|
+
response,
|
|
114
|
+
)
|
|
115
|
+
await expect(held.receipt.commit()).resolves.toBeUndefined()
|
|
116
|
+
await expect(terminal.promise).resolves.toBe('committed')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('rejects commit after a bare remote close', async () => {
|
|
120
|
+
const call = new ClientRPC('test.Service', 'Unary')
|
|
121
|
+
const iterator = call.rpcDataSource[Symbol.asyncIterator]()
|
|
122
|
+
await call.handleCallData({
|
|
123
|
+
data: response,
|
|
124
|
+
dataIsZero: false,
|
|
125
|
+
complete: false,
|
|
126
|
+
error: '',
|
|
127
|
+
})
|
|
128
|
+
const first = await iterator.next()
|
|
129
|
+
expect(first.done).toBe(false)
|
|
130
|
+
const receipt = new CallReceipt(call, iterator)
|
|
131
|
+
await call.close()
|
|
132
|
+
await expect(receipt.commit()).rejects.toThrow()
|
|
133
|
+
await expect(receipt.done).rejects.toThrow('receipt closed before commit')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('aborts a held receipt with one cancel packet', async () => {
|
|
137
|
+
const sent: Packet[] = []
|
|
138
|
+
const terminal = Promise.withResolvers<void>()
|
|
139
|
+
const cancelSeen = Promise.withResolvers<void>()
|
|
140
|
+
const client = new Client(async () => ({
|
|
141
|
+
source: responseSource(terminal.promise),
|
|
142
|
+
sink: async (source) => {
|
|
143
|
+
for await (const data of source) {
|
|
144
|
+
const packet = Packet.fromBinary(data)
|
|
145
|
+
sent.push(packet)
|
|
146
|
+
if (packet.body?.case === 'callCancel') {
|
|
147
|
+
cancelSeen.resolve()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
}))
|
|
152
|
+
|
|
153
|
+
const held = await client.requestWithReceipt(
|
|
154
|
+
'test.Service',
|
|
155
|
+
'Unary',
|
|
156
|
+
response,
|
|
157
|
+
)
|
|
158
|
+
await held.receipt.abort()
|
|
159
|
+
await cancelSeen.promise
|
|
160
|
+
const terminalPackets = sent.filter(
|
|
161
|
+
(packet) => packet.body?.case === 'callCancel',
|
|
162
|
+
)
|
|
163
|
+
expect(terminalPackets).toHaveLength(1)
|
|
164
|
+
await expect(held.receipt.done).rejects.toThrow()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('allows exactly one concurrent commit or abort terminal', async () => {
|
|
168
|
+
const sent: Packet[] = []
|
|
169
|
+
const terminal = Promise.withResolvers<void>()
|
|
170
|
+
const client = new Client(async () => ({
|
|
171
|
+
source: responseSource(terminal.promise),
|
|
172
|
+
sink: async (source) => {
|
|
173
|
+
for await (const data of source) {
|
|
174
|
+
const packet = Packet.fromBinary(data)
|
|
175
|
+
sent.push(packet)
|
|
176
|
+
if (packet.body?.case === 'callData' && packet.body.value.complete) {
|
|
177
|
+
terminal.resolve()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
const held = await client.requestWithReceipt(
|
|
184
|
+
'test.Service',
|
|
185
|
+
'Unary',
|
|
186
|
+
response,
|
|
187
|
+
)
|
|
188
|
+
const results = await Promise.allSettled([
|
|
189
|
+
held.receipt.commit(),
|
|
190
|
+
held.receipt.abort(),
|
|
191
|
+
])
|
|
192
|
+
const terminalPackets = sent.filter((packet) => {
|
|
193
|
+
const body = packet.body
|
|
194
|
+
return (
|
|
195
|
+
body?.case === 'callCancel' ||
|
|
196
|
+
(body?.case === 'callData' && body.value.complete)
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
expect(terminalPackets).toHaveLength(1)
|
|
200
|
+
expect(results).toHaveLength(2)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('rejects a trailing response datum instead of committing', async () => {
|
|
204
|
+
const terminal = Promise.withResolvers<void>()
|
|
205
|
+
const client = new Client(async () => ({
|
|
206
|
+
source: (async function* () {
|
|
207
|
+
yield Packet.toBinary({
|
|
208
|
+
body: {
|
|
209
|
+
case: 'callData',
|
|
210
|
+
value: {
|
|
211
|
+
data: response,
|
|
212
|
+
dataIsZero: false,
|
|
213
|
+
complete: false,
|
|
214
|
+
error: '',
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
await terminal.promise
|
|
219
|
+
yield Packet.toBinary({
|
|
220
|
+
body: {
|
|
221
|
+
case: 'callData',
|
|
222
|
+
value: {
|
|
223
|
+
data: new Uint8Array([9]),
|
|
224
|
+
dataIsZero: false,
|
|
225
|
+
complete: false,
|
|
226
|
+
error: '',
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
})(),
|
|
231
|
+
sink: async (source) => {
|
|
232
|
+
for await (const data of source) {
|
|
233
|
+
const packet = Packet.fromBinary(data)
|
|
234
|
+
if (packet.body?.case === 'callData' && packet.body.value.complete) {
|
|
235
|
+
terminal.resolve()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
}))
|
|
240
|
+
|
|
241
|
+
const held = await client.requestWithReceipt(
|
|
242
|
+
'test.Service',
|
|
243
|
+
'Unary',
|
|
244
|
+
response,
|
|
245
|
+
)
|
|
246
|
+
await expect(held.receipt.commit()).rejects.toThrow(
|
|
247
|
+
'unexpected trailing response data',
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('rejects done on a remote terminal error', async () => {
|
|
252
|
+
const client = new Client(async () => ({
|
|
253
|
+
source: (async function* () {
|
|
254
|
+
yield Packet.toBinary({
|
|
255
|
+
body: {
|
|
256
|
+
case: 'callData',
|
|
257
|
+
value: {
|
|
258
|
+
data: response,
|
|
259
|
+
dataIsZero: false,
|
|
260
|
+
complete: false,
|
|
261
|
+
error: '',
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
yield Packet.toBinary({
|
|
266
|
+
body: {
|
|
267
|
+
case: 'callData',
|
|
268
|
+
value: {
|
|
269
|
+
data: new Uint8Array(),
|
|
270
|
+
dataIsZero: false,
|
|
271
|
+
complete: true,
|
|
272
|
+
error: 'remote failure',
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
})(),
|
|
277
|
+
sink: async (source) => {
|
|
278
|
+
for await (const _data of source) {
|
|
279
|
+
void _data
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
}))
|
|
283
|
+
|
|
284
|
+
const held = await client.requestWithReceipt(
|
|
285
|
+
'test.Service',
|
|
286
|
+
'Unary',
|
|
287
|
+
response,
|
|
288
|
+
)
|
|
289
|
+
await expect(held.receipt.done).rejects.toThrow('remote failure')
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
describe('server invocation terminal', () => {
|
|
294
|
+
it.each([
|
|
295
|
+
['explicit completion', 'committed'],
|
|
296
|
+
['cancel', 'canceled'],
|
|
297
|
+
['transport loss', 'transportLost'],
|
|
298
|
+
['remote error packet', 'transportLost'],
|
|
299
|
+
['bare close', 'closed'],
|
|
300
|
+
['remote error packet with completion', 'transportLost'],
|
|
301
|
+
] as const)('%s is classified distinctly', async (_name, expected) => {
|
|
302
|
+
const captured = Promise.withResolvers<ServerInvocation>()
|
|
303
|
+
const owner = new AbortController()
|
|
304
|
+
const observed = Promise.withResolvers<TerminalKind>()
|
|
305
|
+
const rpc = new ServerRPC(async () => {
|
|
306
|
+
return async (_source, _sink, invocation) => {
|
|
307
|
+
if (!invocation) {
|
|
308
|
+
throw new Error('missing invocation')
|
|
309
|
+
}
|
|
310
|
+
captured.resolve(invocation)
|
|
311
|
+
observed.resolve(await invocation.waitTerminal(owner.signal))
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
await rpc.handleCallStart({
|
|
315
|
+
rpcService: 'test.Service',
|
|
316
|
+
rpcMethod: 'Unary',
|
|
317
|
+
data: new Uint8Array(),
|
|
318
|
+
dataIsZero: true,
|
|
319
|
+
})
|
|
320
|
+
const invocation = await captured.promise
|
|
321
|
+
if (expected === 'committed') {
|
|
322
|
+
await rpc.handleCallData({ complete: true })
|
|
323
|
+
} else if (expected === 'canceled') {
|
|
324
|
+
await rpc.handleCallCancel()
|
|
325
|
+
} else if (expected === 'transportLost') {
|
|
326
|
+
if (
|
|
327
|
+
_name === 'remote error packet' ||
|
|
328
|
+
_name === 'remote error packet with completion'
|
|
329
|
+
) {
|
|
330
|
+
await rpc.handleCallData({
|
|
331
|
+
complete: _name === 'remote error packet with completion',
|
|
332
|
+
error: 'remote failure',
|
|
333
|
+
})
|
|
334
|
+
} else {
|
|
335
|
+
await rpc.close(new Error('transport loss'))
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
await rpc.close()
|
|
339
|
+
}
|
|
340
|
+
await expect(observed.promise).resolves.toBe(expected)
|
|
341
|
+
owner.abort()
|
|
342
|
+
if (_name === 'remote error packet with completion') {
|
|
343
|
+
const rpcState: object = rpc
|
|
344
|
+
if (!('remoteCompleted' in rpcState)) {
|
|
345
|
+
throw new Error('remote completion discriminator is missing')
|
|
346
|
+
}
|
|
347
|
+
expect(rpcState.remoteCompleted).toBe(false)
|
|
348
|
+
}
|
|
349
|
+
if (expected === 'closed' || expected === 'transportLost') {
|
|
350
|
+
expect(invocation.signal.aborted).toBe(true)
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it.each(['transport loss', 'cancel'] as const)(
|
|
355
|
+
'keeps completion after %s',
|
|
356
|
+
async (followUp) => {
|
|
357
|
+
const captured = Promise.withResolvers<ServerInvocation>()
|
|
358
|
+
const owner = new AbortController()
|
|
359
|
+
const rpc = new ServerRPC(async () => {
|
|
360
|
+
return async (_source, _sink, invocation) => {
|
|
361
|
+
if (!invocation) {
|
|
362
|
+
throw new Error('missing invocation')
|
|
363
|
+
}
|
|
364
|
+
captured.resolve(invocation)
|
|
365
|
+
await invocation.waitTerminal(owner.signal)
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
await rpc.handleCallStart({
|
|
369
|
+
rpcService: 'test.Service',
|
|
370
|
+
rpcMethod: 'Unary',
|
|
371
|
+
data: new Uint8Array(),
|
|
372
|
+
dataIsZero: true,
|
|
373
|
+
})
|
|
374
|
+
const invocation = await captured.promise
|
|
375
|
+
await rpc.handleCallData({ complete: true })
|
|
376
|
+
if (followUp === 'transport loss') {
|
|
377
|
+
await rpc.close(new Error('transport loss'))
|
|
378
|
+
} else {
|
|
379
|
+
await rpc.handleCallCancel()
|
|
380
|
+
}
|
|
381
|
+
await expect(invocation.waitTerminal(owner.signal)).resolves.toBe(
|
|
382
|
+
'committed',
|
|
383
|
+
)
|
|
384
|
+
owner.abort()
|
|
385
|
+
},
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
it('returns abandoned only for owner cancellation without a remote terminal', async () => {
|
|
389
|
+
const captured = Promise.withResolvers<ServerInvocation>()
|
|
390
|
+
const owner = new AbortController()
|
|
391
|
+
const rpc = new ServerRPC(async () => {
|
|
392
|
+
return async (_source, _sink, invocation) => {
|
|
393
|
+
if (!invocation) {
|
|
394
|
+
throw new Error('missing invocation')
|
|
395
|
+
}
|
|
396
|
+
captured.resolve(invocation)
|
|
397
|
+
await invocation.waitTerminal(owner.signal)
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
await rpc.handleCallStart({
|
|
401
|
+
rpcService: 'test.Service',
|
|
402
|
+
rpcMethod: 'Unary',
|
|
403
|
+
data: new Uint8Array(),
|
|
404
|
+
dataIsZero: true,
|
|
405
|
+
})
|
|
406
|
+
const invocation = await captured.promise
|
|
407
|
+
owner.abort()
|
|
408
|
+
await expect(invocation.waitTerminal(owner.signal)).resolves.toBe(
|
|
409
|
+
'abandoned',
|
|
410
|
+
)
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
async function* responseSource(terminal: Promise<void>) {
|
|
415
|
+
yield Packet.toBinary({
|
|
416
|
+
body: {
|
|
417
|
+
case: 'callData',
|
|
418
|
+
value: {
|
|
419
|
+
data: response,
|
|
420
|
+
dataIsZero: false,
|
|
421
|
+
complete: false,
|
|
422
|
+
error: '',
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
await terminal
|
|
427
|
+
yield Packet.toBinary({
|
|
428
|
+
body: {
|
|
429
|
+
case: 'callData',
|
|
430
|
+
value: {
|
|
431
|
+
data: new Uint8Array(),
|
|
432
|
+
dataIsZero: false,
|
|
433
|
+
complete: true,
|
|
434
|
+
error: '',
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
})
|
|
438
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/// <reference lib="es2024.promise" />
|
|
2
|
+
import { ERR_RPC_ABORT } from './errors.js'
|
|
3
|
+
import { ClientRPC } from './client-rpc.js'
|
|
4
|
+
|
|
5
|
+
// ReceiptRpc exposes held unary calls without widening ProtoRpc.
|
|
6
|
+
export interface ReceiptRpc {
|
|
7
|
+
requestWithReceipt(
|
|
8
|
+
service: string,
|
|
9
|
+
method: string,
|
|
10
|
+
data: Uint8Array,
|
|
11
|
+
abortSignal?: AbortSignal,
|
|
12
|
+
): Promise<HeldCall>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// HeldCall contains the first response and its held terminal receipt.
|
|
16
|
+
export interface HeldCall {
|
|
17
|
+
readonly response: Uint8Array
|
|
18
|
+
readonly receipt: CallReceipt
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// CallReceipt holds a unary call until it is committed or aborted.
|
|
22
|
+
export class CallReceipt {
|
|
23
|
+
#call: ClientRPC
|
|
24
|
+
#iterator: AsyncIterator<Uint8Array>
|
|
25
|
+
#terminalPromise: Promise<IteratorResult<Uint8Array>>
|
|
26
|
+
#terminal?: 'committed' | 'aborted'
|
|
27
|
+
#done: Promise<void>
|
|
28
|
+
#requestCommitted = false
|
|
29
|
+
#resolveDone!: () => void
|
|
30
|
+
#rejectDone!: (reason?: unknown) => void
|
|
31
|
+
|
|
32
|
+
public constructor(call: ClientRPC, iterator: AsyncIterator<Uint8Array>) {
|
|
33
|
+
this.#call = call
|
|
34
|
+
this.#iterator = iterator
|
|
35
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>()
|
|
36
|
+
this.#done = promise
|
|
37
|
+
this.#resolveDone = resolve
|
|
38
|
+
this.#rejectDone = reject
|
|
39
|
+
this.#done.catch(() => undefined)
|
|
40
|
+
this.#terminalPromise = this.#observeTerminal()
|
|
41
|
+
this.#terminalPromise.catch(() => undefined)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async #observeTerminal(): Promise<IteratorResult<Uint8Array>> {
|
|
45
|
+
try {
|
|
46
|
+
const result = await this.#iterator.next()
|
|
47
|
+
if (!result.done) {
|
|
48
|
+
throw new Error('unexpected trailing response data')
|
|
49
|
+
}
|
|
50
|
+
const terminal = this.#call.getTerminalKind()
|
|
51
|
+
if (
|
|
52
|
+
terminal !== 'committed' ||
|
|
53
|
+
!this.#requestCommitted ||
|
|
54
|
+
this.#terminal !== 'committed'
|
|
55
|
+
) {
|
|
56
|
+
throw new Error('receipt closed before commit')
|
|
57
|
+
}
|
|
58
|
+
return result
|
|
59
|
+
} catch (err) {
|
|
60
|
+
this.#rejectDone(err)
|
|
61
|
+
const error =
|
|
62
|
+
err instanceof Error ? err : new Error('receipt terminal failed')
|
|
63
|
+
try {
|
|
64
|
+
await this.#call.close(error)
|
|
65
|
+
} catch {
|
|
66
|
+
// The primary terminal error is already recorded on the receipt.
|
|
67
|
+
}
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// done resolves after committed close and rejects on terminal failure.
|
|
73
|
+
public get done(): Promise<void> {
|
|
74
|
+
return this.#done
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// settled reports whether a terminal transition has been claimed.
|
|
78
|
+
public get settled(): boolean {
|
|
79
|
+
return this.#terminal !== undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// commit sends request completion and waits for server finalization.
|
|
83
|
+
public async commit(): Promise<void> {
|
|
84
|
+
if (this.#terminal === 'aborted') {
|
|
85
|
+
throw new Error(ERR_RPC_ABORT)
|
|
86
|
+
}
|
|
87
|
+
if (this.#terminal === 'committed') {
|
|
88
|
+
return this.#done
|
|
89
|
+
}
|
|
90
|
+
this.#terminal = 'committed'
|
|
91
|
+
this.#requestCommitted = true
|
|
92
|
+
try {
|
|
93
|
+
await this.#call.writeCallData(undefined, true)
|
|
94
|
+
await this.#terminalPromise
|
|
95
|
+
await this.#call.close()
|
|
96
|
+
this.#resolveDone()
|
|
97
|
+
} catch (err) {
|
|
98
|
+
await this.#call.close(
|
|
99
|
+
err instanceof Error ? err : new Error('receipt commit failed'),
|
|
100
|
+
)
|
|
101
|
+
this.#rejectDone(err)
|
|
102
|
+
throw err
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// abort sends request cancellation and never rejects.
|
|
107
|
+
public async abort(reason?: Error): Promise<void> {
|
|
108
|
+
if (this.#terminal !== undefined) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
this.#terminal = 'aborted'
|
|
112
|
+
let terminalError: unknown = reason
|
|
113
|
+
try {
|
|
114
|
+
await this.#call.writeCallCancel(true)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
terminalError ??= err
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await this.#call.close()
|
|
120
|
+
} catch (err) {
|
|
121
|
+
terminalError ??= err
|
|
122
|
+
}
|
|
123
|
+
this.#rejectDone(terminalError ?? new Error(ERR_RPC_ABORT))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// asyncDispose aborts a receipt that has not reached a terminal.
|
|
127
|
+
public async [Symbol.asyncDispose](): Promise<void> {
|
|
128
|
+
await this.abort()
|
|
129
|
+
}
|
|
130
|
+
}
|