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/stream.test.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { pipe } from 'it-pipe'
|
|
3
|
+
import { pushable } from 'it-pushable'
|
|
4
|
+
import type { Source } from 'it-stream-types'
|
|
5
|
+
import type { Stream } from './stream-muxer.js'
|
|
6
|
+
import { streamToPacketStream } from './stream.js'
|
|
3
7
|
|
|
4
8
|
import {
|
|
5
9
|
ChannelStream,
|
|
@@ -71,6 +75,133 @@ describe('StreamConn packet stream', () => {
|
|
|
71
75
|
expect(serverError).toBeUndefined()
|
|
72
76
|
})
|
|
73
77
|
|
|
78
|
+
it('settles a blocked packet source when closed', async () => {
|
|
79
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
80
|
+
handlePacketStream() {},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const stream = await clientConn.openStream()
|
|
85
|
+
const pending = stream.source.next()
|
|
86
|
+
|
|
87
|
+
await stream.close()
|
|
88
|
+
|
|
89
|
+
await expect(pending).resolves.toEqual({ done: true, value: undefined })
|
|
90
|
+
} finally {
|
|
91
|
+
cleanup()
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('settles a blocked packet source when aborted', async () => {
|
|
96
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
97
|
+
handlePacketStream() {},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const stream = await clientConn.openStream()
|
|
102
|
+
const pending = stream.source.next()
|
|
103
|
+
const error = new Error('stopped')
|
|
104
|
+
|
|
105
|
+
stream.abort(error)
|
|
106
|
+
|
|
107
|
+
await expect(pending).rejects.toBe(error)
|
|
108
|
+
} finally {
|
|
109
|
+
cleanup()
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('settles a blocked packet sink when closed', async () => {
|
|
114
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
115
|
+
handlePacketStream() {},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const stream = await clientConn.openStream()
|
|
120
|
+
const input = pushable<Uint8Array>({ objectMode: true })
|
|
121
|
+
const pending = stream.sink(input)
|
|
122
|
+
|
|
123
|
+
await stream.close()
|
|
124
|
+
|
|
125
|
+
await expect(pending).resolves.toBeUndefined()
|
|
126
|
+
} finally {
|
|
127
|
+
cleanup()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('settles a packet sink blocked in the underlying write', async () => {
|
|
132
|
+
const sinkStarted = Promise.withResolvers<void>()
|
|
133
|
+
const transport = {
|
|
134
|
+
source: (async function* () {})(),
|
|
135
|
+
sink: async (source: Source<Uint8Array>) => {
|
|
136
|
+
for await (const _chunk of source) {
|
|
137
|
+
sinkStarted.resolve()
|
|
138
|
+
await new Promise<void>(() => {})
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
close: vi.fn(async () => {}),
|
|
142
|
+
closeRead: vi.fn(async () => {}),
|
|
143
|
+
closeWrite: vi.fn(async () => {}),
|
|
144
|
+
abort: vi.fn(),
|
|
145
|
+
} satisfies Stream
|
|
146
|
+
const stream = streamToPacketStream(transport)
|
|
147
|
+
const input = pushable<Uint8Array>({ objectMode: true })
|
|
148
|
+
input.push(new Uint8Array([1]))
|
|
149
|
+
const pending = stream.sink(input)
|
|
150
|
+
await sinkStarted.promise
|
|
151
|
+
|
|
152
|
+
await stream.close()
|
|
153
|
+
|
|
154
|
+
await expect(pending).resolves.toBeUndefined()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('rejects a blocked packet sink with the abort error', async () => {
|
|
158
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
159
|
+
handlePacketStream() {},
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const stream = await clientConn.openStream()
|
|
164
|
+
const input = pushable<Uint8Array>({ objectMode: true })
|
|
165
|
+
const pending = stream.sink(input)
|
|
166
|
+
const error = new Error('stopped')
|
|
167
|
+
|
|
168
|
+
stream.abort(error)
|
|
169
|
+
|
|
170
|
+
await expect(pending).rejects.toBe(error)
|
|
171
|
+
} finally {
|
|
172
|
+
cleanup()
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not write ready input after close', async () => {
|
|
177
|
+
const serverStream = Promise.withResolvers<PacketStream>()
|
|
178
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
179
|
+
handlePacketStream(stream) {
|
|
180
|
+
serverStream.resolve(stream)
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const stream = await clientConn.openStream()
|
|
186
|
+
const peer = await serverStream.promise
|
|
187
|
+
const input = pushable<Uint8Array>({ objectMode: true })
|
|
188
|
+
input.push(new Uint8Array([1]))
|
|
189
|
+
|
|
190
|
+
const pending = stream.sink(input)
|
|
191
|
+
await stream.close()
|
|
192
|
+
|
|
193
|
+
await expect(pending).resolves.toBeUndefined()
|
|
194
|
+
await expect(nextWithTimeout(peer.source, 'server eof')).resolves.toEqual(
|
|
195
|
+
{
|
|
196
|
+
done: true,
|
|
197
|
+
value: undefined,
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
} finally {
|
|
201
|
+
cleanup()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
74
205
|
it('aborts the yamux stream when the packet source errors', async () => {
|
|
75
206
|
const request = new TextEncoder().encode('request')
|
|
76
207
|
const sourceError = new Error('source failed')
|
package/srpc/stream.ts
CHANGED
|
@@ -8,16 +8,26 @@ import {
|
|
|
8
8
|
parseLengthPrefixTransform,
|
|
9
9
|
prependLengthPrefixTransform,
|
|
10
10
|
} from './packet.js'
|
|
11
|
+
import {
|
|
12
|
+
closeIterator,
|
|
13
|
+
sourceIterator,
|
|
14
|
+
TerminationGate,
|
|
15
|
+
} from './termination.js'
|
|
11
16
|
|
|
12
17
|
// PacketHandler handles incoming packets.
|
|
13
18
|
export type PacketHandler = (packet: Packet) => Promise<void>
|
|
14
19
|
|
|
15
20
|
// PacketStream represents a stream of packets where each Uint8Array represents one packet.
|
|
16
|
-
export
|
|
21
|
+
export interface PacketStream extends Duplex<
|
|
17
22
|
AsyncGenerator<Uint8Array>,
|
|
18
23
|
Source<Uint8Array>,
|
|
19
24
|
Promise<void>
|
|
20
|
-
>
|
|
25
|
+
> {
|
|
26
|
+
// close cleanly ends both directions of the stream.
|
|
27
|
+
close(): Promise<void>
|
|
28
|
+
// abort ends both directions of the stream with err.
|
|
29
|
+
abort(err: Error): void
|
|
30
|
+
}
|
|
21
31
|
|
|
22
32
|
// OpenStreamFunc is a function to start a new RPC by opening a Stream.
|
|
23
33
|
export type OpenStreamFunc = () => Promise<PacketStream>
|
|
@@ -29,20 +39,66 @@ export type HandleStreamFunc = (ch: PacketStream) => Promise<void>
|
|
|
29
39
|
|
|
30
40
|
// streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
|
|
31
41
|
export function streamToPacketStream(stream: Stream): PacketStream {
|
|
42
|
+
const termination = new TerminationGate()
|
|
32
43
|
return {
|
|
33
|
-
|
|
34
|
-
stream
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
close: async () => {
|
|
45
|
+
if (termination.terminate()) await stream.close()
|
|
46
|
+
},
|
|
47
|
+
abort: (err: Error) => {
|
|
48
|
+
if (termination.terminate(err)) stream.abort(err)
|
|
49
|
+
},
|
|
50
|
+
source: (async function* () {
|
|
51
|
+
const packets = pipe(
|
|
52
|
+
stream,
|
|
53
|
+
parseLengthPrefixTransform(),
|
|
54
|
+
combineUint8ArrayListTransform(),
|
|
55
|
+
)[Symbol.asyncIterator]()
|
|
56
|
+
try {
|
|
57
|
+
while (true) {
|
|
58
|
+
const next = await termination.next(packets)
|
|
59
|
+
if ('terminated' in next) {
|
|
60
|
+
if (next.error) throw next.error
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if ('error' in next) throw next.error
|
|
64
|
+
if (next.result.done) return
|
|
65
|
+
yield next.result.value
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
closeIterator(packets)
|
|
69
|
+
}
|
|
70
|
+
})(),
|
|
38
71
|
sink: async (source: Source<Uint8Array>): Promise<void> => {
|
|
72
|
+
const iterator = sourceIterator(
|
|
73
|
+
pipe(source, prependLengthPrefixTransform()),
|
|
74
|
+
)
|
|
75
|
+
const gatedSource = (async function* () {
|
|
76
|
+
while (true) {
|
|
77
|
+
const next = await termination.next(iterator)
|
|
78
|
+
if ('terminated' in next) {
|
|
79
|
+
if (next.error) throw next.error
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if ('error' in next) throw next.error
|
|
83
|
+
if (next.result.done) return
|
|
84
|
+
if (termination.terminated) return
|
|
85
|
+
yield next.result.value
|
|
86
|
+
}
|
|
87
|
+
})()
|
|
39
88
|
try {
|
|
40
|
-
await
|
|
89
|
+
const result = await termination.wait(stream.sink(gatedSource))
|
|
90
|
+
if ('terminated' in result) {
|
|
91
|
+
if (result.error) throw result.error
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if ('error' in result) throw result.error
|
|
41
95
|
await stream.closeWrite()
|
|
42
96
|
} catch (err) {
|
|
43
97
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
44
|
-
stream.abort(error)
|
|
98
|
+
if (termination.terminate(error)) stream.abort(error)
|
|
45
99
|
throw error
|
|
100
|
+
} finally {
|
|
101
|
+
closeIterator(iterator)
|
|
46
102
|
}
|
|
47
103
|
},
|
|
48
104
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { TerminationGate } from './termination.js'
|
|
4
|
+
|
|
5
|
+
describe('TerminationGate', () => {
|
|
6
|
+
it('prioritizes termination over an already-ready result', async () => {
|
|
7
|
+
const gate = new TerminationGate()
|
|
8
|
+
const iterator = (async function* () {
|
|
9
|
+
yield 1
|
|
10
|
+
})()
|
|
11
|
+
const pending = gate.next(iterator)
|
|
12
|
+
const error = new Error('stopped')
|
|
13
|
+
|
|
14
|
+
gate.terminate(error)
|
|
15
|
+
|
|
16
|
+
await expect(pending).resolves.toEqual({ terminated: true, error })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('keeps the first termination result', async () => {
|
|
20
|
+
const gate = new TerminationGate()
|
|
21
|
+
const error = new Error('stopped')
|
|
22
|
+
|
|
23
|
+
expect(gate.terminate(error)).toBe(true)
|
|
24
|
+
expect(gate.terminate()).toBe(false)
|
|
25
|
+
await expect(gate.wait(new Promise<void>(() => {}))).resolves.toEqual({
|
|
26
|
+
terminated: true,
|
|
27
|
+
error,
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Source } from 'it-stream-types'
|
|
2
|
+
|
|
3
|
+
type Terminated = { terminated: true; error?: Error }
|
|
4
|
+
type Received<T> = { result: IteratorResult<T> } | { error: unknown }
|
|
5
|
+
type WaitResult<T> = { result: T } | { error: unknown }
|
|
6
|
+
|
|
7
|
+
// TerminationGate makes the first clean close or abort visible to blocked I/O.
|
|
8
|
+
export class TerminationGate {
|
|
9
|
+
private readonly _waiters = new Set<(error?: Error) => void>()
|
|
10
|
+
private _error: Error | undefined
|
|
11
|
+
private _terminated = false
|
|
12
|
+
|
|
13
|
+
public get terminated(): boolean {
|
|
14
|
+
return this._terminated
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public terminate(error?: Error): boolean {
|
|
18
|
+
if (this._terminated) return false
|
|
19
|
+
this._terminated = true
|
|
20
|
+
this._error = error
|
|
21
|
+
for (const waiter of this._waiters) waiter(error)
|
|
22
|
+
this._waiters.clear()
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public async wait<T>(
|
|
27
|
+
promise: Promise<T>,
|
|
28
|
+
): Promise<WaitResult<T> | Terminated> {
|
|
29
|
+
if (this._terminated) return { terminated: true, error: this._error }
|
|
30
|
+
|
|
31
|
+
let notify!: (error?: Error) => void
|
|
32
|
+
const terminated = new Promise<Terminated>((resolve) => {
|
|
33
|
+
notify = (error) => resolve({ terminated: true, error })
|
|
34
|
+
this._waiters.add(notify)
|
|
35
|
+
})
|
|
36
|
+
const received: Promise<WaitResult<T>> = promise.then(
|
|
37
|
+
(result) => ({ result }),
|
|
38
|
+
(error: unknown) => ({ error }),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const result = await Promise.race([received, terminated])
|
|
43
|
+
if (this._terminated) {
|
|
44
|
+
return { terminated: true, error: this._error }
|
|
45
|
+
}
|
|
46
|
+
return result
|
|
47
|
+
} finally {
|
|
48
|
+
this._waiters.delete(notify)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public next<T>(
|
|
53
|
+
iterator: AsyncIterator<T>,
|
|
54
|
+
): Promise<Received<T> | Terminated> {
|
|
55
|
+
return this.wait(iterator.next())
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sourceIterator<T>(source: Source<T>): AsyncIterator<T> {
|
|
60
|
+
if (Symbol.asyncIterator in source) return source[Symbol.asyncIterator]()
|
|
61
|
+
const iterator = source[Symbol.iterator]()
|
|
62
|
+
return { next: async () => iterator.next() }
|
|
63
|
+
}
|
|
64
|
+
// closeIterator asks an abandoned iterator to release its upstream resources.
|
|
65
|
+
// Async generators may wait for an active next call before running return;
|
|
66
|
+
// rejection is observed here so cleanup cannot create an unhandled promise.
|
|
67
|
+
export function closeIterator<T>(iterator: AsyncIterator<T>): void {
|
|
68
|
+
if (!iterator.return) return
|
|
69
|
+
void Promise.resolve(iterator.return()).catch(() => undefined)
|
|
70
|
+
}
|