starpc 0.49.10 → 0.49.13
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/srpc/channel.d.ts +6 -0
- package/dist/srpc/channel.js +38 -6
- package/dist/srpc/channel.test.d.ts +1 -0
- package/dist/srpc/channel.test.js +89 -0
- package/dist/srpc/server.test.js +64 -1
- package/dist/srpc/stream.js +9 -5
- package/dist/srpc/stream.test.d.ts +1 -0
- package/dist/srpc/stream.test.js +121 -0
- package/go.mod +2 -2
- package/go.sum +4 -6
- package/package.json +1 -1
- package/srpc/channel.test.ts +108 -0
- package/srpc/channel.ts +46 -6
- package/srpc/client-rpc.go +28 -28
- package/srpc/common-rpc.go +62 -66
- package/srpc/common-rpc_test.go +29 -1
- package/srpc/server-rpc.go +22 -20
- package/srpc/server.test.ts +72 -0
- package/srpc/stream.test.ts +174 -0
- package/srpc/stream.ts +8 -5
package/dist/srpc/channel.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface ChannelStreamMessage<T> {
|
|
|
4
4
|
ack?: true;
|
|
5
5
|
opened?: true;
|
|
6
6
|
closed?: true;
|
|
7
|
+
teardown?: true;
|
|
7
8
|
error?: Error;
|
|
8
9
|
data?: T;
|
|
9
10
|
}
|
|
@@ -32,6 +33,8 @@ export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenera
|
|
|
32
33
|
private keepAlive?;
|
|
33
34
|
private idleWatchdog?;
|
|
34
35
|
private closed;
|
|
36
|
+
private localWriteClosed;
|
|
37
|
+
private remoteWriteClosed;
|
|
35
38
|
get isAcked(): boolean;
|
|
36
39
|
get isOpen(): boolean;
|
|
37
40
|
get isIdlePaused(): boolean;
|
|
@@ -40,6 +43,9 @@ export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenera
|
|
|
40
43
|
private idleElapsed;
|
|
41
44
|
private keepAliveElapsed;
|
|
42
45
|
private finish;
|
|
46
|
+
private finishIfBothDirectionsClosed;
|
|
47
|
+
private clearKeepAlive;
|
|
48
|
+
private clearIdleWatchdog;
|
|
43
49
|
close(error?: Error): void;
|
|
44
50
|
pauseIdle(): void;
|
|
45
51
|
resumeIdle(): void;
|
package/dist/srpc/channel.js
CHANGED
|
@@ -39,6 +39,10 @@ export class ChannelStream {
|
|
|
39
39
|
idleWatchdog;
|
|
40
40
|
// closed indicates the local channel has been torn down.
|
|
41
41
|
closed = false;
|
|
42
|
+
// localWriteClosed indicates the local sink finished normally.
|
|
43
|
+
localWriteClosed = false;
|
|
44
|
+
// remoteWriteClosed indicates the remote sink finished normally.
|
|
45
|
+
remoteWriteClosed = false;
|
|
42
46
|
// isAcked checks if the stream is acknowledged by the remote.
|
|
43
47
|
get isAcked() {
|
|
44
48
|
return this.remoteAck ?? false;
|
|
@@ -151,13 +155,15 @@ export class ChannelStream {
|
|
|
151
155
|
}
|
|
152
156
|
if (notifyRemote) {
|
|
153
157
|
try {
|
|
154
|
-
this.postMessage({ closed: true, error });
|
|
158
|
+
this.postMessage({ closed: true, error, teardown: true });
|
|
155
159
|
}
|
|
156
160
|
catch {
|
|
157
161
|
// Ignore close races while tearing down the local channel.
|
|
158
162
|
}
|
|
159
163
|
}
|
|
160
164
|
this.closed = true;
|
|
165
|
+
this.localWriteClosed = true;
|
|
166
|
+
this.remoteWriteClosed = true;
|
|
161
167
|
// close channels
|
|
162
168
|
if (this.channel instanceof MessagePort) {
|
|
163
169
|
this.channel.onmessage = null;
|
|
@@ -186,6 +192,26 @@ export class ChannelStream {
|
|
|
186
192
|
}
|
|
187
193
|
this._source.end(error);
|
|
188
194
|
}
|
|
195
|
+
// finishIfBothDirectionsClosed tears down after a clean bidirectional EOF.
|
|
196
|
+
finishIfBothDirectionsClosed() {
|
|
197
|
+
if (this.localWriteClosed && this.remoteWriteClosed) {
|
|
198
|
+
this.finish(undefined, false);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// clearKeepAlive stops local write keep-alives after the write side closes.
|
|
202
|
+
clearKeepAlive() {
|
|
203
|
+
if (this.keepAlive) {
|
|
204
|
+
this.keepAlive.clear();
|
|
205
|
+
delete this.keepAlive;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// clearIdleWatchdog stops receive watchdogs after the peer write side closes.
|
|
209
|
+
clearIdleWatchdog() {
|
|
210
|
+
if (this.idleWatchdog) {
|
|
211
|
+
this.idleWatchdog.clear();
|
|
212
|
+
delete this.idleWatchdog;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
189
215
|
// close closes the broadcast channels.
|
|
190
216
|
close(error) {
|
|
191
217
|
this.finish(error, true);
|
|
@@ -235,10 +261,13 @@ export class ChannelStream {
|
|
|
235
261
|
for await (const msg of source) {
|
|
236
262
|
this.postMessage({ data: msg });
|
|
237
263
|
}
|
|
264
|
+
this.localWriteClosed = true;
|
|
265
|
+
this.clearKeepAlive();
|
|
238
266
|
this.postMessage({ closed: true });
|
|
267
|
+
this.finishIfBothDirectionsClosed();
|
|
239
268
|
}
|
|
240
269
|
catch (error) {
|
|
241
|
-
this.
|
|
270
|
+
this.finish(error instanceof Error ? error : new Error(String(error)), true);
|
|
242
271
|
}
|
|
243
272
|
};
|
|
244
273
|
}
|
|
@@ -254,16 +283,19 @@ export class ChannelStream {
|
|
|
254
283
|
if (msg.opened) {
|
|
255
284
|
this.onRemoteOpened();
|
|
256
285
|
}
|
|
257
|
-
const { data, closed, error: err } = msg;
|
|
258
|
-
if (data) {
|
|
286
|
+
const { data, closed, error: err, teardown } = msg;
|
|
287
|
+
if (data !== undefined) {
|
|
259
288
|
this._source.push(data);
|
|
260
289
|
}
|
|
261
|
-
if (err) {
|
|
290
|
+
if (err || teardown) {
|
|
262
291
|
this.finish(err, false);
|
|
263
292
|
return;
|
|
264
293
|
}
|
|
265
294
|
if (closed) {
|
|
266
|
-
this.
|
|
295
|
+
this.remoteWriteClosed = true;
|
|
296
|
+
this.clearIdleWatchdog();
|
|
297
|
+
this._source.end();
|
|
298
|
+
this.finishIfBothDirectionsClosed();
|
|
267
299
|
}
|
|
268
300
|
}
|
|
269
301
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { pushable } from 'it-pushable';
|
|
3
|
+
import { ChannelStream } from './channel.js';
|
|
4
|
+
describe('ChannelStream', () => {
|
|
5
|
+
it('keeps MessagePort peer writes open after local source completes normally', async () => {
|
|
6
|
+
const { port1, port2 } = new MessageChannel();
|
|
7
|
+
const client = new ChannelStream('client', port1);
|
|
8
|
+
const server = new ChannelStream('server', port2);
|
|
9
|
+
await expectPeerWriteAfterLocalSourceCompletes(client, server);
|
|
10
|
+
});
|
|
11
|
+
it('keeps BroadcastChannel peer writes open after local source completes normally', async () => {
|
|
12
|
+
const channelName = `channel-stream-${Date.now()}-${Math.random()}`;
|
|
13
|
+
const clientToServer = `${channelName}-client-to-server`;
|
|
14
|
+
const serverToClient = `${channelName}-server-to-client`;
|
|
15
|
+
const client = new ChannelStream('client', {
|
|
16
|
+
tx: new BroadcastChannel(clientToServer),
|
|
17
|
+
rx: new BroadcastChannel(serverToClient),
|
|
18
|
+
});
|
|
19
|
+
const server = new ChannelStream('server', {
|
|
20
|
+
tx: new BroadcastChannel(serverToClient),
|
|
21
|
+
rx: new BroadcastChannel(clientToServer),
|
|
22
|
+
});
|
|
23
|
+
await expectPeerWriteAfterLocalSourceCompletes(client, server);
|
|
24
|
+
});
|
|
25
|
+
it('propagates explicit close errors as full teardown', async () => {
|
|
26
|
+
const { port1, port2 } = new MessageChannel();
|
|
27
|
+
const active = new ChannelStream('active', port1);
|
|
28
|
+
const passive = new ChannelStream('passive', port2);
|
|
29
|
+
const next = passive.source[Symbol.asyncIterator]().next();
|
|
30
|
+
try {
|
|
31
|
+
active.close(new Error('boom'));
|
|
32
|
+
await expect(next).rejects.toThrow('boom');
|
|
33
|
+
expect(passive.closed).toBe(true);
|
|
34
|
+
expect(port2.onmessage).toBe(null);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
active.close();
|
|
38
|
+
passive.close();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
async function expectPeerWriteAfterLocalSourceCompletes(client, server) {
|
|
43
|
+
const clientWrites = pushable({ objectMode: true });
|
|
44
|
+
const serverWrites = pushable({ objectMode: true });
|
|
45
|
+
const clientSink = client.sink(clientWrites);
|
|
46
|
+
const serverSink = server.sink(serverWrites);
|
|
47
|
+
const clientReads = client.source[Symbol.asyncIterator]();
|
|
48
|
+
const serverReads = server.source[Symbol.asyncIterator]();
|
|
49
|
+
try {
|
|
50
|
+
const request = new Uint8Array([1, 2, 3]);
|
|
51
|
+
clientWrites.push(request);
|
|
52
|
+
await expect(readNext(serverReads)).resolves.toEqual({
|
|
53
|
+
done: false,
|
|
54
|
+
value: request,
|
|
55
|
+
});
|
|
56
|
+
clientWrites.end();
|
|
57
|
+
await expect(readNext(serverReads)).resolves.toEqual({
|
|
58
|
+
done: true,
|
|
59
|
+
value: undefined,
|
|
60
|
+
});
|
|
61
|
+
const response = new Uint8Array([4, 5, 6]);
|
|
62
|
+
serverWrites.push(response);
|
|
63
|
+
await expect(readNext(clientReads)).resolves.toEqual({
|
|
64
|
+
done: false,
|
|
65
|
+
value: response,
|
|
66
|
+
});
|
|
67
|
+
serverWrites.end();
|
|
68
|
+
await expect(clientSink).resolves.toBeUndefined();
|
|
69
|
+
await expect(serverSink).resolves.toBeUndefined();
|
|
70
|
+
await expect(readNext(clientReads)).resolves.toEqual({
|
|
71
|
+
done: true,
|
|
72
|
+
value: undefined,
|
|
73
|
+
});
|
|
74
|
+
expect(client.closed).toBe(true);
|
|
75
|
+
expect(server.closed).toBe(true);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
client.close();
|
|
79
|
+
server.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function readNext(source) {
|
|
83
|
+
return Promise.race([
|
|
84
|
+
source.next(),
|
|
85
|
+
new Promise((_, reject) => {
|
|
86
|
+
setTimeout(() => reject(new Error('timed out waiting for stream data')), 100);
|
|
87
|
+
}),
|
|
88
|
+
]);
|
|
89
|
+
}
|
package/dist/srpc/server.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
2
2
|
import { pipe } from 'it-pipe';
|
|
3
|
-
import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, } from '../srpc/index.js';
|
|
3
|
+
import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, Packet, } from '../srpc/index.js';
|
|
4
4
|
import { EchoerDefinition, EchoerServer, EchoerServiceName, runClientTest, } from '../echo/index.js';
|
|
5
5
|
import { runAbortControllerTest, runRpcStreamTest, } from '../echo/client-test.js';
|
|
6
6
|
describe('srpc server', () => {
|
|
@@ -39,6 +39,61 @@ describe('srpc server', () => {
|
|
|
39
39
|
it('should pass rpc stream tests', async () => {
|
|
40
40
|
await runRpcStreamTest(client);
|
|
41
41
|
});
|
|
42
|
+
it('keeps detached server-streaming responses open after request source completes', async () => {
|
|
43
|
+
const mux = createMux();
|
|
44
|
+
const response = new TextEncoder().encode('delayed init');
|
|
45
|
+
mux.registerLookupMethod(async (service, method) => {
|
|
46
|
+
if (service !== 'test.ResourceService' || method !== 'ResourceClient') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return async (_dataSource, dataSink) => {
|
|
50
|
+
await dataSink((async function* () {
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
52
|
+
yield response;
|
|
53
|
+
})());
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
const server = new Server(mux.lookupMethod);
|
|
57
|
+
const firstResponse = new Promise((resolve, reject) => {
|
|
58
|
+
server.handlePacketStream({
|
|
59
|
+
source: (async function* () {
|
|
60
|
+
yield Packet.toBinary({
|
|
61
|
+
body: {
|
|
62
|
+
case: 'callStart',
|
|
63
|
+
value: {
|
|
64
|
+
rpcService: 'test.ResourceService',
|
|
65
|
+
rpcMethod: 'ResourceClient',
|
|
66
|
+
data: new Uint8Array(0),
|
|
67
|
+
dataIsZero: true,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
})(),
|
|
72
|
+
sink: async (source) => {
|
|
73
|
+
try {
|
|
74
|
+
for await (const packetData of source) {
|
|
75
|
+
const packet = Packet.fromBinary(packetData);
|
|
76
|
+
if (packet.body?.case === 'callData') {
|
|
77
|
+
resolve(packet);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
reject(new Error('server response stream ended before call data'));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
reject(err);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
const packet = await promiseWithTimeout(firstResponse, 'detached server-streaming response');
|
|
90
|
+
const body = packet.body;
|
|
91
|
+
expect(body?.case).toBe('callData');
|
|
92
|
+
if (body?.case !== 'callData' || !body.value?.data) {
|
|
93
|
+
throw new Error('expected callData packet');
|
|
94
|
+
}
|
|
95
|
+
expect([...body.value.data]).toEqual([...response]);
|
|
96
|
+
});
|
|
42
97
|
it('removes abort listeners after a request completes', async () => {
|
|
43
98
|
const controller = new AbortController();
|
|
44
99
|
const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener');
|
|
@@ -60,3 +115,11 @@ describe('srpc server', () => {
|
|
|
60
115
|
expect(port2.onmessage).toBe(null);
|
|
61
116
|
});
|
|
62
117
|
});
|
|
118
|
+
function promiseWithTimeout(promise, label) {
|
|
119
|
+
return Promise.race([
|
|
120
|
+
promise,
|
|
121
|
+
new Promise((_, reject) => {
|
|
122
|
+
setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 100);
|
|
123
|
+
}),
|
|
124
|
+
]);
|
|
125
|
+
}
|
package/dist/srpc/stream.js
CHANGED
|
@@ -2,15 +2,19 @@ import { pipe } from 'it-pipe';
|
|
|
2
2
|
import { combineUint8ArrayListTransform } from './array-list.js';
|
|
3
3
|
import { parseLengthPrefixTransform, prependLengthPrefixTransform, } from './packet.js';
|
|
4
4
|
// streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
|
|
5
|
-
//
|
|
6
|
-
// The stream is closed when the source writing to the sink ends.
|
|
7
5
|
export function streamToPacketStream(stream) {
|
|
8
6
|
return {
|
|
9
7
|
source: pipe(stream, parseLengthPrefixTransform(), combineUint8ArrayListTransform()),
|
|
10
8
|
sink: async (source) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
try {
|
|
10
|
+
await pipe(source, prependLengthPrefixTransform(), stream);
|
|
11
|
+
await stream.closeWrite();
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
15
|
+
stream.abort(error);
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
14
18
|
},
|
|
15
19
|
};
|
|
16
20
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { pipe } from 'it-pipe';
|
|
3
|
+
import { ChannelStream, combineUint8ArrayListTransform, StreamConn, } from '../srpc/index.js';
|
|
4
|
+
describe('StreamConn packet stream', () => {
|
|
5
|
+
it('keeps yamux peer writes open after local packet source completes normally', async () => {
|
|
6
|
+
const request = new TextEncoder().encode('request');
|
|
7
|
+
const response = new TextEncoder().encode('response');
|
|
8
|
+
let serverError;
|
|
9
|
+
const serverDone = new Promise((resolve, reject) => {
|
|
10
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
11
|
+
handlePacketStream(stream) {
|
|
12
|
+
void (async () => {
|
|
13
|
+
const packets = stream.source[Symbol.asyncIterator]();
|
|
14
|
+
const first = await nextWithTimeout(packets, 'server request packet');
|
|
15
|
+
expect(first.done).toBe(false);
|
|
16
|
+
expect([...first.value]).toEqual([...request]);
|
|
17
|
+
const done = await nextWithTimeout(packets, 'server request eof');
|
|
18
|
+
expect(done.done).toBe(true);
|
|
19
|
+
await stream.sink((async function* () {
|
|
20
|
+
yield response;
|
|
21
|
+
})());
|
|
22
|
+
})()
|
|
23
|
+
.then(resolve)
|
|
24
|
+
.catch((err) => {
|
|
25
|
+
serverError = err;
|
|
26
|
+
reject(err);
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
void (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const clientStream = await clientConn.openStream();
|
|
33
|
+
await clientStream.sink((async function* () {
|
|
34
|
+
yield request;
|
|
35
|
+
})());
|
|
36
|
+
const packets = clientStream.source[Symbol.asyncIterator]();
|
|
37
|
+
const first = await nextWithTimeout(packets, 'client response packet');
|
|
38
|
+
expect(first.done).toBe(false);
|
|
39
|
+
expect([...first.value]).toEqual([...response]);
|
|
40
|
+
const done = await nextWithTimeout(packets, 'client response eof');
|
|
41
|
+
expect(done.done).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
cleanup();
|
|
45
|
+
}
|
|
46
|
+
})().catch(reject);
|
|
47
|
+
});
|
|
48
|
+
await serverDone;
|
|
49
|
+
expect(serverError).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
it('aborts the yamux stream when the packet source errors', async () => {
|
|
52
|
+
const request = new TextEncoder().encode('request');
|
|
53
|
+
const sourceError = new Error('source failed');
|
|
54
|
+
let resolveReset = () => { };
|
|
55
|
+
const resetSeen = new Promise((resolve) => {
|
|
56
|
+
resolveReset = resolve;
|
|
57
|
+
});
|
|
58
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
59
|
+
handlePacketStream(stream) {
|
|
60
|
+
void (async () => {
|
|
61
|
+
try {
|
|
62
|
+
for await (const _packet of stream.source) {
|
|
63
|
+
// Drain until the reset arrives.
|
|
64
|
+
}
|
|
65
|
+
resolveReset(new Error('server stream ended without reset'));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
resolveReset(err);
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
const clientStream = await clientConn.openStream();
|
|
75
|
+
await expect(clientStream.sink((async function* () {
|
|
76
|
+
yield request;
|
|
77
|
+
throw sourceError;
|
|
78
|
+
})())).rejects.toThrow('source failed');
|
|
79
|
+
const resetErr = await promiseWithTimeout(resetSeen, 'server reset');
|
|
80
|
+
expect(resetErr).toBeInstanceOf(Error);
|
|
81
|
+
expect(resetErr.message).toBe('stream reset');
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
cleanup();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
function connectStreamConns(server) {
|
|
89
|
+
const clientConn = new StreamConn();
|
|
90
|
+
const serverConn = new StreamConn(server, { direction: 'inbound' });
|
|
91
|
+
const { port1: clientPort, port2: serverPort } = new MessageChannel();
|
|
92
|
+
const opts = {};
|
|
93
|
+
const clientChannelStream = new ChannelStream('client', clientPort, opts);
|
|
94
|
+
const serverChannelStream = new ChannelStream('server', serverPort, opts);
|
|
95
|
+
pipe(clientChannelStream, clientConn, combineUint8ArrayListTransform(), clientChannelStream)
|
|
96
|
+
.catch((err) => clientConn.close(err))
|
|
97
|
+
.then(() => clientConn.close());
|
|
98
|
+
pipe(serverChannelStream, serverConn, combineUint8ArrayListTransform(), serverChannelStream)
|
|
99
|
+
.catch((err) => serverConn.close(err))
|
|
100
|
+
.then(() => serverConn.close());
|
|
101
|
+
return {
|
|
102
|
+
clientConn,
|
|
103
|
+
cleanup() {
|
|
104
|
+
clientConn.close();
|
|
105
|
+
serverConn.close();
|
|
106
|
+
clientChannelStream.close();
|
|
107
|
+
serverChannelStream.close();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function nextWithTimeout(source, label) {
|
|
112
|
+
return promiseWithTimeout(source.next(), label);
|
|
113
|
+
}
|
|
114
|
+
async function promiseWithTimeout(promise, label) {
|
|
115
|
+
return Promise.race([
|
|
116
|
+
promise,
|
|
117
|
+
new Promise((_, reject) => {
|
|
118
|
+
setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 500);
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
}
|
package/go.mod
CHANGED
|
@@ -3,9 +3,9 @@ module github.com/aperturerobotics/starpc
|
|
|
3
3
|
go 1.25.0
|
|
4
4
|
|
|
5
5
|
require (
|
|
6
|
-
github.com/aperturerobotics/common v0.33.0 // latest
|
|
6
|
+
github.com/aperturerobotics/common v0.33.1-0.20260516193515-675cfc5a0c12 // latest
|
|
7
7
|
github.com/aperturerobotics/protobuf-go-lite v0.13.0 // latest
|
|
8
|
-
github.com/aperturerobotics/util v1.34.5
|
|
8
|
+
github.com/aperturerobotics/util v1.34.5 // latest
|
|
9
9
|
)
|
|
10
10
|
|
|
11
11
|
require (
|
package/go.sum
CHANGED
|
@@ -2,8 +2,8 @@ github.com/aperturerobotics/abseil-cpp v0.0.0-20260131110040-4bb56e2f9017 h1:3U7
|
|
|
2
2
|
github.com/aperturerobotics/abseil-cpp v0.0.0-20260131110040-4bb56e2f9017/go.mod h1:lNSJTKECIUFAnfeSqy01kXYTYe1BHubW7198jNX3nEw=
|
|
3
3
|
github.com/aperturerobotics/cli v1.1.0 h1:7a+YRC+EY3npAnTzhHV5gLCiw91KS0Ts3XwLILGOsT8=
|
|
4
4
|
github.com/aperturerobotics/cli v1.1.0/go.mod h1:M7BFP9wow5ytTzMyJQOOO991fGfsUqdTI7gGEsHfTQ8=
|
|
5
|
-
github.com/aperturerobotics/common v0.33.0 h1:
|
|
6
|
-
github.com/aperturerobotics/common v0.33.0/go.mod h1:
|
|
5
|
+
github.com/aperturerobotics/common v0.33.1-0.20260516193515-675cfc5a0c12 h1:KQiFEDyu5//G8JZ3yTN92kF2P3F+LHjSKsQikgROncU=
|
|
6
|
+
github.com/aperturerobotics/common v0.33.1-0.20260516193515-675cfc5a0c12/go.mod h1:pW7BhBpKzVrTxN8XIz6jb3MJERZ15GDxaGQ7jcIl0Xg=
|
|
7
7
|
github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0 h1:6/3RSSlPEQ6LeidslB1ZCJkxW+MnfYDkvdWMDklDXw4=
|
|
8
8
|
github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0/go.mod h1:OBb/beWmr/pDIZAUfi86j/4tBh2v5ctTxKMqSnh9c/4=
|
|
9
9
|
github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260329113540-600516012db3 h1:lp+V8RYcBwTX1p81swkpZn5fhw1wn2xLorzETIxRyZQ=
|
|
@@ -16,10 +16,8 @@ github.com/aperturerobotics/protobuf v0.0.0-20260203024654-8201686529c4 h1:4Dy3B
|
|
|
16
16
|
github.com/aperturerobotics/protobuf v0.0.0-20260203024654-8201686529c4/go.mod h1:tMgO7y6SJo/d9ZcvrpNqIQtdYT9de+QmYaHOZ4KnhOg=
|
|
17
17
|
github.com/aperturerobotics/protobuf-go-lite v0.13.0 h1:jEvCJhHaJEikDY/va2AUnS0DOb/0n82aISLAqxSh4Sk=
|
|
18
18
|
github.com/aperturerobotics/protobuf-go-lite v0.13.0/go.mod h1:lGH3s5ArCTXKI4wJdlNpaybUtwSjfAG0vdWjxOfMcF8=
|
|
19
|
-
github.com/aperturerobotics/util v1.34.5
|
|
20
|
-
github.com/aperturerobotics/util v1.34.5
|
|
21
|
-
github.com/aperturerobotics/util v1.34.5-0.20260516103104-cbfc6d6a0589 h1:8B9O13He1sz8Spr2pc+RL3hBzAMveLgUCXT7BpAfvEY=
|
|
22
|
-
github.com/aperturerobotics/util v1.34.5-0.20260516103104-cbfc6d6a0589/go.mod h1:mDe7WnncVuV7yjeeVSsagyfrw4xfncu7d+f0+d70niY=
|
|
19
|
+
github.com/aperturerobotics/util v1.34.5 h1:007MaOJrrsiGm5o1c8Tt7p8nVwUAxkM6pmGflrBww/U=
|
|
20
|
+
github.com/aperturerobotics/util v1.34.5/go.mod h1:mDe7WnncVuV7yjeeVSsagyfrw4xfncu7d+f0+d70niY=
|
|
23
21
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
24
22
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
25
23
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
package/package.json
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { pushable } from 'it-pushable'
|
|
3
|
+
|
|
4
|
+
import { ChannelStream } from './channel.js'
|
|
5
|
+
|
|
6
|
+
describe('ChannelStream', () => {
|
|
7
|
+
it('keeps MessagePort peer writes open after local source completes normally', async () => {
|
|
8
|
+
const { port1, port2 } = new MessageChannel()
|
|
9
|
+
const client = new ChannelStream<Uint8Array>('client', port1)
|
|
10
|
+
const server = new ChannelStream<Uint8Array>('server', port2)
|
|
11
|
+
await expectPeerWriteAfterLocalSourceCompletes(client, server)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('keeps BroadcastChannel peer writes open after local source completes normally', async () => {
|
|
15
|
+
const channelName = `channel-stream-${Date.now()}-${Math.random()}`
|
|
16
|
+
const clientToServer = `${channelName}-client-to-server`
|
|
17
|
+
const serverToClient = `${channelName}-server-to-client`
|
|
18
|
+
const client = new ChannelStream<Uint8Array>('client', {
|
|
19
|
+
tx: new BroadcastChannel(clientToServer),
|
|
20
|
+
rx: new BroadcastChannel(serverToClient),
|
|
21
|
+
})
|
|
22
|
+
const server = new ChannelStream<Uint8Array>('server', {
|
|
23
|
+
tx: new BroadcastChannel(serverToClient),
|
|
24
|
+
rx: new BroadcastChannel(clientToServer),
|
|
25
|
+
})
|
|
26
|
+
await expectPeerWriteAfterLocalSourceCompletes(client, server)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('propagates explicit close errors as full teardown', async () => {
|
|
30
|
+
const { port1, port2 } = new MessageChannel()
|
|
31
|
+
const active = new ChannelStream<Uint8Array>('active', port1)
|
|
32
|
+
const passive = new ChannelStream<Uint8Array>('passive', port2)
|
|
33
|
+
const next = passive.source[Symbol.asyncIterator]().next()
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
active.close(new Error('boom'))
|
|
37
|
+
|
|
38
|
+
await expect(next).rejects.toThrow('boom')
|
|
39
|
+
expect((passive as any).closed).toBe(true)
|
|
40
|
+
expect(port2.onmessage).toBe(null)
|
|
41
|
+
} finally {
|
|
42
|
+
active.close()
|
|
43
|
+
passive.close()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
async function expectPeerWriteAfterLocalSourceCompletes(
|
|
49
|
+
client: ChannelStream<Uint8Array>,
|
|
50
|
+
server: ChannelStream<Uint8Array>,
|
|
51
|
+
) {
|
|
52
|
+
const clientWrites = pushable<Uint8Array>({ objectMode: true })
|
|
53
|
+
const serverWrites = pushable<Uint8Array>({ objectMode: true })
|
|
54
|
+
const clientSink = client.sink(clientWrites)
|
|
55
|
+
const serverSink = server.sink(serverWrites)
|
|
56
|
+
const clientReads = client.source[Symbol.asyncIterator]()
|
|
57
|
+
const serverReads = server.source[Symbol.asyncIterator]()
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const request = new Uint8Array([1, 2, 3])
|
|
61
|
+
clientWrites.push(request)
|
|
62
|
+
|
|
63
|
+
await expect(readNext(serverReads)).resolves.toEqual({
|
|
64
|
+
done: false,
|
|
65
|
+
value: request,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
clientWrites.end()
|
|
69
|
+
await expect(readNext(serverReads)).resolves.toEqual({
|
|
70
|
+
done: true,
|
|
71
|
+
value: undefined,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const response = new Uint8Array([4, 5, 6])
|
|
75
|
+
serverWrites.push(response)
|
|
76
|
+
await expect(readNext(clientReads)).resolves.toEqual({
|
|
77
|
+
done: false,
|
|
78
|
+
value: response,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
serverWrites.end()
|
|
82
|
+
await expect(clientSink).resolves.toBeUndefined()
|
|
83
|
+
await expect(serverSink).resolves.toBeUndefined()
|
|
84
|
+
await expect(readNext(clientReads)).resolves.toEqual({
|
|
85
|
+
done: true,
|
|
86
|
+
value: undefined,
|
|
87
|
+
})
|
|
88
|
+
expect((client as any).closed).toBe(true)
|
|
89
|
+
expect((server as any).closed).toBe(true)
|
|
90
|
+
} finally {
|
|
91
|
+
client.close()
|
|
92
|
+
server.close()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readNext<T>(
|
|
97
|
+
source: AsyncIterator<T>,
|
|
98
|
+
): Promise<IteratorResult<T, undefined>> {
|
|
99
|
+
return Promise.race([
|
|
100
|
+
source.next(),
|
|
101
|
+
new Promise<IteratorResult<T, undefined>>((_, reject) => {
|
|
102
|
+
setTimeout(
|
|
103
|
+
() => reject(new Error('timed out waiting for stream data')),
|
|
104
|
+
100,
|
|
105
|
+
)
|
|
106
|
+
}),
|
|
107
|
+
])
|
|
108
|
+
}
|