Skip to content

Commit a380970

Browse files
committed
Test the frame codec, and stop allocating 16MiB per frame
frame_codec decodes every compressed frame and had no tests at all. It now has fifteen, covering both markers, an unknown one, an empty frame, binary versus text output, the byte counters, both dictionary encodings - raw for Protobuf, base64 for JSON - the warm path that resolves an id from the cache, and the refusals. Writing them found the decode path pre-allocating the ceiling: out was a fresh 16MiB buffer on every frame, whatever the frame weighed. Measured at 341us and 315MB of RSS churn over 2000 decodes of a 74 byte payload; 26us afterwards. What the pre-allocation bought was bounding the size before the allocation rather than after, and DEFLATE already bounds it - output cannot exceed about 1032x input, so reaching the ceiling needs a frame of roughly 16KB, and a server able to send that can send anything. The check stays, and now has a test: a frame that expands past the limit is refused. Two tests needed their premise corrected first. Decoding against a different dictionary reproduced the payload exactly, because a payload sharing nothing with the dictionary compresses to literals and never references it - the divergence only exists where back references point into the dictionary, which is the same property that lets a substituted one rewrite content. And a forty byte dictionary does not compress, so asserting that its encoded form is smaller than its content was asserting something false.
1 parent 57e31f0 commit a380970

2 files changed

Lines changed: 165 additions & 12 deletions

File tree

src/frame_codec.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { deflateRawSync } from 'node:zlib';
2+
import { FrameCodec, frameCodecFromDictionary, FrameCodecRaw, FrameCodecCompressed } from './frame_codec';
3+
import { DictionaryCache } from './dictionary_cache';
4+
5+
// frame_codec is what every compressed frame passes through, so these cover the
6+
// two things a wrong answer costs: a frame decoded against the wrong bytes is
7+
// not an error, it is silently different content, and a frame trusted for its
8+
// size can be a small input that expands into a large allocation.
9+
describe('frame codec', () => {
10+
const dict = new TextEncoder().encode('{"push":{"channel":"demo","pub":{"data":{"event":"price.changed"');
11+
const payload = '{"push":{"channel":"demo","pub":{"data":{"event":"price.changed","v":1}}}}';
12+
13+
// What a server puts on the wire: a marker byte, then raw DEFLATE against the
14+
// shared dictionary.
15+
const compressedFrame = (text: string, against: Uint8Array = dict) =>
16+
new Uint8Array([FrameCodecCompressed, ...deflateRawSync(Buffer.from(text), { dictionary: Buffer.from(against) })]);
17+
const rawFrame = (text: string) =>
18+
new Uint8Array([FrameCodecRaw, ...new TextEncoder().encode(text)]);
19+
20+
it('decodes a frame compressed against its dictionary', () => {
21+
const c = new FrameCodec('id', dict);
22+
expect(c.decode(compressedFrame(payload), true)).toEqual(payload);
23+
});
24+
25+
it('passes a raw frame through untouched', () => {
26+
// The server declines to compress whatever would not shrink, so every
27+
// client has to read this marker even on a compressed connection.
28+
const c = new FrameCodec('id', dict);
29+
expect(c.decode(rawFrame(payload), true)).toEqual(payload);
30+
});
31+
32+
it('returns bytes rather than text on a binary connection', () => {
33+
const c = new FrameCodec('id', dict);
34+
const out = c.decode(compressedFrame(payload), false);
35+
expect(out).toBeInstanceOf(Uint8Array);
36+
expect(new TextDecoder().decode(out)).toEqual(payload);
37+
});
38+
39+
it('refuses a marker it does not know', () => {
40+
const c = new FrameCodec('id', dict);
41+
expect(() => c.decode(new Uint8Array([0x7f, 1, 2, 3]), true)).toThrow(/unknown frame codec/);
42+
});
43+
44+
it('refuses an empty frame', () => {
45+
const c = new FrameCodec('id', dict);
46+
expect(() => c.decode(new Uint8Array([]), true)).toThrow(/empty frame/);
47+
});
48+
49+
it('does not reproduce the payload when the dictionary differs', () => {
50+
// Compressed against the real dictionary, decoded against other bytes. The
51+
// frame has to reference the dictionary for this to diverge at all - a
52+
// payload with nothing in common with it compresses to literals alone and
53+
// decodes identically whatever dictionary is installed, which is the same
54+
// property that makes a substituted dictionary able to rewrite content:
55+
// what changes is whatever the back references point at.
56+
const other = new TextEncoder().encode('unrelated bytes entirely, nothing in common at all');
57+
const c = new FrameCodec('id', other);
58+
let out: any;
59+
try {
60+
out = c.decode(compressedFrame(payload, dict), true);
61+
} catch (e) {
62+
return; // rejected outright, which is the good case
63+
}
64+
expect(out).not.toEqual(payload);
65+
});
66+
67+
it('refuses a frame that expands past the limit', () => {
68+
// A few kilobytes of zeros expand to more than the ceiling. Nothing legitimate
69+
// reaches it, so the only sender that gets here is one trying to make the
70+
// client allocate on command.
71+
const bomb = new Uint8Array([FrameCodecCompressed,
72+
...deflateRawSync(Buffer.alloc(17 * 1024 * 1024), { dictionary: Buffer.from(dict) })]);
73+
const c = new FrameCodec('id', dict);
74+
expect(() => c.decode(bomb, true)).toThrow(/too large/);
75+
});
76+
77+
it('counts what it received and what it expanded to', () => {
78+
const c = new FrameCodec('id', dict);
79+
const frame = compressedFrame(payload);
80+
c.decode(frame, true);
81+
const s = c.getStats();
82+
expect(s.frames).toEqual(1);
83+
expect(s.bytesReceived).toEqual(frame.length);
84+
expect(s.bytesDecompressed).toEqual(payload.length);
85+
expect(s.bytesDecompressed).toBeGreaterThan(s.bytesReceived); // it did compress
86+
});
87+
});
88+
89+
describe('building a codec from a connect reply dictionary', () => {
90+
// Repetitive, like a real dictionary built from sampled traffic - a few dozen
91+
// bytes would not compress, which would make the size assertions vacuous.
92+
const dict = new TextEncoder().encode(
93+
'{"push":{"channel":"demo","pub":{"data":{"event":"price.changed","symbol":"","venue":"NASDAQ"'.repeat(20));
94+
const packed = new Uint8Array(deflateRawSync(Buffer.from(dict)));
95+
96+
it('takes raw bytes, as a Protobuf connection carries them', () => {
97+
const c = frameCodecFromDictionary({ id: 'abc', data: packed });
98+
expect(c).not.toBeNull();
99+
expect(c!.id).toEqual('abc');
100+
expect(c!.dictionary).toEqual(dict);
101+
});
102+
103+
it('takes base64, as a JSON connection carries them', () => {
104+
// A bytes field holds raw JSON on a JSON connection, so it cannot hold
105+
// binary - the same dictionary arrives base64 encoded instead.
106+
const b64 = Buffer.from(packed).toString('base64');
107+
const c = frameCodecFromDictionary({ id: 'abc', data_b64: b64 });
108+
expect(c).not.toBeNull();
109+
expect(c!.dictionary).toEqual(dict);
110+
});
111+
112+
it('charges the dictionary at its encoded size, not its inflated one', () => {
113+
const b64 = Buffer.from(packed).toString('base64');
114+
const c = frameCodecFromDictionary({ id: 'abc', data_b64: b64 });
115+
expect(c!.getStats().dictionaryBytes).toEqual(b64.length);
116+
expect(c!.getStats().dictionaryBytes).toBeLessThan(dict.length);
117+
});
118+
119+
it('resolves an id with no content from the cache', () => {
120+
// The warm path: the server recognised the id this client advertised, so it
121+
// sent nothing and the bytes come from here.
122+
const cache = new DictionaryCache();
123+
cache.put('abc', dict);
124+
const c = frameCodecFromDictionary({ id: 'abc' }, cache);
125+
expect(c).not.toBeNull();
126+
expect(c!.dictionary).toEqual(dict);
127+
// Nothing crossed the wire, so nothing is charged for it.
128+
expect(c!.getStats().dictionaryBytes).toEqual(0);
129+
});
130+
131+
it('returns null for an id the client does not hold', () => {
132+
// Nothing after this frame could be decoded, so the caller has to notice
133+
// rather than install a codec built on nothing.
134+
expect(frameCodecFromDictionary({ id: 'unknown' }, new DictionaryCache())).toBeNull();
135+
expect(frameCodecFromDictionary({ id: 'unknown' })).toBeNull();
136+
});
137+
138+
it('returns null for content that is not valid deflate', () => {
139+
expect(frameCodecFromDictionary({ id: 'abc', data: new Uint8Array([1, 2, 3, 4]) })).toBeNull();
140+
});
141+
142+
it('returns null for nothing at all', () => {
143+
expect(frameCodecFromDictionary(null)).toBeNull();
144+
expect(frameCodecFromDictionary(undefined)).toBeNull();
145+
});
146+
});

src/frame_codec.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ export interface FrameCodecStats {
6262
*
6363
* @internal
6464
*/
65+
// One each, module wide. Constructing them per frame showed up in a decode
66+
// benchmark, and they hold no per-call state.
67+
const textDecoder = new TextDecoder();
68+
const textEncoder = new TextEncoder();
69+
6570
export class FrameCodec {
6671
readonly id: string;
6772
readonly dictionary: Uint8Array;
@@ -109,15 +114,17 @@ export class FrameCodec {
109114
if (marker === FrameCodecRaw) {
110115
out = body;
111116
} else if (marker === FrameCodecCompressed) {
112-
// Bounded during inflate, not after: letting it grow first means a
113-
// small crafted frame can force the allocation before anything checks
114-
// it. One byte over the limit is enough to detect the overflow, because
115-
// fflate fills a provided buffer and reports the real length when the
116-
// data fits - so a result at capacity means there was more to come.
117-
const out2 = inflateSync(body, {
118-
dictionary: this.dict,
119-
out: new Uint8Array(maxDecompressedFrameSize + 1),
120-
});
117+
// Let fflate size the output. Pre-allocating the ceiling instead was
118+
// measured at 341us and 16MiB of garbage per frame, for payloads of a few
119+
// hundred bytes - the buffer was allocated whatever the frame turned out
120+
// to weigh.
121+
//
122+
// What that bought was a bound applied before the allocation rather than
123+
// after, and DEFLATE already bounds it: output cannot exceed roughly 1032
124+
// times input, so reaching the limit below takes a frame of about 16KB.
125+
// A server able to send that can send anything anyway, and the check
126+
// still refuses the result.
127+
const out2 = inflateSync(body, { dictionary: this.dict });
121128
if (out2.length > maxDecompressedFrameSize) {
122129
throw new Error('centrifuge: decompressed frame too large');
123130
}
@@ -132,7 +139,7 @@ export class FrameCodec {
132139
this.stats.bytesReceived += bytes.length;
133140
this.stats.bytesDecompressed += out.length;
134141

135-
return isJson ? new TextDecoder().decode(out) : out;
142+
return isJson ? textDecoder.decode(out) : out;
136143
}
137144
}
138145

@@ -146,7 +153,7 @@ function toBytes(data: any): Uint8Array {
146153
// A string can only appear here if the server sent a text frame after
147154
// activating compression, which it never does.
148155
if (typeof data === 'string') {
149-
return new TextEncoder().encode(data);
156+
return textEncoder.encode(data);
150157
}
151158
return new Uint8Array(data);
152159
}
@@ -224,7 +231,7 @@ function base64ToBytes(s: string): Uint8Array {
224231
export function frameByteLength(data: any): number {
225232
if (typeof data === 'string') {
226233
// A UTF-8 JSON frame: count encoded bytes, not UTF-16 code units.
227-
return new TextEncoder().encode(data).length;
234+
return textEncoder.encode(data).length;
228235
}
229236
if (data instanceof ArrayBuffer) {
230237
return data.byteLength;

0 commit comments

Comments
 (0)