starpc 0.49.11 → 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.
@@ -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;
@@ -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.postMessage({ closed: true, error: error });
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.finish(undefined, false);
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
+ }
@@ -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
+ }
@@ -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
- await pipe(source, prependLengthPrefixTransform(), stream)
12
- .catch((err) => stream.close(err))
13
- .then(() => stream.close());
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starpc",
3
- "version": "0.49.11",
3
+ "version": "0.49.13",
4
4
  "description": "Streaming protobuf RPC service protocol over any two-way channel.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -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
+ }
package/srpc/channel.ts CHANGED
@@ -13,6 +13,8 @@ export interface ChannelStreamMessage<T> {
13
13
  opened?: true
14
14
  // closed indicates the stream is closed.
15
15
  closed?: true
16
+ // teardown indicates the whole channel should be torn down.
17
+ teardown?: true
16
18
  // error indicates the stream has an error.
17
19
  error?: Error
18
20
  // data is any message data.
@@ -82,6 +84,10 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
82
84
  private idleWatchdog?: Watchdog
83
85
  // closed indicates the local channel has been torn down.
84
86
  private closed = false
87
+ // localWriteClosed indicates the local sink finished normally.
88
+ private localWriteClosed = false
89
+ // remoteWriteClosed indicates the remote sink finished normally.
90
+ private remoteWriteClosed = false
85
91
 
86
92
  // isAcked checks if the stream is acknowledged by the remote.
87
93
  public get isAcked() {
@@ -207,12 +213,14 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
207
213
  }
208
214
  if (notifyRemote) {
209
215
  try {
210
- this.postMessage({ closed: true, error })
216
+ this.postMessage({ closed: true, error, teardown: true })
211
217
  } catch {
212
218
  // Ignore close races while tearing down the local channel.
213
219
  }
214
220
  }
215
221
  this.closed = true
222
+ this.localWriteClosed = true
223
+ this.remoteWriteClosed = true
216
224
  // close channels
217
225
  if (this.channel instanceof MessagePort) {
218
226
  this.channel.onmessage = null
@@ -241,6 +249,29 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
241
249
  this._source.end(error)
242
250
  }
243
251
 
252
+ // finishIfBothDirectionsClosed tears down after a clean bidirectional EOF.
253
+ private finishIfBothDirectionsClosed() {
254
+ if (this.localWriteClosed && this.remoteWriteClosed) {
255
+ this.finish(undefined, false)
256
+ }
257
+ }
258
+
259
+ // clearKeepAlive stops local write keep-alives after the write side closes.
260
+ private clearKeepAlive() {
261
+ if (this.keepAlive) {
262
+ this.keepAlive.clear()
263
+ delete this.keepAlive
264
+ }
265
+ }
266
+
267
+ // clearIdleWatchdog stops receive watchdogs after the peer write side closes.
268
+ private clearIdleWatchdog() {
269
+ if (this.idleWatchdog) {
270
+ this.idleWatchdog.clear()
271
+ delete this.idleWatchdog
272
+ }
273
+ }
274
+
244
275
  // close closes the broadcast channels.
245
276
  public close(error?: Error) {
246
277
  this.finish(error, true)
@@ -297,9 +328,15 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
297
328
  for await (const msg of source) {
298
329
  this.postMessage({ data: msg })
299
330
  }
331
+ this.localWriteClosed = true
332
+ this.clearKeepAlive()
300
333
  this.postMessage({ closed: true })
334
+ this.finishIfBothDirectionsClosed()
301
335
  } catch (error) {
302
- this.postMessage({ closed: true, error: error as Error })
336
+ this.finish(
337
+ error instanceof Error ? error : new Error(String(error)),
338
+ true,
339
+ )
303
340
  }
304
341
  }
305
342
  }
@@ -316,16 +353,19 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
316
353
  if (msg.opened) {
317
354
  this.onRemoteOpened()
318
355
  }
319
- const { data, closed, error: err } = msg
320
- if (data) {
356
+ const { data, closed, error: err, teardown } = msg
357
+ if (data !== undefined) {
321
358
  this._source.push(data)
322
359
  }
323
- if (err) {
360
+ if (err || teardown) {
324
361
  this.finish(err, false)
325
362
  return
326
363
  }
327
364
  if (closed) {
328
- this.finish(undefined, false)
365
+ this.remoteWriteClosed = true
366
+ this.clearIdleWatchdog()
367
+ this._source.end()
368
+ this.finishIfBothDirectionsClosed()
329
369
  }
330
370
  }
331
371
  }
@@ -9,6 +9,7 @@ import {
9
9
  ChannelStream,
10
10
  combineUint8ArrayListTransform,
11
11
  ChannelStreamOpts,
12
+ Packet,
12
13
  } from '../srpc/index.js'
13
14
  import {
14
15
  EchoerDefinition,
@@ -77,6 +78,68 @@ describe('srpc server', () => {
77
78
  await runRpcStreamTest(client)
78
79
  })
79
80
 
81
+ it('keeps detached server-streaming responses open after request source completes', async () => {
82
+ const mux = createMux()
83
+ const response = new TextEncoder().encode('delayed init')
84
+ mux.registerLookupMethod(async (service, method) => {
85
+ if (service !== 'test.ResourceService' || method !== 'ResourceClient') {
86
+ return null
87
+ }
88
+ return async (_dataSource, dataSink) => {
89
+ await dataSink(
90
+ (async function* () {
91
+ await new Promise((resolve) => setTimeout(resolve, 10))
92
+ yield response
93
+ })(),
94
+ )
95
+ }
96
+ })
97
+
98
+ const server = new Server(mux.lookupMethod)
99
+ const firstResponse = new Promise<Packet>((resolve, reject) => {
100
+ server.handlePacketStream({
101
+ source: (async function* () {
102
+ yield Packet.toBinary({
103
+ body: {
104
+ case: 'callStart',
105
+ value: {
106
+ rpcService: 'test.ResourceService',
107
+ rpcMethod: 'ResourceClient',
108
+ data: new Uint8Array(0),
109
+ dataIsZero: true,
110
+ },
111
+ },
112
+ })
113
+ })(),
114
+ sink: async (source) => {
115
+ try {
116
+ for await (const packetData of source) {
117
+ const packet = Packet.fromBinary(packetData)
118
+ if (packet.body?.case === 'callData') {
119
+ resolve(packet)
120
+ return
121
+ }
122
+ }
123
+ reject(new Error('server response stream ended before call data'))
124
+ } catch (err) {
125
+ reject(err as Error)
126
+ }
127
+ },
128
+ })
129
+ })
130
+
131
+ const packet = await promiseWithTimeout(
132
+ firstResponse,
133
+ 'detached server-streaming response',
134
+ )
135
+ const body = packet.body
136
+ expect(body?.case).toBe('callData')
137
+ if (body?.case !== 'callData' || !body.value?.data) {
138
+ throw new Error('expected callData packet')
139
+ }
140
+ expect([...body.value.data]).toEqual([...response])
141
+ })
142
+
80
143
  it('removes abort listeners after a request completes', async () => {
81
144
  const controller = new AbortController()
82
145
  const removeEventListener = vi.spyOn(
@@ -114,3 +177,12 @@ describe('srpc server', () => {
114
177
  expect(port2.onmessage).toBe(null)
115
178
  })
116
179
  })
180
+
181
+ function promiseWithTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
182
+ return Promise.race([
183
+ promise,
184
+ new Promise<T>((_, reject) => {
185
+ setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 100)
186
+ }),
187
+ ])
188
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pipe } from 'it-pipe'
3
+
4
+ import {
5
+ ChannelStream,
6
+ combineUint8ArrayListTransform,
7
+ StreamConn,
8
+ type ChannelStreamOpts,
9
+ type PacketStream,
10
+ type StreamHandler,
11
+ } from '../srpc/index.js'
12
+
13
+ describe('StreamConn packet stream', () => {
14
+ it('keeps yamux peer writes open after local packet source completes normally', async () => {
15
+ const request = new TextEncoder().encode('request')
16
+ const response = new TextEncoder().encode('response')
17
+
18
+ let serverError: unknown
19
+ const serverDone = new Promise<void>((resolve, reject) => {
20
+ const { clientConn, cleanup } = connectStreamConns({
21
+ handlePacketStream(stream: PacketStream) {
22
+ void (async () => {
23
+ const packets = stream.source[Symbol.asyncIterator]()
24
+ const first = await nextWithTimeout(
25
+ packets,
26
+ 'server request packet',
27
+ )
28
+ expect(first.done).toBe(false)
29
+ expect([...first.value]).toEqual([...request])
30
+
31
+ const done = await nextWithTimeout(packets, 'server request eof')
32
+ expect(done.done).toBe(true)
33
+
34
+ await stream.sink(
35
+ (async function* () {
36
+ yield response
37
+ })(),
38
+ )
39
+ })()
40
+ .then(resolve)
41
+ .catch((err) => {
42
+ serverError = err
43
+ reject(err)
44
+ })
45
+ },
46
+ })
47
+
48
+ void (async () => {
49
+ try {
50
+ const clientStream = await clientConn.openStream()
51
+ await clientStream.sink(
52
+ (async function* () {
53
+ yield request
54
+ })(),
55
+ )
56
+
57
+ const packets = clientStream.source[Symbol.asyncIterator]()
58
+ const first = await nextWithTimeout(packets, 'client response packet')
59
+ expect(first.done).toBe(false)
60
+ expect([...first.value]).toEqual([...response])
61
+
62
+ const done = await nextWithTimeout(packets, 'client response eof')
63
+ expect(done.done).toBe(true)
64
+ } finally {
65
+ cleanup()
66
+ }
67
+ })().catch(reject)
68
+ })
69
+
70
+ await serverDone
71
+ expect(serverError).toBeUndefined()
72
+ })
73
+
74
+ it('aborts the yamux stream when the packet source errors', async () => {
75
+ const request = new TextEncoder().encode('request')
76
+ const sourceError = new Error('source failed')
77
+ let resolveReset: (err: unknown) => void = () => {}
78
+ const resetSeen = new Promise<unknown>((resolve) => {
79
+ resolveReset = resolve
80
+ })
81
+ const { clientConn, cleanup } = connectStreamConns({
82
+ handlePacketStream(stream: PacketStream) {
83
+ void (async () => {
84
+ try {
85
+ for await (const _packet of stream.source) {
86
+ // Drain until the reset arrives.
87
+ }
88
+ resolveReset(new Error('server stream ended without reset'))
89
+ } catch (err) {
90
+ resolveReset(err)
91
+ }
92
+ })()
93
+ },
94
+ })
95
+
96
+ try {
97
+ const clientStream = await clientConn.openStream()
98
+ await expect(
99
+ clientStream.sink(
100
+ (async function* () {
101
+ yield request
102
+ throw sourceError
103
+ })(),
104
+ ),
105
+ ).rejects.toThrow('source failed')
106
+
107
+ const resetErr = await promiseWithTimeout(resetSeen, 'server reset')
108
+ expect(resetErr).toBeInstanceOf(Error)
109
+ expect((resetErr as Error).message).toBe('stream reset')
110
+ } finally {
111
+ cleanup()
112
+ }
113
+ })
114
+ })
115
+
116
+ function connectStreamConns(server: StreamHandler): {
117
+ clientConn: StreamConn
118
+ cleanup: () => void
119
+ } {
120
+ const clientConn = new StreamConn()
121
+ const serverConn = new StreamConn(server, { direction: 'inbound' })
122
+
123
+ const { port1: clientPort, port2: serverPort } = new MessageChannel()
124
+ const opts: ChannelStreamOpts = {}
125
+ const clientChannelStream = new ChannelStream('client', clientPort, opts)
126
+ const serverChannelStream = new ChannelStream('server', serverPort, opts)
127
+
128
+ pipe(
129
+ clientChannelStream,
130
+ clientConn,
131
+ combineUint8ArrayListTransform(),
132
+ clientChannelStream,
133
+ )
134
+ .catch((err: Error) => clientConn.close(err))
135
+ .then(() => clientConn.close())
136
+
137
+ pipe(
138
+ serverChannelStream,
139
+ serverConn,
140
+ combineUint8ArrayListTransform(),
141
+ serverChannelStream,
142
+ )
143
+ .catch((err: Error) => serverConn.close(err))
144
+ .then(() => serverConn.close())
145
+
146
+ return {
147
+ clientConn,
148
+ cleanup() {
149
+ clientConn.close()
150
+ serverConn.close()
151
+ clientChannelStream.close()
152
+ serverChannelStream.close()
153
+ },
154
+ }
155
+ }
156
+
157
+ async function nextWithTimeout<T>(
158
+ source: AsyncIterator<T>,
159
+ label: string,
160
+ ): Promise<IteratorResult<T>> {
161
+ return promiseWithTimeout(source.next(), label)
162
+ }
163
+
164
+ async function promiseWithTimeout<T>(
165
+ promise: Promise<T>,
166
+ label: string,
167
+ ): Promise<T> {
168
+ return Promise.race([
169
+ promise,
170
+ new Promise<T>((_, reject) => {
171
+ setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 500)
172
+ }),
173
+ ])
174
+ }
package/srpc/stream.ts CHANGED
@@ -28,8 +28,6 @@ export type OpenStreamFunc = () => Promise<PacketStream>
28
28
  export type HandleStreamFunc = (ch: PacketStream) => Promise<void>
29
29
 
30
30
  // streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
31
- //
32
- // The stream is closed when the source writing to the sink ends.
33
31
  export function streamToPacketStream(stream: Stream): PacketStream {
34
32
  return {
35
33
  source: pipe(
@@ -38,9 +36,14 @@ export function streamToPacketStream(stream: Stream): PacketStream {
38
36
  combineUint8ArrayListTransform(),
39
37
  ),
40
38
  sink: async (source: Source<Uint8Array>): Promise<void> => {
41
- await pipe(source, prependLengthPrefixTransform(), stream)
42
- .catch((err) => stream.close(err))
43
- .then(() => stream.close())
39
+ try {
40
+ await pipe(source, prependLengthPrefixTransform(), stream)
41
+ await stream.closeWrite()
42
+ } catch (err) {
43
+ const error = err instanceof Error ? err : new Error(String(err))
44
+ stream.abort(error)
45
+ throw error
46
+ }
44
47
  },
45
48
  }
46
49
  }