@statewalker/webrun-streams 0.1.0 → 0.2.0
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 +197 -13
- package/dist/collect.d.ts +7 -0
- package/dist/collect.d.ts.map +1 -0
- package/dist/duplex.d.ts +52 -0
- package/dist/duplex.d.ts.map +1 -0
- package/dist/emulate-mux.d.ts +39 -0
- package/dist/emulate-mux.d.ts.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/flow-control.d.ts +71 -0
- package/dist/flow-control.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/{index.mjs → index.js} +251 -56
- package/dist/jsonl.d.ts +5 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/lines.d.ts +5 -0
- package/dist/lines.d.ts.map +1 -0
- package/dist/map.d.ts +3 -0
- package/dist/map.d.ts.map +1 -0
- package/dist/new-async-generator.d.ts +70 -0
- package/dist/new-async-generator.d.ts.map +1 -0
- package/dist/normalize.d.ts +8 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/readable-streams.d.ts +13 -0
- package/dist/readable-streams.d.ts.map +1 -0
- package/dist/recieve-iterator.d.ts +14 -0
- package/dist/recieve-iterator.d.ts.map +1 -0
- package/dist/send-iterator.d.ts +15 -0
- package/dist/send-iterator.d.ts.map +1 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/to-chunks.d.ts +11 -0
- package/dist/to-chunks.d.ts.map +1 -0
- package/dist/uint32.d.ts +25 -0
- package/dist/uint32.d.ts.map +1 -0
- package/package.json +13 -8
- package/src/duplex.ts +59 -0
- package/src/emulate-mux.ts +188 -74
- package/src/flow-control.ts +146 -0
- package/src/index.ts +2 -0
- package/src/readable-streams.ts +49 -13
- package/src/uint32.ts +34 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"uint32.d.ts","sourceRoot":"","sources":["../src/uint32.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,0EAA0E;AAC1E,eAAO,MAAM,UAAU,aAAa,CAAC;AAErC;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,UAAU,CAKlD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAGlE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-streams",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Async-iterator / ReadableStream primitives: collect, text/jsonl codecs, lines, backpressure generators, serialisable errors.",
|
|
@@ -16,31 +16,36 @@
|
|
|
16
16
|
"directory": "packages/webrun-streams"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
|
-
".":
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
20
23
|
},
|
|
21
24
|
"files": [
|
|
22
25
|
"dist",
|
|
23
26
|
"src"
|
|
24
27
|
],
|
|
25
28
|
"devDependencies": {
|
|
26
|
-
"@types/node": "^
|
|
29
|
+
"@types/node": "^26.2.0",
|
|
27
30
|
"rimraf": "^6.1.3",
|
|
28
|
-
"
|
|
29
|
-
"typescript": "^
|
|
30
|
-
"vitest": "^4.1.
|
|
31
|
+
"rolldown": "^1.2.4",
|
|
32
|
+
"typescript": "^7.0.2",
|
|
33
|
+
"vitest": "^4.1.10"
|
|
31
34
|
},
|
|
32
35
|
"sideEffects": false,
|
|
33
36
|
"publishConfig": {
|
|
34
37
|
"access": "public"
|
|
35
38
|
},
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
36
40
|
"scripts": {
|
|
37
|
-
"build": "
|
|
41
|
+
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
38
42
|
"dev": "tsdown --watch",
|
|
39
43
|
"test": "vitest run",
|
|
40
44
|
"test:watch": "vitest",
|
|
41
45
|
"typecheck": "tsc --noEmit",
|
|
46
|
+
"typecheck:tests": "tsc -p tsconfig.tests.json",
|
|
42
47
|
"clean": "rimraf dist",
|
|
43
|
-
"lint": "biome check
|
|
48
|
+
"lint": "biome check src tests",
|
|
44
49
|
"format": "biome format --write ."
|
|
45
50
|
}
|
|
46
51
|
}
|
package/src/duplex.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport seam every `webrun-streams-*` adapter and the conformance suite
|
|
3
|
+
* are written against.
|
|
4
|
+
*
|
|
5
|
+
* These types live here rather than beside an implementation on purpose: the
|
|
6
|
+
* emulated multiplexer that once declared them is scheduled for deletion, and a
|
|
7
|
+
* seam that disappears with its first implementation is not a seam.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Canonical seam for the webrun-streams transport family. A `Duplex` carries
|
|
12
|
+
* one logical call: caller emits an iterable of bytes as input, peer yields an
|
|
13
|
+
* async generator of bytes as output. Same shape on both sides — an in-process
|
|
14
|
+
* test can wire `const caller = handler` and run without any transport.
|
|
15
|
+
*
|
|
16
|
+
* Iterator semantics carry every signal:
|
|
17
|
+
* - Consumer `.return()` on the output → producer's `finally` runs.
|
|
18
|
+
* - Producer `throw` → consumer's `for await` throws.
|
|
19
|
+
* - Normal exhaustion on either side → matching end on the other side.
|
|
20
|
+
*/
|
|
21
|
+
export type Duplex = (
|
|
22
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
23
|
+
) => AsyncGenerator<Uint8Array>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Adapter-side factory that stands up a transport connection and yields a
|
|
27
|
+
* caller `Duplex`. One `Connect` invocation owns one transport; each call
|
|
28
|
+
* to the resolved `call` opens a new sub-stream on it.
|
|
29
|
+
*
|
|
30
|
+
* A caller must either drain the returned generator or `.return()` it.
|
|
31
|
+
* Dropping the reference without doing either emits no observable signal:
|
|
32
|
+
* the abandoned consumer never acknowledges inbound data, so the peer's
|
|
33
|
+
* outbound pump blocks awaiting that acknowledgement, no end-of-stream is
|
|
34
|
+
* ever exchanged, and both peers hold the stream's slot open. There is no
|
|
35
|
+
* way for the transport to detect this — an unreferenced generator is not
|
|
36
|
+
* observable — so it is the consumer's obligation.
|
|
37
|
+
*/
|
|
38
|
+
export type Connect<P> = (params: P) => Promise<{
|
|
39
|
+
call: Duplex;
|
|
40
|
+
close: () => Promise<void>;
|
|
41
|
+
}>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Adapter-side factory that registers a handler `Duplex` against a transport.
|
|
45
|
+
* Returns an idempotent teardown.
|
|
46
|
+
*/
|
|
47
|
+
export type Serve<P> = (params: P, handler: Duplex) => Promise<() => Promise<void>>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Thrown by `emulateMux` and adapters when the underlying transport closes
|
|
51
|
+
* while one or more `Duplex` calls are in flight. Consumers can catch by
|
|
52
|
+
* `instanceof TransportClosedError` or by checking `error.name`.
|
|
53
|
+
*/
|
|
54
|
+
export class TransportClosedError extends Error {
|
|
55
|
+
override readonly name = "TransportClosedError";
|
|
56
|
+
constructor(message = "transport closed") {
|
|
57
|
+
super(message);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/emulate-mux.ts
CHANGED
|
@@ -1,35 +1,12 @@
|
|
|
1
|
+
import { type Duplex, TransportClosedError } from "./duplex.js";
|
|
1
2
|
import { deserializeError, serializeError } from "./errors.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* Iterator semantics carry every signal:
|
|
10
|
-
* - Consumer `.return()` on the output → producer's `finally` runs.
|
|
11
|
-
* - Producer `throw` → consumer's `for await` throws.
|
|
12
|
-
* - Normal exhaustion on either side → matching end on the other side.
|
|
13
|
-
*/
|
|
14
|
-
export type Duplex = (
|
|
15
|
-
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
16
|
-
) => AsyncGenerator<Uint8Array>;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Adapter-side factory that stands up a transport connection and yields a
|
|
20
|
-
* caller `Duplex`. One `Connect` invocation owns one transport; each call
|
|
21
|
-
* to the resolved `call` opens a new sub-stream on it.
|
|
22
|
-
*/
|
|
23
|
-
export type Connect<P> = (params: P) => Promise<{
|
|
24
|
-
call: Duplex;
|
|
25
|
-
close: () => Promise<void>;
|
|
26
|
-
}>;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Adapter-side factory that registers a handler `Duplex` against a transport.
|
|
30
|
-
* Returns an idempotent teardown.
|
|
31
|
-
*/
|
|
32
|
-
export type Serve<P> = (params: P, handler: Duplex) => Promise<() => Promise<void>>;
|
|
3
|
+
import {
|
|
4
|
+
type CreditGrantor,
|
|
5
|
+
type CreditLedger,
|
|
6
|
+
newCreditGrantor,
|
|
7
|
+
newCreditLedger,
|
|
8
|
+
} from "./flow-control.js";
|
|
9
|
+
import { decodeUint32, encodeUint32 } from "./uint32.js";
|
|
33
10
|
|
|
34
11
|
/**
|
|
35
12
|
* The minimum a transport must expose to be wrapped by `emulateMux`. Inbound
|
|
@@ -45,18 +22,6 @@ export type ByteChannel = {
|
|
|
45
22
|
close(): void;
|
|
46
23
|
};
|
|
47
24
|
|
|
48
|
-
/**
|
|
49
|
-
* Thrown by `emulateMux` and adapters when the underlying transport closes
|
|
50
|
-
* while one or more `Duplex` calls are in flight. Consumers can catch by
|
|
51
|
-
* `instanceof TransportClosedError` or by checking `error.name`.
|
|
52
|
-
*/
|
|
53
|
-
export class TransportClosedError extends Error {
|
|
54
|
-
override readonly name = "TransportClosedError";
|
|
55
|
-
constructor(message = "transport closed") {
|
|
56
|
-
super(message);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
25
|
const TYPE_OPEN = 0x01;
|
|
61
26
|
const TYPE_DATA = 0x02;
|
|
62
27
|
const TYPE_ACK = 0x03;
|
|
@@ -66,6 +31,7 @@ const TYPE_CLOSE = 0x06;
|
|
|
66
31
|
|
|
67
32
|
const DEFAULT_MAX_STREAMS = 256;
|
|
68
33
|
const DEFAULT_MTU = 64 * 1024;
|
|
34
|
+
const DEFAULT_MAX_STREAM_BUFFER = 8 * 1024 * 1024;
|
|
69
35
|
|
|
70
36
|
const textEncoder = new TextEncoder();
|
|
71
37
|
const textDecoder = new TextDecoder();
|
|
@@ -75,14 +41,42 @@ interface Stream {
|
|
|
75
41
|
inbound: AsyncGenerator<Uint8Array>;
|
|
76
42
|
pushIn: (chunk: Uint8Array) => Promise<boolean>;
|
|
77
43
|
doneIn: (err?: Error) => Promise<boolean>;
|
|
78
|
-
|
|
79
|
-
|
|
44
|
+
/** Credit the peer has granted us for sending on this stream. */
|
|
45
|
+
outboundCredit: CreditLedger;
|
|
46
|
+
/** Tracks consumer drainage to decide when to grant the peer more credit. */
|
|
47
|
+
grantor: CreditGrantor;
|
|
80
48
|
closed: boolean;
|
|
49
|
+
/** Peer sent END (or the stream was torn down): nothing more arrives. */
|
|
50
|
+
inDone: boolean;
|
|
51
|
+
/** Our outbound pump sent END: nothing more will be sent. */
|
|
52
|
+
outDone: boolean;
|
|
53
|
+
/** Inbound bytes pushed but not yet taken by the consumer. */
|
|
54
|
+
queuedBytes: number;
|
|
55
|
+
/**
|
|
56
|
+
* Cancels the caller's outbound input, set by `pumpOutbound`.
|
|
57
|
+
*
|
|
58
|
+
* WHY THIS EXISTS. `pumpOutbound` is fire-and-forget and checks `s.closed`
|
|
59
|
+
* only AFTER a chunk arrives, so an input that yields nothing more leaves
|
|
60
|
+
* the pump parked on `input.next()` for ever — holding the producer and its
|
|
61
|
+
* `finally` — even though the consumer has cancelled and the slot is gone.
|
|
62
|
+
* Teardown has to reach back and return the input rather than wait for it.
|
|
63
|
+
*/
|
|
64
|
+
cancelInput?: () => void;
|
|
81
65
|
}
|
|
82
66
|
|
|
83
67
|
export interface EmulateMuxOptions {
|
|
84
68
|
maxStreams?: number;
|
|
85
69
|
mtu?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Cap on inbound bytes one stream may hold for a consumer that has not
|
|
72
|
+
* drained them. Exceeding it tears down that stream alone.
|
|
73
|
+
*
|
|
74
|
+
* It is also the credit this side advertises to the peer, so it must be at
|
|
75
|
+
* least 1 — a window of 0 authorises nothing and hangs the peer forever —
|
|
76
|
+
* and it travels in a uint32, so a value above `MAX_UINT32` (4 GiB - 1) is
|
|
77
|
+
* advertised as `MAX_UINT32`.
|
|
78
|
+
*/
|
|
79
|
+
maxStreamBuffer?: number;
|
|
86
80
|
/**
|
|
87
81
|
* Stream-id allocation side. Initiator uses even ids (2, 4, …); responder
|
|
88
82
|
* uses odd ids (1, 3, …). Pick one per peer so allocations don't collide.
|
|
@@ -100,6 +94,13 @@ export function emulateMux(
|
|
|
100
94
|
} {
|
|
101
95
|
const maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
|
|
102
96
|
const mtu = opts.mtu ?? DEFAULT_MTU;
|
|
97
|
+
const maxStreamBuffer = opts.maxStreamBuffer ?? DEFAULT_MAX_STREAM_BUFFER;
|
|
98
|
+
// A window of 0 is not a small window, it is a permanent stall: the peer is
|
|
99
|
+
// authorised to send nothing and no event would ever grant it more. Refuse
|
|
100
|
+
// it at construction rather than deadlocking on the first call.
|
|
101
|
+
if (!(maxStreamBuffer >= 1)) {
|
|
102
|
+
throw new RangeError(`emulateMux: maxStreamBuffer must be at least 1, got ${maxStreamBuffer}`);
|
|
103
|
+
}
|
|
103
104
|
const side = opts.side ?? "initiator";
|
|
104
105
|
|
|
105
106
|
const streams = new Map<number, Stream>();
|
|
@@ -125,13 +126,31 @@ export function emulateMux(
|
|
|
125
126
|
const teardownStream = (s: Stream, err?: Error): void => {
|
|
126
127
|
if (s.closed) return;
|
|
127
128
|
s.closed = true;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
s.
|
|
131
|
-
s.rejectAck = null;
|
|
132
|
-
if (reject && err) reject(err);
|
|
133
|
-
else resolve?.();
|
|
129
|
+
// A sender parked in `reserve()` unwinds here: the credit it is waiting
|
|
130
|
+
// for will never arrive, so fail the ledger rather than leave it queued.
|
|
131
|
+
s.outboundCredit.fail(err ?? new TransportClosedError("emulateMux: stream closed"));
|
|
134
132
|
void s.doneIn(err);
|
|
133
|
+
// Return the caller's input so its `finally` runs. Not awaited, and that
|
|
134
|
+
// is deliberate: `.return()` on a generator parked awaiting its own source
|
|
135
|
+
// is QUEUED behind that pending `next()`, so awaiting it here would make
|
|
136
|
+
// teardown hang on exactly the input we are trying to abandon.
|
|
137
|
+
s.cancelInput?.();
|
|
138
|
+
streams.delete(s.id);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Graceful counterpart to `teardownStream`. A stream occupies a slot in
|
|
143
|
+
* `streams` until BOTH directions finish; releasing on inbound END alone
|
|
144
|
+
* would set `closed` while our own `pumpOutbound` is still sending, and it
|
|
145
|
+
* checks that flag to decide whether to keep going.
|
|
146
|
+
*
|
|
147
|
+
* Without this, only abnormal endings (cancel, ERROR, CLOSE, transport
|
|
148
|
+
* failure) ever freed a slot, so every normally-completed call leaked one
|
|
149
|
+
* until `maxStreams` began rejecting new calls.
|
|
150
|
+
*/
|
|
151
|
+
const releaseIfComplete = (s: Stream): void => {
|
|
152
|
+
if (s.closed || !s.inDone || !s.outDone) return;
|
|
153
|
+
s.closed = true;
|
|
135
154
|
streams.delete(s.id);
|
|
136
155
|
};
|
|
137
156
|
|
|
@@ -152,9 +171,12 @@ export function emulateMux(
|
|
|
152
171
|
}),
|
|
153
172
|
pushIn: queue.push,
|
|
154
173
|
doneIn: queue.done,
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
outboundCredit: newCreditLedger(0),
|
|
175
|
+
grantor: newCreditGrantor(maxStreamBuffer),
|
|
157
176
|
closed: false,
|
|
177
|
+
inDone: false,
|
|
178
|
+
outDone: false,
|
|
179
|
+
queuedBytes: 0,
|
|
158
180
|
};
|
|
159
181
|
return state;
|
|
160
182
|
};
|
|
@@ -163,24 +185,48 @@ export function emulateMux(
|
|
|
163
185
|
s: Stream,
|
|
164
186
|
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
165
187
|
): Promise<void> => {
|
|
188
|
+
// Hand teardown a way to cancel this input. An async iterator's `return`
|
|
189
|
+
// is the only cancellation the protocol has, and the pump cannot poll for
|
|
190
|
+
// one: between two chunks it is parked inside `next()`, which for a
|
|
191
|
+
// long-lived session is where it spends its whole life.
|
|
192
|
+
//
|
|
193
|
+
// THE ITERATOR IS ACQUIRED ONCE AND DRIVEN BY HAND. `for await (… of
|
|
194
|
+
// input)` would call `[Symbol.asyncIterator]()` again; for a generator
|
|
195
|
+
// that returns the same object, but for any other iterable it returns a
|
|
196
|
+
// SECOND iterator — and then `cancelInput` would be cancelling something
|
|
197
|
+
// the loop is not reading.
|
|
198
|
+
const iterator: AsyncIterator<Uint8Array> | Iterator<Uint8Array> =
|
|
199
|
+
(input as AsyncIterable<Uint8Array>)[Symbol.asyncIterator]?.() ??
|
|
200
|
+
(input as Iterable<Uint8Array>)[Symbol.iterator]();
|
|
201
|
+
s.cancelInput = () => {
|
|
202
|
+
void (iterator as AsyncIterator<Uint8Array>).return?.(undefined);
|
|
203
|
+
};
|
|
166
204
|
try {
|
|
167
|
-
for
|
|
205
|
+
for (;;) {
|
|
206
|
+
const next = await iterator.next();
|
|
207
|
+
if (next.done === true) break;
|
|
208
|
+
const chunk = next.value;
|
|
168
209
|
if (s.closed || muxClosed) return;
|
|
169
210
|
if (chunk.byteLength === 0) continue;
|
|
170
211
|
let off = 0;
|
|
171
212
|
while (off < chunk.byteLength) {
|
|
172
213
|
if (s.closed || muxClosed) return;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
214
|
+
// Reserve BEFORE framing. `reserve` returns what the peer's window
|
|
215
|
+
// can actually take, up to one mtu, so a piece is never larger than
|
|
216
|
+
// the credit that exists for it — which is what stops a sender whose
|
|
217
|
+
// mtu exceeds the peer's whole window from stalling forever.
|
|
218
|
+
const take = await s.outboundCredit.reserve(Math.min(mtu, chunk.byteLength - off));
|
|
219
|
+
if (s.closed || muxClosed) return;
|
|
220
|
+
const end = off + take;
|
|
221
|
+
sendFrame(s.id, TYPE_DATA, chunk.subarray(off, end));
|
|
180
222
|
off = end;
|
|
181
223
|
}
|
|
182
224
|
}
|
|
183
|
-
if (!s.closed && !muxClosed)
|
|
225
|
+
if (!s.closed && !muxClosed) {
|
|
226
|
+
sendFrame(s.id, TYPE_END);
|
|
227
|
+
s.outDone = true;
|
|
228
|
+
releaseIfComplete(s);
|
|
229
|
+
}
|
|
184
230
|
} catch (err) {
|
|
185
231
|
if (!s.closed && !muxClosed) {
|
|
186
232
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
@@ -201,16 +247,54 @@ export function emulateMux(
|
|
|
201
247
|
return;
|
|
202
248
|
}
|
|
203
249
|
await pumpOutbound(s, outbound);
|
|
250
|
+
// The response is complete. If the handler never consumed the request
|
|
251
|
+
// body, nothing will ever drain the inbound queue, so no ACK is sent, the
|
|
252
|
+
// caller's pump parks forever awaiting one, and neither peer frees the
|
|
253
|
+
// slot — while the caller's `for await` completes normally, so nothing
|
|
254
|
+
// looks wrong from user code. Tell the peer we no longer want the body.
|
|
255
|
+
//
|
|
256
|
+
// This is why a handler must consume `input` within its generator's
|
|
257
|
+
// lifetime: draining it from a detached task is indistinguishable, from
|
|
258
|
+
// here, from not draining it at all.
|
|
259
|
+
if (!s.inDone && !s.closed && !muxClosed) {
|
|
260
|
+
sendFrame(s.id, TYPE_CLOSE);
|
|
261
|
+
teardownStream(s);
|
|
262
|
+
}
|
|
204
263
|
};
|
|
205
264
|
|
|
206
265
|
const handleFrame = (frame: Uint8Array): void => {
|
|
207
266
|
if (muxClosed) return;
|
|
208
267
|
if (frame.byteLength < 2) return;
|
|
209
|
-
|
|
268
|
+
|
|
269
|
+
// The id is the one field parsed from untrusted bytes before we know which
|
|
270
|
+
// stream a frame belongs to, and decodeVarint throws on a truncated or
|
|
271
|
+
// over-long encoding. Left unguarded that exception escapes handleFrame,
|
|
272
|
+
// reaches the inbound loop's catch, and calls failAll — so a single
|
|
273
|
+
// malformed frame tears down every stream on the connection.
|
|
274
|
+
//
|
|
275
|
+
// A ByteChannel is message-oriented, so frames are discrete and a corrupt
|
|
276
|
+
// one cannot desync the next. Dropping it is therefore safe, and is what
|
|
277
|
+
// keeps one bad frame from becoming a denial of service against every
|
|
278
|
+
// healthy stream sharing the mux.
|
|
279
|
+
let id: number;
|
|
280
|
+
let offset: number;
|
|
281
|
+
try {
|
|
282
|
+
({ value: id, offset } = decodeVarint(frame, 0));
|
|
283
|
+
} catch {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (offset >= frame.byteLength) return; // id consumed the whole frame: no type byte
|
|
210
287
|
const type = frame[offset];
|
|
211
288
|
const payload = frame.subarray(offset + 1);
|
|
212
289
|
|
|
213
290
|
if (type === TYPE_OPEN) {
|
|
291
|
+
// A duplicate OPEN for a LIVE stream is ignored. A replay of one that
|
|
292
|
+
// already finished is deliberately not guarded: every transport here is
|
|
293
|
+
// ordered and reliable, so a replay cannot occur by accident, and a
|
|
294
|
+
// hostile peer gains nothing by it — opening a fresh id invokes the same
|
|
295
|
+
// handler just as well. An earlier monotonic high-water mark did guard
|
|
296
|
+
// it, at the cost of letting one OPEN with a high id permanently refuse
|
|
297
|
+
// every later stream, including that peer's own legitimate traffic.
|
|
214
298
|
if (streams.has(id)) return;
|
|
215
299
|
if (streams.size >= maxStreams) {
|
|
216
300
|
sendFrame(
|
|
@@ -226,6 +310,13 @@ export function emulateMux(
|
|
|
226
310
|
}
|
|
227
311
|
const s = createStream(id);
|
|
228
312
|
streams.set(id, s);
|
|
313
|
+
// The opener's advertisement is our whole sending allowance; our own
|
|
314
|
+
// goes back so the opener can start. Both ledgers begin at zero, so the
|
|
315
|
+
// advertisement and every later grant are the same operation — an
|
|
316
|
+
// increment — and the ACK handler needs no special first case.
|
|
317
|
+
const advertised = decodeUint32(payload);
|
|
318
|
+
if (advertised !== undefined) s.outboundCredit.grant(advertised);
|
|
319
|
+
sendFrame(id, TYPE_ACK, encodeUint32(maxStreamBuffer));
|
|
229
320
|
void runHandler(s, handler);
|
|
230
321
|
return;
|
|
231
322
|
}
|
|
@@ -235,25 +326,48 @@ export function emulateMux(
|
|
|
235
326
|
|
|
236
327
|
switch (type) {
|
|
237
328
|
case TYPE_DATA: {
|
|
329
|
+
// Flow control is the peer spending only the credit we granted it —
|
|
330
|
+
// voluntary, and a hostile peer simply does not. Pushes are
|
|
331
|
+
// fire-and-forget by necessity (see the comment below), so nothing
|
|
332
|
+
// else bounds this queue: a peer that floods DATA at a handler which
|
|
333
|
+
// has not started draining retains every payload. Refuse past a
|
|
334
|
+
// per-stream cap and tear down THAT stream, never the whole mux.
|
|
335
|
+
s.queuedBytes += payload.byteLength;
|
|
336
|
+
if (s.queuedBytes > maxStreamBuffer) {
|
|
337
|
+
const err = new RangeError(
|
|
338
|
+
`emulateMux: stream ${id} buffered ${s.queuedBytes} bytes past maxStreamBuffer=${maxStreamBuffer} without being drained`,
|
|
339
|
+
);
|
|
340
|
+
sendFrame(id, TYPE_ERROR, encodeError(err));
|
|
341
|
+
teardownStream(s, err);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
238
344
|
const copy = payload.byteLength === 0 ? payload : new Uint8Array(payload);
|
|
239
|
-
// Push fire-and-forget;
|
|
240
|
-
// loop must NOT block on consumer drainage —
|
|
241
|
-
//
|
|
242
|
-
// cross-direction deadlock where ACK frames can't be
|
|
345
|
+
// Push fire-and-forget; grant after the consumer drains. The inbound
|
|
346
|
+
// loop must NOT block on consumer drainage — our own sender is parked
|
|
347
|
+
// in `reserve()` waiting for the peer's grants, so blocking here
|
|
348
|
+
// causes a cross-direction deadlock where ACK frames can't be
|
|
349
|
+
// processed.
|
|
243
350
|
void s.pushIn(copy).then((handled) => {
|
|
244
|
-
|
|
351
|
+
s.queuedBytes -= copy.byteLength;
|
|
352
|
+
if (!handled || s.closed || muxClosed) return;
|
|
353
|
+
// Grant only for bytes the consumer actually took, batched while the
|
|
354
|
+
// receiver is behind and flushed the moment its queue empties.
|
|
355
|
+
const grant = s.grantor.consumed(copy.byteLength, s.queuedBytes === 0);
|
|
356
|
+
if (grant > 0) sendFrame(id, TYPE_ACK, encodeUint32(grant));
|
|
245
357
|
});
|
|
246
358
|
return;
|
|
247
359
|
}
|
|
248
360
|
case TYPE_ACK: {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
361
|
+
const granted = decodeUint32(payload);
|
|
362
|
+
// A payload-less ACK is not from a credit-speaking peer; ignore it
|
|
363
|
+
// rather than granting an arbitrary amount.
|
|
364
|
+
if (granted !== undefined) s.outboundCredit.grant(granted);
|
|
253
365
|
return;
|
|
254
366
|
}
|
|
255
367
|
case TYPE_END: {
|
|
368
|
+
s.inDone = true;
|
|
256
369
|
void s.doneIn();
|
|
370
|
+
releaseIfComplete(s);
|
|
257
371
|
return;
|
|
258
372
|
}
|
|
259
373
|
case TYPE_ERROR: {
|
|
@@ -294,7 +408,7 @@ export function emulateMux(
|
|
|
294
408
|
nextLocalId += 2;
|
|
295
409
|
const s = createStream(id);
|
|
296
410
|
streams.set(id, s);
|
|
297
|
-
sendFrame(id, TYPE_OPEN);
|
|
411
|
+
sendFrame(id, TYPE_OPEN, encodeUint32(maxStreamBuffer));
|
|
298
412
|
void pumpOutbound(s, input);
|
|
299
413
|
return s.inbound;
|
|
300
414
|
};
|
|
@@ -419,7 +533,7 @@ function decodeVarint(buf: Uint8Array, start: number): { value: number; offset:
|
|
|
419
533
|
let shift = 0;
|
|
420
534
|
let i = start;
|
|
421
535
|
while (i < buf.length) {
|
|
422
|
-
const b = buf[i++]
|
|
536
|
+
const b = buf[i++]!; // i < buf.length, checked by the while condition, so this index exists
|
|
423
537
|
value |= (b & 0x7f) << shift;
|
|
424
538
|
if ((b & 0x80) === 0) return { value: value >>> 0, offset: i };
|
|
425
539
|
shift += 7;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credit-based flow control, as a pair of pure state machines with no I/O.
|
|
3
|
+
*
|
|
4
|
+
* The sender holds a {@link CreditLedger}: it reserves credit before it puts
|
|
5
|
+
* anything on the wire, and stalls at zero. The receiver holds a
|
|
6
|
+
* {@link CreditGrantor}: it counts what its consumer has actually drained and
|
|
7
|
+
* says when to hand the sender more.
|
|
8
|
+
*
|
|
9
|
+
* A sender therefore cannot overrun a receiver's buffer, because it was never
|
|
10
|
+
* granted permission to. That is the property a sender-side window cannot
|
|
11
|
+
* offer: the receiver's capacity is not knowable to the sender unless the
|
|
12
|
+
* receiver states it.
|
|
13
|
+
*
|
|
14
|
+
* **The unit is opaque.** This module never interprets the numbers it counts.
|
|
15
|
+
* `emulateMux` passes byte counts and advertises `maxStreamBuffer`; the RPC
|
|
16
|
+
* tier passes 1 per value and advertises a maximum in-flight value count.
|
|
17
|
+
* Nothing here depends on which.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CreditLedger {
|
|
21
|
+
/** Units authorised by the peer and not yet reserved. */
|
|
22
|
+
readonly available: number;
|
|
23
|
+
/**
|
|
24
|
+
* Reserve up to `upTo` units, resolving with how many were actually
|
|
25
|
+
* granted — at least 1, never more than `upTo`. The caller sends exactly
|
|
26
|
+
* that much and calls `reserve` again for the rest.
|
|
27
|
+
*
|
|
28
|
+
* `upTo` must itself be at least 1; anything less rejects with a
|
|
29
|
+
* `RangeError` rather than resolving with 0, which would be a silent no-op
|
|
30
|
+
* that also consumed a waiter slot.
|
|
31
|
+
*
|
|
32
|
+
* Returning a partial amount rather than waiting for the full request is
|
|
33
|
+
* what makes the ledger deadlock-free: a peer that advertises less than
|
|
34
|
+
* one `upTo` still makes progress, one short piece at a time.
|
|
35
|
+
*
|
|
36
|
+
* Rejects if {@link fail} is called.
|
|
37
|
+
*/
|
|
38
|
+
reserve(upTo: number): Promise<number>;
|
|
39
|
+
/** The peer authorised `units` more. */
|
|
40
|
+
grant(units: number): void;
|
|
41
|
+
/** Reject every pending and future reservation — transport or stream is gone. */
|
|
42
|
+
fail(err: Error): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Waiter {
|
|
46
|
+
upTo: number;
|
|
47
|
+
resolve: (granted: number) => void;
|
|
48
|
+
reject: (err: Error) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function newCreditLedger(initial = 0): CreditLedger {
|
|
52
|
+
let available = initial;
|
|
53
|
+
let failure: Error | undefined;
|
|
54
|
+
const waiters: Waiter[] = [];
|
|
55
|
+
|
|
56
|
+
// Waiters are released strictly in order, head first. Letting a later
|
|
57
|
+
// reservation overtake an earlier one would reorder the stream.
|
|
58
|
+
const pump = (): void => {
|
|
59
|
+
while (waiters.length > 0 && available > 0) {
|
|
60
|
+
const next = waiters[0];
|
|
61
|
+
if (!next) return;
|
|
62
|
+
waiters.shift();
|
|
63
|
+
const granted = Math.min(next.upTo, available);
|
|
64
|
+
available -= granted;
|
|
65
|
+
next.resolve(granted);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
get available() {
|
|
71
|
+
return available;
|
|
72
|
+
},
|
|
73
|
+
reserve(upTo: number): Promise<number> {
|
|
74
|
+
if (failure) return Promise.reject(failure);
|
|
75
|
+
// A reservation below one unit is a caller bug, not a legal request: the
|
|
76
|
+
// contract is "at least 1", and the queued path would otherwise consume
|
|
77
|
+
// a waiter in order to hand back nothing.
|
|
78
|
+
if (!(upTo >= 1)) {
|
|
79
|
+
return Promise.reject(
|
|
80
|
+
new RangeError(`newCreditLedger: reserve(${upTo}) — upTo must be at least 1`),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (waiters.length === 0 && available > 0) {
|
|
84
|
+
const granted = Math.min(upTo, available);
|
|
85
|
+
available -= granted;
|
|
86
|
+
return Promise.resolve(granted);
|
|
87
|
+
}
|
|
88
|
+
return new Promise<number>((resolve, reject) => {
|
|
89
|
+
waiters.push({ upTo, resolve, reject });
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
grant(units: number): void {
|
|
93
|
+
if (failure) return;
|
|
94
|
+
available += units;
|
|
95
|
+
pump();
|
|
96
|
+
},
|
|
97
|
+
fail(err: Error): void {
|
|
98
|
+
failure ??= err;
|
|
99
|
+
while (waiters.length > 0) {
|
|
100
|
+
waiters.shift()?.reject(err);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CreditGrantor {
|
|
107
|
+
/**
|
|
108
|
+
* Record that the consumer drained `units`, and whether the receive queue
|
|
109
|
+
* is now empty. Returns the credit to hand back to the peer, or `0` to stay
|
|
110
|
+
* silent and keep accumulating.
|
|
111
|
+
*/
|
|
112
|
+
consumed(units: number, queueEmpty: boolean): number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Grants are batched: replenishing on every chunk while the receiver is
|
|
117
|
+
* behind would reinvent the per-frame ACK this change exists to remove.
|
|
118
|
+
* `threshold` is the fraction of the window that must drain before a grant is
|
|
119
|
+
* emitted.
|
|
120
|
+
*
|
|
121
|
+
* The batch is flushed unconditionally once the receive queue is empty, even
|
|
122
|
+
* below the threshold, so the receiver never sits on credit it owes. Note what
|
|
123
|
+
* this is and is not: paired with a {@link CreditLedger}, a sender blocks only
|
|
124
|
+
* at *exactly* zero credit, and at that point the receiver holds the entire
|
|
125
|
+
* window as `pending` — above any threshold at or below the whole window — so
|
|
126
|
+
* the threshold alone cannot deadlock that pairing. The flush is what keeps
|
|
127
|
+
* that from being an argument about a global accounting identity: it is
|
|
128
|
+
* locally decidable from one boolean, and it returns owed credit now rather
|
|
129
|
+
* than at the next threshold crossing. It costs an extra frame only when the
|
|
130
|
+
* consumer is keeping pace, which is exactly when the sender is not blocked
|
|
131
|
+
* and the frame is cheap.
|
|
132
|
+
*/
|
|
133
|
+
export function newCreditGrantor(window: number, threshold = 0.5): CreditGrantor {
|
|
134
|
+
const trigger = Math.max(1, Math.floor(window * threshold));
|
|
135
|
+
let pending = 0;
|
|
136
|
+
return {
|
|
137
|
+
consumed(units: number, queueEmpty: boolean): number {
|
|
138
|
+
pending += units;
|
|
139
|
+
if (pending === 0) return 0;
|
|
140
|
+
if (pending < trigger && !queueEmpty) return 0;
|
|
141
|
+
const grant = pending;
|
|
142
|
+
pending = 0;
|
|
143
|
+
return grant;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/index.ts
CHANGED