Skip to content

Commit be95199

Browse files
committed
feat: Stream backpressure and drain (#43)
- Queue packed messages when write() returns false - Re-emit drain; cap pending sends at 1024 - Drop the queue on underlying error/close/end - Version 3.3.0 Closes #43.
1 parent 917be5a commit be95199

8 files changed

Lines changed: 352 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [3.3.0] - 2026-09-19
11+
12+
`Stream.send` queues packed messages when the underlying writable returns
13+
`false`, re-emits `drain`, and refuses more than 1024 pending messages.
14+
See `#43`.
15+
16+
### Added
17+
18+
- `Stream` re-emits `drain` from the underlying writable so callers can
19+
listen on the msgpack Stream, not only on the raw socket.
20+
- After `write()` returns `false`, further `send()` calls queue the already
21+
packed Buffer and flush FIFO on `drain`. `send()` stays synchronous and
22+
returns the boolean from `write()`, or `false` if the message was queued.
23+
- Extra arguments (encoding, callback) are still forwarded to `write` on an
24+
immediate write. Queued flushes call `write(buf)` without inventing an
25+
encoding; a callback supplied on a queued `send` runs after that buffer is
26+
written, or with an error if the queue is dropped.
27+
- The pending-send queue is capped at **1024** messages. A further `send()`
28+
throws a catchable `Error` whose message mentions backpressure / queue
29+
full.
30+
- If the underlying stream emits `error`, `close`, or `end` with messages
31+
still queued, the queue is dropped and Stream emits `error`. An empty
32+
queue does not emit that extra error. Handlers do not throw.
33+
1034
## [3.2.0] - 2026-09-19
1135

1236
Optional second-argument unpack option `{ lazy: true }` wraps maps and arrays
@@ -128,7 +152,8 @@ GitHub Actions tests Node 18/20/22 on Ubuntu, macOS, and Windows 2022.
128152
- Pack throw paths free or return pooled sbuffers on every exit.
129153
- msgpack-c c-7.0.2 includes unpacker buffer-expansion overflow checks.
130154

131-
[Unreleased]: https://github.com/msgpack/msgpack-node/compare/v3.2.0...HEAD
155+
[Unreleased]: https://github.com/msgpack/msgpack-node/compare/v3.3.0...HEAD
156+
[3.3.0]: https://github.com/msgpack/msgpack-node/compare/v3.2.0...v3.3.0
132157
[3.2.0]: https://github.com/msgpack/msgpack-node/compare/v3.1.0...v3.2.0
133158
[3.1.0]: https://github.com/msgpack/msgpack-node/compare/v3.0.0...v3.1.0
134159
[3.0.0]: https://github.com/msgpack/msgpack-node/compare/e04c9b55f98d64512174d6e859b8294b729659a2...HEAD

‎COVERAGE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Coverage — msgpack 3.2.0
1+
# Coverage — msgpack 3.3.0
22

33
`npm run coverage` runs both halves and fails the build under 95%.
44

‎README.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
and de-serializes JavaScript values with [MessagePack](https://msgpack.org).
33
Packed output is a `Buffer` and is typically much smaller than JSON.
44

5-
Version 3.2 requires **Node.js 18+**, vendors **msgpack-c c-7.0.2**, unpacks
5+
Version 3.3 requires **Node.js 18+**, vendors **msgpack-c c-7.0.2**, unpacks
66
64-bit integers outside `Number.MAX_SAFE_INTEGER` as `bigint`, accepts
7-
optional pack type/family hints, and can unpack maps and arrays lazily
8-
(`unpack(buf, { lazy: true })`). See [`SECURITY.md`](SECURITY.md).
7+
optional pack type/family hints, can unpack maps and arrays lazily
8+
(`unpack(buf, { lazy: true })`), and applies write backpressure on
9+
`Stream.send`. See [`SECURITY.md`](SECURITY.md).
910

1011
### Usage
1112

@@ -26,7 +27,14 @@ and returns a JavaScript value, or `null` if the buffer is a truncated
2627
(incomplete) MessagePack object. Oversized array/map/string bombs throw.
2728

2829
A streaming helper wraps a readable socket and emits `msg`, plus `error` when
29-
a packet cannot be unpacked (the offending buffer is dropped):
30+
a packet cannot be unpacked (the offending buffer is dropped). `send()` packs
31+
and writes; it returns the boolean from the underlying `write()`, or `false`
32+
if the message was queued because a previous write returned `false` and
33+
`drain` has not fired yet. `drain` is re-emitted from the underlying
34+
writable onto the Stream. At most **1024** messages may wait in that queue;
35+
a further `send()` throws. Extra `write` arguments (encoding, callback) are
36+
forwarded on an immediate write. On underlying `error` / `close` / `end`,
37+
queued messages are dropped and Stream emits `error` if any were unsent:
3038

3139
```javascript
3240
const msgpack = require('msgpack');
@@ -37,6 +45,9 @@ ms.on('msg', (m) => {
3745
ms.on('error', (e) => {
3846
console.error('bad packet', e.message);
3947
});
48+
ms.on('drain', () => {
49+
/* underlying writable is ready for more send() calls */
50+
});
4051
ms.send({ hello: 'world' });
4152
```
4253

‎index.d.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Type definitions for msgpack 3.2.0
1+
// Type definitions for msgpack 3.3.0
22
// Project: https://github.com/msgpack/msgpack-node
33

44
/// <reference types="node" />
@@ -96,8 +96,11 @@ export namespace unpack {
9696
/**
9797
* Frames MessagePack messages over a stream.
9898
*
99-
* Emits `'msg'` with each decoded value, and `'error'` if a packet cannot be
100-
* decoded (the buffered data is then dropped).
99+
* Emits `'msg'` with each decoded value, `'drain'` when the underlying
100+
* writable is ready for more data and the send queue is empty, and
101+
* `'error'` if a packet cannot be decoded (the buffered data is then
102+
* dropped) or if queued sends are discarded because the underlying stream
103+
* emitted `error`/`close`/`end`.
101104
*/
102105
export class Stream extends EventEmitter {
103106
constructor(s: NodeJS.ReadWriteStream);
@@ -107,7 +110,13 @@ export class Stream extends EventEmitter {
107110

108111
/**
109112
* Pack `m` and write it to the underlying stream. Extra arguments are
110-
* forwarded to `stream.write()` (encoding, callback).
113+
* forwarded to `stream.write()` (encoding, callback) on an immediate
114+
* write. Returns the boolean from `write()`, or `false` if the message
115+
* was queued because a previous write returned false and `drain` has
116+
* not fired yet. At most 1024 messages may wait in that queue; further
117+
* `send()` throws. Queued flushes call `write(buf)` without inventing
118+
* an encoding; a supplied callback runs after that buffer is written
119+
* or if the queue is dropped.
111120
*/
112121
send(m: any, ...args: any[]): boolean;
113122
}

‎lib/msgpack.js‎

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,92 @@ function unpack(buf, opts) {
2626

2727
unpack.bytes_remaining = 0;
2828

29+
const SEND_QUEUE_CAP = 1024;
30+
2931
function Stream(s) {
3032
const self = this;
3133
events.EventEmitter.call(self);
3234
self.buf = null;
3335

36+
const queue = [];
37+
let waitingForDrain = false;
38+
let flushing = false;
39+
40+
function abandonQueue() {
41+
const pending = queue.splice(0, queue.length);
42+
waitingForDrain = false;
43+
if (pending.length === 0) {
44+
return;
45+
}
46+
const err = new Error(
47+
'msgpack Stream dropped ' + pending.length +
48+
' unsent message(s) after backpressure'
49+
);
50+
for (let i = 0; i < pending.length; i++) {
51+
const cb = pending[i].cb;
52+
if (cb) {
53+
process.nextTick(cb, err);
54+
}
55+
}
56+
self.emit('error', err);
57+
}
58+
59+
function onWritableDrain() {
60+
if (flushing) {
61+
return;
62+
}
63+
flushing = true;
64+
try {
65+
while (queue.length > 0) {
66+
const item = queue.shift();
67+
const ok = item.cb ? s.write(item.buf, item.cb) : s.write(item.buf);
68+
if (ok === false) {
69+
waitingForDrain = true;
70+
return;
71+
}
72+
}
73+
waitingForDrain = false;
74+
self.emit('drain');
75+
} finally {
76+
flushing = false;
77+
}
78+
}
79+
3480
self.send = function (m) {
35-
const args = [pack(m)];
81+
const packed = pack(m);
82+
if (waitingForDrain) {
83+
if (queue.length >= SEND_QUEUE_CAP) {
84+
throw new Error(
85+
'msgpack Stream backpressure queue full (' +
86+
SEND_QUEUE_CAP +
87+
' pending messages)'
88+
);
89+
}
90+
let cb;
91+
if (arguments.length > 1 &&
92+
typeof arguments[arguments.length - 1] === 'function') {
93+
cb = arguments[arguments.length - 1];
94+
}
95+
queue.push({ buf: packed, cb: cb });
96+
return false;
97+
}
98+
99+
const args = [packed];
36100
for (let i = 1; i < arguments.length; i++) {
37101
args.push(arguments[i]);
38102
}
39-
return s.write.apply(s, args);
103+
const ok = s.write.apply(s, args);
104+
if (ok === false) {
105+
waitingForDrain = true;
106+
}
107+
return ok;
40108
};
41109

110+
s.addListener('drain', onWritableDrain);
111+
s.addListener('error', abandonQueue);
112+
s.addListener('close', abandonQueue);
113+
s.addListener('end', abandonQueue);
114+
42115
s.addListener('data', function (d) {
43116
if (self.buf) {
44117
const b = buffer.Buffer.allocUnsafe(self.buf.length + d.length);

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "msgpack",
33
"description": "A space-efficient object serialization library for Node.js",
4-
"version": "3.2.0",
4+
"version": "3.3.0",
55
"homepage": "https://github.com/msgpack/msgpack-node",
66
"author": "Peter Griess <pg@std.in>",
77
"contributors": [

0 commit comments

Comments
 (0)