starpc 0.52.0 → 0.52.2
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 +37 -8
- 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-rpc.d.ts +1 -0
- package/dist/srpc/client-rpc.js +9 -0
- package/dist/srpc/client-rpc.test.d.ts +1 -0
- package/dist/srpc/client-rpc.test.js +50 -0
- package/dist/srpc/client.js +18 -4
- package/dist/srpc/common-rpc.d.ts +5 -0
- package/dist/srpc/common-rpc.js +37 -8
- package/dist/srpc/common-rpc.test.js +2 -0
- package/dist/srpc/errors.d.ts +2 -0
- package/dist/srpc/errors.js +14 -0
- package/dist/srpc/index.d.ts +1 -1
- package/dist/srpc/index.js +1 -1
- 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/Cargo.toml +8 -0
- package/echo/client-test.ts +124 -2
- package/echo/echo_e2e_test.cpp +542 -985
- package/echo/echo_pb2.py +40 -0
- package/echo/echo_pb2.pyi +13 -0
- package/echo/echo_srpc.pb.cpp +3 -5
- package/echo/echo_srpc.pb.go +6 -9
- package/echo/echo_srpc.pb.hpp +23 -3
- package/echo/echo_srpc.pb.rs +61 -26
- package/echo/echo_srpc.py +309 -0
- package/echo/echo_srpc.pyi +85 -0
- package/echo/half_close_test.rs +178 -0
- package/echo/integration_server.rs +14 -21
- package/echo/main.rs +28 -48
- package/go.mod +3 -3
- package/go.sum +6 -8
- package/integration/cross-language/cpp-client.cpp +446 -402
- package/integration/cross-language/cpp-pipe-server.cpp +139 -0
- package/integration/cross-language/cpp-pipe_test.go +114 -0
- package/integration/cross-language/cpp-server.cpp +222 -186
- 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.pb.cpp +1 -1
- package/mock/mock_srpc.pb.hpp +1 -1
- package/mock/mock_srpc.pb.rs +10 -9
- package/mock/mock_srpc.py +71 -0
- package/mock/mock_srpc.pyi +27 -0
- package/package.json +19 -5
- package/srpc/CMakeLists.txt +3 -58
- package/srpc/README-cpp.md +69 -0
- package/srpc/__init__.py +0 -0
- package/srpc/accept.go +10 -4
- package/srpc/accept_test.go +25 -0
- package/srpc/build.rs +129 -34
- package/srpc/client-half-close_test.go +152 -0
- package/srpc/client-invoker.go +32 -42
- package/srpc/client-invoker_test.go +53 -0
- package/srpc/client-rpc.cpp +65 -80
- package/srpc/client-rpc.go +32 -33
- package/srpc/client-rpc.hpp +52 -38
- package/srpc/client-rpc.test.ts +67 -0
- package/srpc/client-rpc.ts +10 -0
- package/srpc/client-rpc_test.go +76 -0
- package/srpc/client.cpp +99 -87
- package/srpc/client.hpp +46 -35
- package/srpc/client.rs +78 -38
- package/srpc/client.ts +20 -4
- package/srpc/client_half_close_test.cc +331 -0
- package/srpc/client_invoker.rs +75 -0
- package/srpc/codec.rs +6 -0
- package/srpc/common-rpc.cpp +186 -146
- package/srpc/common-rpc.go +5 -4
- package/srpc/common-rpc.hpp +119 -89
- package/srpc/common-rpc.test.ts +2 -0
- package/srpc/common-rpc.ts +43 -8
- package/srpc/common-rpc_test.go +23 -0
- package/srpc/error.rs +19 -2
- package/srpc/errors.hpp +80 -50
- package/srpc/errors.ts +16 -0
- package/srpc/handler.hpp +9 -7
- package/srpc/handler.rs +7 -45
- package/srpc/index.ts +2 -0
- package/srpc/invoker.hpp +82 -55
- package/srpc/invoker.rs +14 -52
- package/srpc/lib.rs +2 -0
- package/srpc/message.hpp +17 -12
- package/srpc/msg-stream.go +1 -0
- package/srpc/msg-stream.hpp +108 -61
- package/srpc/mux.cpp +84 -84
- package/srpc/mux.hpp +48 -38
- package/srpc/mux.rs +4 -4
- package/srpc/net.go +12 -3
- 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.cpp +60 -58
- package/srpc/packet.hpp +35 -20
- package/srpc/packet.rs +8 -27
- package/srpc/packet.ts +15 -2
- package/srpc/py.typed +0 -0
- package/srpc/rpc.rs +332 -115
- package/srpc/rpc_lifecycle_test.cc +333 -0
- package/srpc/rpcproto_pb2.py +40 -0
- package/srpc/rpcproto_pb2.pyi +40 -0
- package/srpc/server-cancel_test.go +39 -0
- package/srpc/server-rpc.cpp +83 -73
- package/srpc/server-rpc.go +21 -19
- package/srpc/server-rpc.hpp +56 -37
- package/srpc/server.rs +76 -131
- package/srpc/server.test.ts +50 -0
- package/srpc/server.ts +22 -6
- package/srpc/starpc.hpp +4 -2
- package/srpc/stream.go +4 -0
- package/srpc/stream.hpp +95 -34
- package/srpc/stream.rs +7 -2
- 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/testing.rs +11 -66
- package/srpc/transport.rs +21 -71
- package/srpc/websocket.rs +106 -57
- package/srpc/writer.hpp +46 -32
- package/srpc/yamux.rs +82 -26
- package/srpc/tests/integration_test.rs +0 -495
package/dist/srpc/server.js
CHANGED
|
@@ -13,9 +13,7 @@ export class Server {
|
|
|
13
13
|
get rpcStreamHandler() {
|
|
14
14
|
return async (stream) => {
|
|
15
15
|
const rpc = this.startRpc();
|
|
16
|
-
return
|
|
17
|
-
.catch((err) => rpc.close(err))
|
|
18
|
-
.then(() => rpc.close());
|
|
16
|
+
return runPacketStream(stream, rpc);
|
|
19
17
|
};
|
|
20
18
|
}
|
|
21
19
|
// startRpc starts a new server-side RPC.
|
|
@@ -27,9 +25,24 @@ export class Server {
|
|
|
27
25
|
// the stream has one Uint8Array per packet w/o length prefix.
|
|
28
26
|
handlePacketStream(stream) {
|
|
29
27
|
const rpc = this.startRpc();
|
|
30
|
-
|
|
31
|
-
.catch((err) => rpc.close(err))
|
|
32
|
-
.then(() => rpc.close());
|
|
28
|
+
void runPacketStream(stream, rpc).catch(() => undefined);
|
|
33
29
|
return rpc;
|
|
34
30
|
}
|
|
35
31
|
}
|
|
32
|
+
async function runPacketStream(stream, rpc) {
|
|
33
|
+
try {
|
|
34
|
+
await pipe(stream, decodePacketSource, rpc, encodePacketSource, stream);
|
|
35
|
+
if (rpc.isClosed instanceof Error) {
|
|
36
|
+
stream.abort(rpc.isClosed);
|
|
37
|
+
throw rpc.isClosed;
|
|
38
|
+
}
|
|
39
|
+
await stream.close();
|
|
40
|
+
await rpc.close();
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
44
|
+
stream.abort(error);
|
|
45
|
+
await rpc.close(error);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/srpc/server.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
2
2
|
import { pipe } from 'it-pipe';
|
|
3
|
+
import { pushable } from 'it-pushable';
|
|
3
4
|
import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, Packet, createContextKey, serverContextValue, withServerContextValue, } from '../srpc/index.js';
|
|
4
5
|
import { EchoerDefinition, EchoerServer, EchoerServiceName, EchoMsg, runClientTest, } from '../echo/index.js';
|
|
5
6
|
import { runAbortControllerTest, runRpcStreamTest, } from '../echo/client-test.js';
|
|
@@ -112,6 +113,8 @@ describe('srpc server', () => {
|
|
|
112
113
|
const server = new Server(mux.lookupMethod);
|
|
113
114
|
const firstResponse = new Promise((resolve, reject) => {
|
|
114
115
|
server.handlePacketStream({
|
|
116
|
+
close: async () => { },
|
|
117
|
+
abort: () => { },
|
|
115
118
|
source: (async function* () {
|
|
116
119
|
yield Packet.toBinary({
|
|
117
120
|
body: {
|
|
@@ -229,6 +232,46 @@ describe('srpc server', () => {
|
|
|
229
232
|
controller.abort();
|
|
230
233
|
await Promise.resolve();
|
|
231
234
|
});
|
|
235
|
+
it('closes the packet stream when the server pipeline completes', async () => {
|
|
236
|
+
const server = new Server(createMux().lookupMethod);
|
|
237
|
+
const close = vi.fn(async () => { });
|
|
238
|
+
const source = pushable({ objectMode: true });
|
|
239
|
+
const stream = {
|
|
240
|
+
close,
|
|
241
|
+
abort: vi.fn(),
|
|
242
|
+
source,
|
|
243
|
+
sink: async (output) => {
|
|
244
|
+
for await (const _packet of output) {
|
|
245
|
+
// Drain the response pipeline.
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
const rpc = server.handlePacketStream(stream);
|
|
250
|
+
await rpc.close();
|
|
251
|
+
source.end();
|
|
252
|
+
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
|
|
253
|
+
expect(stream.abort).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
it('aborts the packet stream when the server pipeline fails', async () => {
|
|
256
|
+
const server = new Server(createMux().lookupMethod);
|
|
257
|
+
const error = new Error('input failed');
|
|
258
|
+
const abort = vi.fn();
|
|
259
|
+
const source = pushable({ objectMode: true });
|
|
260
|
+
const stream = {
|
|
261
|
+
close: vi.fn(async () => { }),
|
|
262
|
+
abort,
|
|
263
|
+
source,
|
|
264
|
+
sink: async (output) => {
|
|
265
|
+
for await (const _packet of output) {
|
|
266
|
+
// Drain the response pipeline.
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
server.handlePacketStream(stream);
|
|
271
|
+
source.end(error);
|
|
272
|
+
await vi.waitFor(() => expect(abort).toHaveBeenCalledWith(error));
|
|
273
|
+
expect(stream.close).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
232
275
|
it('tears down passive channel close state', async () => {
|
|
233
276
|
const { port1, port2 } = new MessageChannel();
|
|
234
277
|
const opts = { idleTimeoutMs: 1000, keepAliveMs: 1000 };
|
package/dist/srpc/stream.d.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { Duplex, Source } from 'it-stream-types';
|
|
|
2
2
|
import type { Stream } from './stream-muxer.js';
|
|
3
3
|
import type { Packet } from './rpcproto.pb.js';
|
|
4
4
|
export type PacketHandler = (packet: Packet) => Promise<void>;
|
|
5
|
-
export
|
|
5
|
+
export interface PacketStream extends Duplex<AsyncGenerator<Uint8Array>, Source<Uint8Array>, Promise<void>> {
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
abort(err: Error): void;
|
|
8
|
+
}
|
|
6
9
|
export type OpenStreamFunc = () => Promise<PacketStream>;
|
|
7
10
|
export type HandleStreamFunc = (ch: PacketStream) => Promise<void>;
|
|
8
11
|
export declare function streamToPacketStream(stream: Stream): PacketStream;
|
package/dist/srpc/stream.js
CHANGED
|
@@ -1,20 +1,79 @@
|
|
|
1
1
|
import { pipe } from 'it-pipe';
|
|
2
2
|
import { combineUint8ArrayListTransform } from './array-list.js';
|
|
3
3
|
import { parseLengthPrefixTransform, prependLengthPrefixTransform, } from './packet.js';
|
|
4
|
+
import { closeIterator, sourceIterator, TerminationGate, } from './termination.js';
|
|
4
5
|
// streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
|
|
5
6
|
export function streamToPacketStream(stream) {
|
|
7
|
+
const termination = new TerminationGate();
|
|
6
8
|
return {
|
|
7
|
-
|
|
9
|
+
close: async () => {
|
|
10
|
+
if (termination.terminate())
|
|
11
|
+
await stream.close();
|
|
12
|
+
},
|
|
13
|
+
abort: (err) => {
|
|
14
|
+
if (termination.terminate(err))
|
|
15
|
+
stream.abort(err);
|
|
16
|
+
},
|
|
17
|
+
source: (async function* () {
|
|
18
|
+
const packets = pipe(stream, parseLengthPrefixTransform(), combineUint8ArrayListTransform())[Symbol.asyncIterator]();
|
|
19
|
+
try {
|
|
20
|
+
while (true) {
|
|
21
|
+
const next = await termination.next(packets);
|
|
22
|
+
if ('terminated' in next) {
|
|
23
|
+
if (next.error)
|
|
24
|
+
throw next.error;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if ('error' in next)
|
|
28
|
+
throw next.error;
|
|
29
|
+
if (next.result.done)
|
|
30
|
+
return;
|
|
31
|
+
yield next.result.value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
closeIterator(packets);
|
|
36
|
+
}
|
|
37
|
+
})(),
|
|
8
38
|
sink: async (source) => {
|
|
39
|
+
const iterator = sourceIterator(pipe(source, prependLengthPrefixTransform()));
|
|
40
|
+
const gatedSource = (async function* () {
|
|
41
|
+
while (true) {
|
|
42
|
+
const next = await termination.next(iterator);
|
|
43
|
+
if ('terminated' in next) {
|
|
44
|
+
if (next.error)
|
|
45
|
+
throw next.error;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if ('error' in next)
|
|
49
|
+
throw next.error;
|
|
50
|
+
if (next.result.done)
|
|
51
|
+
return;
|
|
52
|
+
if (termination.terminated)
|
|
53
|
+
return;
|
|
54
|
+
yield next.result.value;
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
9
57
|
try {
|
|
10
|
-
await
|
|
58
|
+
const result = await termination.wait(stream.sink(gatedSource));
|
|
59
|
+
if ('terminated' in result) {
|
|
60
|
+
if (result.error)
|
|
61
|
+
throw result.error;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if ('error' in result)
|
|
65
|
+
throw result.error;
|
|
11
66
|
await stream.closeWrite();
|
|
12
67
|
}
|
|
13
68
|
catch (err) {
|
|
14
69
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
15
|
-
|
|
70
|
+
if (termination.terminate(error))
|
|
71
|
+
stream.abort(error);
|
|
16
72
|
throw error;
|
|
17
73
|
}
|
|
74
|
+
finally {
|
|
75
|
+
closeIterator(iterator);
|
|
76
|
+
}
|
|
18
77
|
},
|
|
19
78
|
};
|
|
20
79
|
}
|
package/dist/srpc/stream.test.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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 { streamToPacketStream } from './stream.js';
|
|
3
5
|
import { ChannelStream, combineUint8ArrayListTransform, StreamConn, } from '../srpc/index.js';
|
|
4
6
|
describe('StreamConn packet stream', () => {
|
|
5
7
|
it('keeps yamux peer writes open after local packet source completes normally', async () => {
|
|
@@ -48,6 +50,113 @@ describe('StreamConn packet stream', () => {
|
|
|
48
50
|
await serverDone;
|
|
49
51
|
expect(serverError).toBeUndefined();
|
|
50
52
|
});
|
|
53
|
+
it('settles a blocked packet source when closed', async () => {
|
|
54
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
55
|
+
handlePacketStream() { },
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
const stream = await clientConn.openStream();
|
|
59
|
+
const pending = stream.source.next();
|
|
60
|
+
await stream.close();
|
|
61
|
+
await expect(pending).resolves.toEqual({ done: true, value: undefined });
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
cleanup();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
it('settles a blocked packet source when aborted', async () => {
|
|
68
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
69
|
+
handlePacketStream() { },
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
const stream = await clientConn.openStream();
|
|
73
|
+
const pending = stream.source.next();
|
|
74
|
+
const error = new Error('stopped');
|
|
75
|
+
stream.abort(error);
|
|
76
|
+
await expect(pending).rejects.toBe(error);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
cleanup();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it('settles a blocked packet sink when closed', async () => {
|
|
83
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
84
|
+
handlePacketStream() { },
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const stream = await clientConn.openStream();
|
|
88
|
+
const input = pushable({ objectMode: true });
|
|
89
|
+
const pending = stream.sink(input);
|
|
90
|
+
await stream.close();
|
|
91
|
+
await expect(pending).resolves.toBeUndefined();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
cleanup();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
it('settles a packet sink blocked in the underlying write', async () => {
|
|
98
|
+
const sinkStarted = Promise.withResolvers();
|
|
99
|
+
const transport = {
|
|
100
|
+
source: (async function* () { })(),
|
|
101
|
+
sink: async (source) => {
|
|
102
|
+
for await (const _chunk of source) {
|
|
103
|
+
sinkStarted.resolve();
|
|
104
|
+
await new Promise(() => { });
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
close: vi.fn(async () => { }),
|
|
108
|
+
closeRead: vi.fn(async () => { }),
|
|
109
|
+
closeWrite: vi.fn(async () => { }),
|
|
110
|
+
abort: vi.fn(),
|
|
111
|
+
};
|
|
112
|
+
const stream = streamToPacketStream(transport);
|
|
113
|
+
const input = pushable({ objectMode: true });
|
|
114
|
+
input.push(new Uint8Array([1]));
|
|
115
|
+
const pending = stream.sink(input);
|
|
116
|
+
await sinkStarted.promise;
|
|
117
|
+
await stream.close();
|
|
118
|
+
await expect(pending).resolves.toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
it('rejects a blocked packet sink with the abort error', async () => {
|
|
121
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
122
|
+
handlePacketStream() { },
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
const stream = await clientConn.openStream();
|
|
126
|
+
const input = pushable({ objectMode: true });
|
|
127
|
+
const pending = stream.sink(input);
|
|
128
|
+
const error = new Error('stopped');
|
|
129
|
+
stream.abort(error);
|
|
130
|
+
await expect(pending).rejects.toBe(error);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
cleanup();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
it('does not write ready input after close', async () => {
|
|
137
|
+
const serverStream = Promise.withResolvers();
|
|
138
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
139
|
+
handlePacketStream(stream) {
|
|
140
|
+
serverStream.resolve(stream);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
const stream = await clientConn.openStream();
|
|
145
|
+
const peer = await serverStream.promise;
|
|
146
|
+
const input = pushable({ objectMode: true });
|
|
147
|
+
input.push(new Uint8Array([1]));
|
|
148
|
+
const pending = stream.sink(input);
|
|
149
|
+
await stream.close();
|
|
150
|
+
await expect(pending).resolves.toBeUndefined();
|
|
151
|
+
await expect(nextWithTimeout(peer.source, 'server eof')).resolves.toEqual({
|
|
152
|
+
done: true,
|
|
153
|
+
value: undefined,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
cleanup();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
51
160
|
it('aborts the yamux stream when the packet source errors', async () => {
|
|
52
161
|
const request = new TextEncoder().encode('request');
|
|
53
162
|
const sourceError = new Error('source failed');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Source } from 'it-stream-types';
|
|
2
|
+
type Terminated = {
|
|
3
|
+
terminated: true;
|
|
4
|
+
error?: Error;
|
|
5
|
+
};
|
|
6
|
+
type Received<T> = {
|
|
7
|
+
result: IteratorResult<T>;
|
|
8
|
+
} | {
|
|
9
|
+
error: unknown;
|
|
10
|
+
};
|
|
11
|
+
type WaitResult<T> = {
|
|
12
|
+
result: T;
|
|
13
|
+
} | {
|
|
14
|
+
error: unknown;
|
|
15
|
+
};
|
|
16
|
+
export declare class TerminationGate {
|
|
17
|
+
private readonly _waiters;
|
|
18
|
+
private _error;
|
|
19
|
+
private _terminated;
|
|
20
|
+
get terminated(): boolean;
|
|
21
|
+
terminate(error?: Error): boolean;
|
|
22
|
+
wait<T>(promise: Promise<T>): Promise<WaitResult<T> | Terminated>;
|
|
23
|
+
next<T>(iterator: AsyncIterator<T>): Promise<Received<T> | Terminated>;
|
|
24
|
+
}
|
|
25
|
+
export declare function sourceIterator<T>(source: Source<T>): AsyncIterator<T>;
|
|
26
|
+
export declare function closeIterator<T>(iterator: AsyncIterator<T>): void;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// TerminationGate makes the first clean close or abort visible to blocked I/O.
|
|
2
|
+
export class TerminationGate {
|
|
3
|
+
_waiters = new Set();
|
|
4
|
+
_error;
|
|
5
|
+
_terminated = false;
|
|
6
|
+
get terminated() {
|
|
7
|
+
return this._terminated;
|
|
8
|
+
}
|
|
9
|
+
terminate(error) {
|
|
10
|
+
if (this._terminated)
|
|
11
|
+
return false;
|
|
12
|
+
this._terminated = true;
|
|
13
|
+
this._error = error;
|
|
14
|
+
for (const waiter of this._waiters)
|
|
15
|
+
waiter(error);
|
|
16
|
+
this._waiters.clear();
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
async wait(promise) {
|
|
20
|
+
if (this._terminated)
|
|
21
|
+
return { terminated: true, error: this._error };
|
|
22
|
+
let notify;
|
|
23
|
+
const terminated = new Promise((resolve) => {
|
|
24
|
+
notify = (error) => resolve({ terminated: true, error });
|
|
25
|
+
this._waiters.add(notify);
|
|
26
|
+
});
|
|
27
|
+
const received = promise.then((result) => ({ result }), (error) => ({ error }));
|
|
28
|
+
try {
|
|
29
|
+
const result = await Promise.race([received, terminated]);
|
|
30
|
+
if (this._terminated) {
|
|
31
|
+
return { terminated: true, error: this._error };
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
this._waiters.delete(notify);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
next(iterator) {
|
|
40
|
+
return this.wait(iterator.next());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function sourceIterator(source) {
|
|
44
|
+
if (Symbol.asyncIterator in source)
|
|
45
|
+
return source[Symbol.asyncIterator]();
|
|
46
|
+
const iterator = source[Symbol.iterator]();
|
|
47
|
+
return { next: async () => iterator.next() };
|
|
48
|
+
}
|
|
49
|
+
// closeIterator asks an abandoned iterator to release its upstream resources.
|
|
50
|
+
// Async generators may wait for an active next call before running return;
|
|
51
|
+
// rejection is observed here so cleanup cannot create an unhandled promise.
|
|
52
|
+
export function closeIterator(iterator) {
|
|
53
|
+
if (!iterator.return)
|
|
54
|
+
return;
|
|
55
|
+
void Promise.resolve(iterator.return()).catch(() => undefined);
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TerminationGate } from './termination.js';
|
|
3
|
+
describe('TerminationGate', () => {
|
|
4
|
+
it('prioritizes termination over an already-ready result', async () => {
|
|
5
|
+
const gate = new TerminationGate();
|
|
6
|
+
const iterator = (async function* () {
|
|
7
|
+
yield 1;
|
|
8
|
+
})();
|
|
9
|
+
const pending = gate.next(iterator);
|
|
10
|
+
const error = new Error('stopped');
|
|
11
|
+
gate.terminate(error);
|
|
12
|
+
await expect(pending).resolves.toEqual({ terminated: true, error });
|
|
13
|
+
});
|
|
14
|
+
it('keeps the first termination result', async () => {
|
|
15
|
+
const gate = new TerminationGate();
|
|
16
|
+
const error = new Error('stopped');
|
|
17
|
+
expect(gate.terminate(error)).toBe(true);
|
|
18
|
+
expect(gate.terminate()).toBe(false);
|
|
19
|
+
await expect(gate.wait(new Promise(() => { }))).resolves.toEqual({
|
|
20
|
+
terminated: true,
|
|
21
|
+
error,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"max_message_size": 10000000,
|
|
4
|
+
"frame_prefix": "uint32 little-endian",
|
|
5
|
+
"cases": [
|
|
6
|
+
{
|
|
7
|
+
"name": "call_start_data",
|
|
8
|
+
"packet_hex": "0a120a0373766312066d6574686f641a03616263",
|
|
9
|
+
"frame_hex": "140000000a120a0373766312066d6574686f641a03616263"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "call_start_absent_empty",
|
|
13
|
+
"packet_hex": "0a0d0a0373766312066d6574686f64",
|
|
14
|
+
"frame_hex": "0f0000000a0d0a0373766312066d6574686f64"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "call_start_present_empty",
|
|
18
|
+
"packet_hex": "0a0f0a0373766312066d6574686f642001",
|
|
19
|
+
"frame_hex": "110000000a0f0a0373766312066d6574686f642001"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "call_data_terminal",
|
|
23
|
+
"packet_hex": "12070a036f75741801",
|
|
24
|
+
"frame_hex": "0900000012070a036f75741801"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "call_data_error",
|
|
28
|
+
"packet_hex": "120a180122066661696c6564",
|
|
29
|
+
"frame_hex": "0c000000120a180122066661696c6564"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "call_cancel",
|
|
33
|
+
"packet_hex": "1801",
|
|
34
|
+
"frame_hex": "020000001801"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "malformed_complete",
|
|
38
|
+
"frame_hex": "030000000a01ff",
|
|
39
|
+
"expect_error": "malformed_packet"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "invalid_zero_length",
|
|
43
|
+
"frame_hex": "00000000",
|
|
44
|
+
"expect_error": "invalid_length"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "invalid_oversized_prefix",
|
|
48
|
+
"frame_hex": "81969800",
|
|
49
|
+
"expect_error": "oversized_frame"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "invalid_empty_packet",
|
|
53
|
+
"packet_hex": "",
|
|
54
|
+
"expect_error": "empty_packet"
|
|
55
|
+
},
|
|
56
|
+
{ "name": "invalid_empty_service_id", "expect_error": "empty_service_id" },
|
|
57
|
+
{ "name": "invalid_empty_method_id", "expect_error": "empty_method_id" },
|
|
58
|
+
{
|
|
59
|
+
"name": "truncated_body",
|
|
60
|
+
"frame_hex": "040000000a01",
|
|
61
|
+
"expect_error": "truncated_frame"
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
package/echo/Cargo.toml
CHANGED
|
@@ -23,3 +23,11 @@ starpc = { workspace = true }
|
|
|
23
23
|
prost = { workspace = true }
|
|
24
24
|
tokio = { version = "1", features = ["full"] }
|
|
25
25
|
async-trait = { workspace = true }
|
|
26
|
+
futures = { workspace = true }
|
|
27
|
+
|
|
28
|
+
[[test]]
|
|
29
|
+
name = "half_close"
|
|
30
|
+
path = "half_close_test.rs"
|
|
31
|
+
|
|
32
|
+
[dev-dependencies]
|
|
33
|
+
tokio-util = { workspace = true }
|
package/echo/client-test.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { Client, ERR_RPC_ABORT } from '../srpc/index.js'
|
|
|
2
2
|
import { EchoMsg } from './echo.pb.js'
|
|
3
3
|
import { EchoerClient } from './echo_srpc.pb.js'
|
|
4
4
|
import { pushable } from 'it-pushable'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildRpcStreamOpenStream,
|
|
7
|
+
openRpcStream,
|
|
8
|
+
} from '../rpcstream/rpcstream.js'
|
|
6
9
|
import { Message } from '@aptre/protobuf-es-lite'
|
|
7
10
|
|
|
8
11
|
export async function runClientTest(client: Client) {
|
|
@@ -89,8 +92,19 @@ export async function runAbortControllerTest(client: Client) {
|
|
|
89
92
|
})
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
function requireError(
|
|
96
|
+
error: unknown,
|
|
97
|
+
label: string,
|
|
98
|
+
fragments: string[],
|
|
99
|
+
): void {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
101
|
+
if (!fragments.some((fragment) => message.includes(fragment))) {
|
|
102
|
+
throw new Error(`${label} returned unexpected error: ${message}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
// runRpcStreamTest tests a RPCStream.
|
|
93
|
-
export async function runRpcStreamTest(client: Client) {
|
|
107
|
+
export async function runRpcStreamTest(client: Client, release = false) {
|
|
94
108
|
console.log('Calling RpcStream to open a RPC stream client...')
|
|
95
109
|
const service = new EchoerClient(client)
|
|
96
110
|
const openStreamFn = buildRpcStreamOpenStream(
|
|
@@ -106,4 +120,112 @@ export async function runRpcStreamTest(client: Client) {
|
|
|
106
120
|
|
|
107
121
|
console.log('Running client test over RPC stream...')
|
|
108
122
|
await runClientTest(proxiedClient)
|
|
123
|
+
|
|
124
|
+
if (release) {
|
|
125
|
+
let unknownRejected = false
|
|
126
|
+
try {
|
|
127
|
+
await openRpcStream('missing', service.RpcStream.bind(service), true)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
unknownRejected = true
|
|
130
|
+
requireError(error, 'unknown component', ['unknown component: missing'])
|
|
131
|
+
}
|
|
132
|
+
if (!unknownRejected) {
|
|
133
|
+
throw new Error('unknown component unexpectedly succeeded')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let methodRejected = false
|
|
138
|
+
try {
|
|
139
|
+
await proxiedClient.request('missing.Service', 'Missing', new Uint8Array())
|
|
140
|
+
} catch (error) {
|
|
141
|
+
methodRejected = true
|
|
142
|
+
requireError(error, 'unknown nested method', [
|
|
143
|
+
'missing.Service',
|
|
144
|
+
'unimplemented',
|
|
145
|
+
])
|
|
146
|
+
}
|
|
147
|
+
if (!methodRejected) {
|
|
148
|
+
throw new Error('unknown nested method unexpectedly succeeded')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (release) {
|
|
152
|
+
const terminalService = new EchoerClient(proxiedClient)
|
|
153
|
+
let terminalFailed = false
|
|
154
|
+
try {
|
|
155
|
+
await terminalService.Echo({ body: '__nested_error__' })
|
|
156
|
+
} catch (error) {
|
|
157
|
+
terminalFailed = true
|
|
158
|
+
requireError(error, 'terminal nested error', ['nested terminal error'])
|
|
159
|
+
}
|
|
160
|
+
if (!terminalFailed) {
|
|
161
|
+
throw new Error('terminal nested error unexpectedly succeeded')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (release) {
|
|
166
|
+
const releaseClient = new Client(
|
|
167
|
+
buildRpcStreamOpenStream('release', service.RpcStream.bind(service)),
|
|
168
|
+
)
|
|
169
|
+
let releaseFailed = false
|
|
170
|
+
const releaseService = new EchoerClient(releaseClient)
|
|
171
|
+
try {
|
|
172
|
+
await releaseService.Echo({ body: '__nested_release__' })
|
|
173
|
+
} catch (error) {
|
|
174
|
+
releaseFailed = true
|
|
175
|
+
requireError(error, 'release during active call', [
|
|
176
|
+
'closed before completion',
|
|
177
|
+
'stream closed',
|
|
178
|
+
'abort',
|
|
179
|
+
'cancel',
|
|
180
|
+
])
|
|
181
|
+
}
|
|
182
|
+
if (!releaseFailed) {
|
|
183
|
+
throw new Error('release during active call unexpectedly succeeded')
|
|
184
|
+
}
|
|
185
|
+
const releaseStatus = await service.Echo({
|
|
186
|
+
body: '__nested_release_status__',
|
|
187
|
+
})
|
|
188
|
+
if (releaseStatus.body !== 'released') {
|
|
189
|
+
throw new Error(`release completion returned ${releaseStatus.body}`)
|
|
190
|
+
}
|
|
191
|
+
let releasedRejected = false
|
|
192
|
+
try {
|
|
193
|
+
await releaseService.Echo({})
|
|
194
|
+
} catch (error) {
|
|
195
|
+
releasedRejected = true
|
|
196
|
+
requireError(error, 'released component', ['unknown component: release'])
|
|
197
|
+
}
|
|
198
|
+
if (!releasedRejected) {
|
|
199
|
+
throw new Error('released component unexpectedly remained available')
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const cancelled = new AbortController()
|
|
204
|
+
const cancelledStream = proxiedClient.bidirectionalStreamingRequest(
|
|
205
|
+
'echo.Echoer',
|
|
206
|
+
'EchoBidiStream',
|
|
207
|
+
(async function* () {
|
|
208
|
+
yield new Uint8Array()
|
|
209
|
+
await new Promise(() => undefined)
|
|
210
|
+
})(),
|
|
211
|
+
cancelled.signal,
|
|
212
|
+
)
|
|
213
|
+
const cancelledIterator = cancelledStream[Symbol.asyncIterator]()
|
|
214
|
+
const firstNestedResponse = await cancelledIterator.next()
|
|
215
|
+
if (firstNestedResponse.done) {
|
|
216
|
+
throw new Error('nested cancellation call ended before its first response')
|
|
217
|
+
}
|
|
218
|
+
cancelled.abort()
|
|
219
|
+
let cancelRejected = false
|
|
220
|
+
try {
|
|
221
|
+
while (!(await cancelledIterator.next()).done) {
|
|
222
|
+
// Drain until the abort reaches the nested call.
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
cancelRejected = true
|
|
226
|
+
requireError(error, 'nested cancellation', [ERR_RPC_ABORT])
|
|
227
|
+
}
|
|
228
|
+
if (!cancelRejected) {
|
|
229
|
+
throw new Error('nested cancellation unexpectedly completed normally')
|
|
230
|
+
}
|
|
109
231
|
}
|