@nxtedition/shared 3.0.2 → 4.0.1
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 +91 -79
- package/lib/index.d.ts +42 -29
- package/lib/index.js +238 -213
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Reads are zero-copy: the reader callback receives a `DataView` directly into the
|
|
|
12
12
|
|
|
13
13
|
## Platform Assumptions
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
All messages are aligned on 4-byte boundaries. Message length headers are read and written via `Int32Array` indexing rather than `DataView`, avoiding per-access endianness checks on the hot path.
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
@@ -23,25 +23,25 @@ npm install @nxtedition/shared
|
|
|
23
23
|
## Usage
|
|
24
24
|
|
|
25
25
|
```js
|
|
26
|
-
import {
|
|
26
|
+
import { State, Reader, Writer } from '@nxtedition/shared'
|
|
27
27
|
|
|
28
|
-
// Allocate shared memory (pass
|
|
29
|
-
const
|
|
28
|
+
// Allocate shared memory (pass state.buffer to a worker thread)
|
|
29
|
+
const state = new State(1024 * 1024) // 1 MB ring buffer
|
|
30
30
|
|
|
31
31
|
// --- Writer side (e.g. main thread) ---
|
|
32
|
-
const w =
|
|
32
|
+
const w = new Writer(state)
|
|
33
33
|
|
|
34
34
|
const payload = Buffer.from('hello world')
|
|
35
35
|
w.writeSync(payload.length, (data) => {
|
|
36
|
-
payload.copy(data.buffer, data.
|
|
37
|
-
return data.
|
|
36
|
+
payload.copy(data.buffer, data.byteOffset)
|
|
37
|
+
return data.byteOffset + payload.length
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
// --- Reader side (e.g. worker thread) ---
|
|
41
|
-
const r =
|
|
41
|
+
const r = new Reader(state)
|
|
42
42
|
|
|
43
43
|
r.readSome((data) => {
|
|
44
|
-
const msg = data.buffer.subarray(data.
|
|
44
|
+
const msg = data.buffer.subarray(data.byteOffset, data.byteOffset + data.byteLength).toString()
|
|
45
45
|
console.log(msg) // 'hello world'
|
|
46
46
|
})
|
|
47
47
|
```
|
|
@@ -53,21 +53,30 @@ w.cork(() => {
|
|
|
53
53
|
for (const item of items) {
|
|
54
54
|
const buf = Buffer.from(JSON.stringify(item))
|
|
55
55
|
w.writeSync(buf.length, (data) => {
|
|
56
|
-
buf.copy(data.buffer, data.
|
|
57
|
-
return data.
|
|
56
|
+
buf.copy(data.buffer, data.byteOffset)
|
|
57
|
+
return data.byteOffset + buf.length
|
|
58
58
|
})
|
|
59
59
|
}
|
|
60
60
|
})
|
|
61
61
|
// All writes flushed atomically when cork returns
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
Or manually:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
w.cork()
|
|
68
|
+
w.writeSync(buf1.length, writeFn, buf1)
|
|
69
|
+
w.writeSync(buf2.length, writeFn, buf2)
|
|
70
|
+
w.uncork() // publishes all writes to the reader
|
|
71
|
+
```
|
|
72
|
+
|
|
64
73
|
### Non-blocking writes with tryWrite
|
|
65
74
|
|
|
66
75
|
```js
|
|
67
76
|
const buf = Buffer.from('data')
|
|
68
77
|
const ok = w.tryWrite(buf.length, (data) => {
|
|
69
|
-
buf.copy(data.buffer, data.
|
|
70
|
-
return data.
|
|
78
|
+
buf.copy(data.buffer, data.byteOffset)
|
|
79
|
+
return data.byteOffset + buf.length
|
|
71
80
|
})
|
|
72
81
|
if (!ok) {
|
|
73
82
|
// Buffer is full — the reader hasn't caught up yet
|
|
@@ -78,28 +87,28 @@ if (!ok) {
|
|
|
78
87
|
|
|
79
88
|
```js
|
|
80
89
|
// main.js
|
|
81
|
-
import {
|
|
90
|
+
import { State, Writer } from '@nxtedition/shared'
|
|
82
91
|
import { Worker } from 'node:worker_threads'
|
|
83
92
|
|
|
84
|
-
const
|
|
93
|
+
const state = new State(1024 * 1024)
|
|
85
94
|
const worker = new Worker('./reader-worker.js', {
|
|
86
|
-
workerData:
|
|
95
|
+
workerData: state.buffer,
|
|
87
96
|
})
|
|
88
97
|
|
|
89
|
-
const w =
|
|
98
|
+
const w = new Writer(state)
|
|
90
99
|
// ... write messages
|
|
91
100
|
```
|
|
92
101
|
|
|
93
102
|
```js
|
|
94
103
|
// reader-worker.js
|
|
95
|
-
import {
|
|
104
|
+
import { Reader } from '@nxtedition/shared'
|
|
96
105
|
import { workerData } from 'node:worker_threads'
|
|
97
106
|
|
|
98
|
-
const r =
|
|
107
|
+
const r = new Reader(workerData)
|
|
99
108
|
|
|
100
109
|
function poll() {
|
|
101
110
|
const count = r.readSome((data) => {
|
|
102
|
-
// process data.buffer at data.
|
|
111
|
+
// process data.buffer at data.byteOffset..data.byteOffset+data.byteLength
|
|
103
112
|
})
|
|
104
113
|
setImmediate(poll)
|
|
105
114
|
}
|
|
@@ -108,113 +117,117 @@ poll()
|
|
|
108
117
|
|
|
109
118
|
## API
|
|
110
119
|
|
|
111
|
-
### `
|
|
120
|
+
### `new State(size: number)` / `new State(buffer: SharedArrayBuffer)`
|
|
121
|
+
|
|
122
|
+
Allocates or wraps a shared memory buffer for the ring buffer. The first 128 bytes are reserved for state (read/write pointers); the rest is the data region.
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
- **size** — Data capacity in bytes (must be a positive integer, max ~2 GB)
|
|
125
|
+
- **buffer** — An existing `SharedArrayBuffer` to wrap
|
|
114
126
|
|
|
115
|
-
|
|
116
|
-
- Returns `{ sharedState: SharedArrayBuffer, sharedBuffer: SharedArrayBuffer }`
|
|
127
|
+
#### `state.buffer`
|
|
117
128
|
|
|
118
|
-
|
|
129
|
+
The underlying `SharedArrayBuffer`. Pass this to a worker thread to share the ring buffer.
|
|
119
130
|
|
|
120
|
-
|
|
131
|
+
### `new Reader(state: State | SharedArrayBuffer)`
|
|
121
132
|
|
|
122
|
-
|
|
133
|
+
Creates a reader for the ring buffer. Accepts a `State` instance or a `SharedArrayBuffer` directly (shorthand for `new Reader(new State(buf))`).
|
|
123
134
|
|
|
124
|
-
|
|
135
|
+
#### `reader.readSome(next, opaque?)`
|
|
136
|
+
|
|
137
|
+
Reads a batch of messages. Calls `next(data, opaque)` for each message, where `data` has:
|
|
125
138
|
|
|
126
139
|
- `buffer: Buffer` — The underlying shared buffer
|
|
127
140
|
- `view: DataView` — A DataView over the shared buffer
|
|
128
|
-
- `
|
|
129
|
-
- `
|
|
141
|
+
- `byteOffset: number` — Start offset of the message payload
|
|
142
|
+
- `byteLength: number` — Length of the message payload in bytes
|
|
143
|
+
|
|
144
|
+
- **opaque** — Optional user-provided context object passed through to the callback. Useful for avoiding closures on hot paths.
|
|
130
145
|
|
|
131
146
|
Return `false` from the callback to stop reading early. Returns the number of messages processed.
|
|
132
147
|
|
|
133
148
|
Messages are batched: up to 1024 items or 256 KiB per call.
|
|
134
149
|
|
|
135
|
-
### `
|
|
150
|
+
### `new Writer(state: State | SharedArrayBuffer, options?)`
|
|
136
151
|
|
|
137
|
-
Creates a writer for the ring buffer.
|
|
152
|
+
Creates a writer for the ring buffer. Accepts a `State` instance or a `SharedArrayBuffer` directly (shorthand for `new Writer(new State(buf))`).
|
|
138
153
|
|
|
139
154
|
**Options:**
|
|
140
155
|
|
|
141
156
|
- `yield?: () => void` — Called when the writer must wait for the reader to catch up. Useful to prevent deadlocks when the writer thread also drives the reader.
|
|
142
157
|
- `logger?: { warn(obj, msg): void }` — Logger for yield warnings (pino-compatible).
|
|
143
158
|
|
|
144
|
-
#### `writer.writeSync(len, fn,
|
|
159
|
+
#### `writer.writeSync(len, fn, opaque?)`
|
|
145
160
|
|
|
146
161
|
Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
|
|
147
162
|
|
|
148
163
|
- **len** — Maximum payload size in bytes. Writing beyond `len` bytes in the callback is undefined behavior.
|
|
149
|
-
- **fn(data) → number** — Write callback. Write payload into `data.buffer` starting at `data.
|
|
150
|
-
- **
|
|
164
|
+
- **fn(data, opaque) → number** — Write callback. Write payload into `data.buffer` starting at `data.byteOffset`. **Must return the end position** (`data.byteOffset + bytesWritten`), not the byte count.
|
|
165
|
+
- **opaque** — Optional user-provided context object passed through to the callback. Useful for avoiding closures on hot paths.
|
|
166
|
+
|
|
167
|
+
Throws on timeout (default: 60000 ms).
|
|
168
|
+
|
|
169
|
+
#### `writer.tryWrite(len, fn, opaque?)`
|
|
151
170
|
|
|
152
|
-
|
|
171
|
+
Non-blocking write attempt. Returns `false` if the buffer is full. The `fn` and `opaque` parameters follow the same contract as `writeSync`.
|
|
153
172
|
|
|
154
|
-
|
|
173
|
+
#### `writer.cork(callback?)`
|
|
155
174
|
|
|
156
|
-
|
|
175
|
+
Batches multiple writes. The write pointer is only published to the reader when the cork is released, reducing atomic operation overhead.
|
|
157
176
|
|
|
158
|
-
|
|
177
|
+
When called with a callback, uncork is called automatically when the callback returns. When called without a callback, you must call `uncork()` manually.
|
|
178
|
+
|
|
179
|
+
#### `writer.uncork()`
|
|
180
|
+
|
|
181
|
+
Decrements the cork counter. When it reaches zero, publishes the pending write position to the reader. Safe to call when not corked (no-op).
|
|
182
|
+
|
|
183
|
+
#### `writer.flushSync()`
|
|
184
|
+
|
|
185
|
+
Immediately publishes the pending write position to the reader. Unlike `uncork`, this does not interact with the cork counter — it forces a flush regardless.
|
|
159
186
|
|
|
160
187
|
## Benchmarks
|
|
161
188
|
|
|
162
|
-
Measured on
|
|
189
|
+
Measured on AMD EPYC 9355P (4.28 GHz), Node.js 25.6.0, 8 MiB ring buffer, Docker (x64-linux).
|
|
163
190
|
|
|
164
191
|
Each benchmark writes batches of fixed-size messages from the main thread and
|
|
165
192
|
reads them in a worker thread. The shared ring buffer is compared against
|
|
166
|
-
Node.js `postMessage` (structured clone).
|
|
167
|
-
collected with [`@mitata/counters`](https://github.com/evanwashere/mitata).
|
|
193
|
+
Node.js `postMessage` (structured clone).
|
|
168
194
|
|
|
169
195
|
### Throughput
|
|
170
196
|
|
|
171
197
|
| Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
|
|
172
198
|
| -----: | --------------: | --------------: | -------------------: | -------------------: |
|
|
173
|
-
| 64 B |
|
|
174
|
-
| 256 B | **2.
|
|
175
|
-
| 1 KiB |
|
|
176
|
-
| 4 KiB |
|
|
177
|
-
| 16 KiB |
|
|
178
|
-
| 64 KiB |
|
|
199
|
+
| 64 B | **901 MiB/s** | 410 MiB/s | 25 MiB/s | 42 MiB/s |
|
|
200
|
+
| 256 B | **2.67 GiB/s** | 896 MiB/s | 88 MiB/s | 158 MiB/s |
|
|
201
|
+
| 1 KiB | **4.88 GiB/s** | 1.26 GiB/s | 328 MiB/s | 498 MiB/s |
|
|
202
|
+
| 4 KiB | **9.22 GiB/s** | 1.50 GiB/s | 1.14 GiB/s | 1.70 GiB/s |
|
|
203
|
+
| 16 KiB | **10.90 GiB/s** | 1.56 GiB/s | 4.29 GiB/s | 6.27 GiB/s |
|
|
204
|
+
| 64 KiB | 13.03 GiB/s | 1.55 GiB/s | 10.10 GiB/s | **15.18 GiB/s** |
|
|
179
205
|
|
|
180
206
|
### Message rate
|
|
181
207
|
|
|
182
208
|
| Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
|
|
183
209
|
| -----: | --------------: | --------------: | -------------------: | -------------------: |
|
|
184
|
-
| 64 B | **
|
|
185
|
-
| 256 B | **
|
|
186
|
-
| 1 KiB |
|
|
187
|
-
| 4 KiB |
|
|
188
|
-
| 16 KiB |
|
|
189
|
-
| 64 KiB |
|
|
190
|
-
|
|
191
|
-
### CPU efficiency (instructions per cycle)
|
|
192
|
-
|
|
193
|
-
| Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
|
|
194
|
-
| -----: | --------------: | --------------: | -------------------: | -------------------: |
|
|
195
|
-
| 64 B | 4.80 | 5.79 | 3.91 | 3.37 |
|
|
196
|
-
| 256 B | 4.46 | 5.98 | 3.48 | 3.06 |
|
|
197
|
-
| 1 KiB | 4.17 | **6.29** | 3.63 | 3.15 |
|
|
198
|
-
| 4 KiB | 3.75 | **6.72** | 3.38 | 2.83 |
|
|
199
|
-
| 16 KiB | 3.80 | **6.03** | 2.74 | 2.86 |
|
|
200
|
-
| 64 KiB | 3.96 | **4.57** | 2.43 | 2.93 |
|
|
210
|
+
| 64 B | **14.76 M/s** | 6.72 M/s | 405 K/s | 688 K/s |
|
|
211
|
+
| 256 B | **11.20 M/s** | 3.67 M/s | 360 K/s | 648 K/s |
|
|
212
|
+
| 1 KiB | **5.12 M/s** | 1.32 M/s | 336 K/s | 510 K/s |
|
|
213
|
+
| 4 KiB | **2.42 M/s** | 394 K/s | 298 K/s | 445 K/s |
|
|
214
|
+
| 16 KiB | **714 K/s** | 102 K/s | 281 K/s | 411 K/s |
|
|
215
|
+
| 64 KiB | 213 K/s | 25 K/s | 165 K/s | **249 K/s** |
|
|
201
216
|
|
|
202
217
|
### Key findings
|
|
203
218
|
|
|
204
|
-
- **Small messages (64
|
|
205
|
-
|
|
206
|
-
Per-message overhead dominates at
|
|
207
|
-
the biggest difference.
|
|
219
|
+
- **Small messages (64–256 B):** The shared ring buffer with `Buffer.set` delivers
|
|
220
|
+
**14.8–11.2 M msg/s** — up to **36x faster** than `postMessage` (buffer) and
|
|
221
|
+
**21x faster** than `postMessage` (string). Per-message overhead dominates at
|
|
222
|
+
these sizes, and avoiding structured cloning makes the biggest difference.
|
|
208
223
|
|
|
209
|
-
- **
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
(6-7 IPC on Apple M3 Pro), which explains why string writes outperform raw
|
|
213
|
-
`Buffer.copy` at larger sizes.
|
|
224
|
+
- **Medium to large messages (1–16 KiB):** `Buffer.set` via the ring buffer
|
|
225
|
+
maintains its lead, reaching **10.9 GiB/s** at 16 KiB — **1.7–5.4x faster**
|
|
226
|
+
than the best `postMessage` variant.
|
|
214
227
|
|
|
215
|
-
- **
|
|
216
|
-
|
|
217
|
-
|
|
228
|
+
- **Very large messages (64 KiB):** `postMessage` (string) overtakes the shared
|
|
229
|
+
buffer at **15.2 GiB/s** vs **13.0 GiB/s**. At this size, structured cloning
|
|
230
|
+
overhead is amortized and the kernel's optimized `memcpy` dominates.
|
|
218
231
|
|
|
219
232
|
- **Caveat:** The string benchmark uses ASCII-only content. Multi-byte UTF-8
|
|
220
233
|
strings will not hit V8's vectorized fast path and will be significantly slower.
|
|
@@ -222,8 +235,7 @@ collected with [`@mitata/counters`](https://github.com/evanwashere/mitata).
|
|
|
222
235
|
### Running the benchmark
|
|
223
236
|
|
|
224
237
|
```sh
|
|
225
|
-
|
|
226
|
-
sudo node --allow-natives-syntax packages/shared/src/bench.mjs
|
|
238
|
+
node --allow-natives-syntax packages/shared/src/bench.mjs
|
|
227
239
|
```
|
|
228
240
|
|
|
229
241
|
## License
|
package/lib/index.d.ts
CHANGED
|
@@ -1,44 +1,57 @@
|
|
|
1
|
-
export interface SharedBuffers {
|
|
2
|
-
sharedState: SharedArrayBuffer;
|
|
3
|
-
sharedBuffer: SharedArrayBuffer;
|
|
4
|
-
}
|
|
5
1
|
export interface BufferRegion {
|
|
6
2
|
buffer: Buffer;
|
|
7
3
|
view: DataView;
|
|
8
|
-
offset: number;
|
|
9
|
-
length: number;
|
|
10
4
|
byteOffset: number;
|
|
11
5
|
byteLength: number;
|
|
12
6
|
}
|
|
13
|
-
/**
|
|
14
|
-
* Allocates the shared memory buffers.
|
|
15
|
-
*/
|
|
16
|
-
export declare function alloc(size: number): SharedBuffers;
|
|
17
|
-
export interface Reader {
|
|
18
|
-
readSome<U>(next: (data: BufferRegion, opaque: U) => void | boolean, opaque: U): number;
|
|
19
|
-
readSome(next: (data: BufferRegion) => void | boolean): number;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Creates a reader for the ring buffer.
|
|
23
|
-
*/
|
|
24
|
-
export declare function reader({ sharedState, sharedBuffer }: SharedBuffers): Reader;
|
|
25
7
|
export interface WriterOptions {
|
|
26
8
|
yield?: () => void;
|
|
27
9
|
logger?: {
|
|
28
10
|
warn(obj: object, msg: string): void;
|
|
29
11
|
};
|
|
30
12
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Shared ring buffer state. Allocates or wraps a SharedArrayBuffer where the
|
|
15
|
+
* first 128 bytes are reserved for read/write pointers and the rest is the
|
|
16
|
+
* data region.
|
|
17
|
+
*/
|
|
18
|
+
export declare class State {
|
|
19
|
+
readonly buffer: SharedArrayBuffer;
|
|
20
|
+
constructor(size: number);
|
|
21
|
+
constructor(buffer: SharedArrayBuffer);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reader for the ring buffer.
|
|
25
|
+
*/
|
|
26
|
+
export declare class Reader {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(state: State | SharedArrayBuffer);
|
|
29
|
+
readSome<U>(next: (data: BufferRegion, opaque?: U) => void | boolean, opaque?: U): number;
|
|
40
30
|
}
|
|
41
31
|
/**
|
|
42
|
-
*
|
|
32
|
+
* Writer for the ring buffer.
|
|
43
33
|
*/
|
|
44
|
-
export declare
|
|
34
|
+
export declare class Writer {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(state: State | SharedArrayBuffer, { yield: onYield, logger }?: WriterOptions);
|
|
37
|
+
/**
|
|
38
|
+
* Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
|
|
39
|
+
* Writing more than "len" bytes in the callback will cause undefined behavior.
|
|
40
|
+
*/
|
|
41
|
+
writeSync<U>(len: number, fn: (data: BufferRegion, opaque?: U) => number, opaque?: U): void;
|
|
42
|
+
/**
|
|
43
|
+
* Non-blocking write attempt. Returns `false` if the buffer is full.
|
|
44
|
+
* Writing more than "len" bytes in the callback will cause undefined behavior.
|
|
45
|
+
*/
|
|
46
|
+
tryWrite<U>(len: number, fn: (data: BufferRegion, opaque?: U) => number, opaque?: U): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Batches multiple writes within the callback. The write pointer is only
|
|
49
|
+
* published to the reader when cork returns, reducing atomic operation overhead.
|
|
50
|
+
*/
|
|
51
|
+
cork<T>(callback?: () => T): T | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Publishes the pending write position to the reader.
|
|
54
|
+
*/
|
|
55
|
+
uncork(): void;
|
|
56
|
+
flushSync(): void;
|
|
57
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -7,90 +7,106 @@
|
|
|
7
7
|
const WRITE_INDEX = 0
|
|
8
8
|
const READ_INDEX = 16
|
|
9
9
|
|
|
10
|
+
// The first 128 bytes of the buffer are reserved for state (read/write pointers).
|
|
11
|
+
// Data starts at byte offset 128.
|
|
12
|
+
const STATE_BYTES = 128
|
|
13
|
+
|
|
10
14
|
// High-Water Mark for batching operations to reduce the frequency
|
|
11
15
|
// of expensive atomic writes.
|
|
12
16
|
const HWM_BYTES = 256 * 1024 // 256 KiB
|
|
13
17
|
const HWM_COUNT = 1024 // 1024 items
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
22
|
|
|
26
23
|
|
|
27
24
|
|
|
28
25
|
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
29
31
|
/**
|
|
30
|
-
* Allocates
|
|
32
|
+
* Shared ring buffer state. Allocates or wraps a SharedArrayBuffer where the
|
|
33
|
+
* first 128 bytes are reserved for read/write pointers and the rest is the
|
|
34
|
+
* data region.
|
|
31
35
|
*/
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
export class State {
|
|
37
|
+
buffer
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
constructor(sizeOrBuffer ) {
|
|
42
|
+
if (sizeOrBuffer instanceof SharedArrayBuffer) {
|
|
43
|
+
if (sizeOrBuffer.byteLength < STATE_BYTES + 8) {
|
|
44
|
+
throw new RangeError('SharedArrayBuffer too small for ring buffer state')
|
|
45
|
+
}
|
|
46
|
+
if (sizeOrBuffer.byteLength >= 2 ** 31) {
|
|
47
|
+
throw new RangeError('Shared buffer size exceeds maximum of 2GB')
|
|
48
|
+
}
|
|
49
|
+
this.buffer = sizeOrBuffer
|
|
50
|
+
} else {
|
|
51
|
+
const size = sizeOrBuffer
|
|
52
|
+
if (!Number.isInteger(size)) {
|
|
53
|
+
throw new TypeError('size must be a positive integer')
|
|
54
|
+
}
|
|
55
|
+
if (size <= 0) {
|
|
56
|
+
throw new RangeError('size must be a positive integer')
|
|
57
|
+
}
|
|
58
|
+
if (size >= 2 ** 31 - 11) {
|
|
59
|
+
throw new RangeError('size exceeds maximum of 2GB minus header size')
|
|
60
|
+
}
|
|
61
|
+
// 128 bytes for state + data region (rounded up to 4-byte boundary for Int32Array).
|
|
62
|
+
this.buffer = new SharedArrayBuffer(STATE_BYTES + ((size + 8 + 3) & ~3))
|
|
63
|
+
}
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
67
|
/**
|
|
58
|
-
*
|
|
68
|
+
* Reader for the ring buffer.
|
|
59
69
|
*/
|
|
60
|
-
export
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
export class Reader {
|
|
71
|
+
#state
|
|
72
|
+
#size
|
|
73
|
+
#int32
|
|
74
|
+
#data
|
|
75
|
+
#readPos
|
|
76
|
+
|
|
77
|
+
constructor(state ) {
|
|
78
|
+
const sharedBuffer = state instanceof SharedArrayBuffer ? state : state.buffer
|
|
79
|
+
const size = sharedBuffer.byteLength - STATE_BYTES
|
|
80
|
+
|
|
81
|
+
this.#state = new Int32Array(sharedBuffer, 0, STATE_BYTES >> 2)
|
|
82
|
+
this.#size = size
|
|
83
|
+
this.#int32 = new Int32Array(sharedBuffer, STATE_BYTES)
|
|
84
|
+
|
|
85
|
+
// This object is reused to avoid creating new objects in a hot path.
|
|
86
|
+
// This helps V8 maintain a stable hidden class for the object,
|
|
87
|
+
// which is a key optimization (zero-copy read).
|
|
88
|
+
this.#data = {
|
|
89
|
+
buffer: Buffer.from(sharedBuffer, STATE_BYTES, size),
|
|
90
|
+
view: new DataView(sharedBuffer, STATE_BYTES, size),
|
|
91
|
+
byteOffset: 0,
|
|
92
|
+
byteLength: 0,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Local copy of the pointer. The `| 0` is a hint to the V8 JIT
|
|
96
|
+
// compiler that this is a 32-bit integer, enabling optimizations.
|
|
97
|
+
this.#readPos = Atomics.load(this.#state, READ_INDEX) | 0
|
|
69
98
|
}
|
|
70
99
|
|
|
71
|
-
|
|
72
|
-
const size = sharedBuffer.byteLength
|
|
73
|
-
const buffer = Buffer.from(sharedBuffer)
|
|
74
|
-
const view = new DataView(sharedBuffer)
|
|
75
|
-
|
|
76
|
-
// This object is reused to avoid creating new objects in a hot path.
|
|
77
|
-
// This helps V8 maintain a stable hidden class for the object,
|
|
78
|
-
// which is a key optimization (zero-copy read).
|
|
79
|
-
const data = { buffer, view, offset: 0, length: 0, byteOffset: 0, byteLength: 0 }
|
|
80
|
-
|
|
81
|
-
// Local copies of the pointers. The `| 0` is a hint to the V8 JIT
|
|
82
|
-
// compiler that these are 32-bit integers, enabling optimizations.
|
|
83
|
-
let readPos = Atomics.load(state, READ_INDEX) | 0
|
|
84
|
-
let writePos = Atomics.load(state, WRITE_INDEX) | 0
|
|
85
|
-
|
|
86
|
-
function readSome (
|
|
87
|
-
next ,
|
|
88
|
-
opaque ,
|
|
89
|
-
) {
|
|
100
|
+
readSome (next , opaque ) {
|
|
90
101
|
let count = 0
|
|
91
102
|
let bytes = 0
|
|
92
103
|
|
|
93
|
-
|
|
104
|
+
const state = this.#state
|
|
105
|
+
const int32 = this.#int32
|
|
106
|
+
const size = this.#size
|
|
107
|
+
const data = this.#data
|
|
108
|
+
let readPos = this.#readPos
|
|
109
|
+
let writePos = state[WRITE_INDEX] | 0
|
|
94
110
|
|
|
95
111
|
// First, check if the local writePos matches the readPos.
|
|
96
112
|
// If so, refresh it from shared memory in case the writer has added data.
|
|
@@ -100,8 +116,8 @@ export function reader({ sharedState, sharedBuffer } ) {
|
|
|
100
116
|
|
|
101
117
|
// Process messages in a batch to minimize loop and atomic operation overhead.
|
|
102
118
|
while (count < HWM_COUNT && bytes < HWM_BYTES && readPos !== writePos) {
|
|
119
|
+
const dataLen = int32[readPos >> 2] | 0
|
|
103
120
|
const dataPos = readPos + 4
|
|
104
|
-
const dataLen = view.getInt32(dataPos - 4, true) | 0
|
|
105
121
|
|
|
106
122
|
bytes += 4
|
|
107
123
|
|
|
@@ -120,16 +136,16 @@ export function reader({ sharedState, sharedBuffer } ) {
|
|
|
120
136
|
throw new Error('Data exceeds buffer size')
|
|
121
137
|
}
|
|
122
138
|
|
|
123
|
-
|
|
139
|
+
// Advance by aligned length so next header is on a 4-byte boundary.
|
|
140
|
+
const alignedLen = (dataLen + 3) & ~3
|
|
141
|
+
readPos += 4 + alignedLen
|
|
124
142
|
|
|
125
|
-
bytes +=
|
|
143
|
+
bytes += alignedLen
|
|
126
144
|
count += 1
|
|
127
145
|
|
|
128
146
|
// This is a "zero-copy" operation. We don't copy the data out.
|
|
129
147
|
// Instead, we pass a "view" into the shared buffer.
|
|
130
|
-
data.offset = dataPos
|
|
131
148
|
data.byteOffset = dataPos
|
|
132
|
-
data.length = dataLen
|
|
133
149
|
data.byteLength = dataLen
|
|
134
150
|
|
|
135
151
|
if (next(data, opaque) === false) {
|
|
@@ -138,6 +154,8 @@ export function reader({ sharedState, sharedBuffer } ) {
|
|
|
138
154
|
}
|
|
139
155
|
}
|
|
140
156
|
|
|
157
|
+
this.#readPos = readPos
|
|
158
|
+
|
|
141
159
|
// IMPORTANT: The reader only updates its shared `readPos` after a batch
|
|
142
160
|
// is processed. This significantly reduces atomic operation overhead.
|
|
143
161
|
if (bytes > 0) {
|
|
@@ -146,190 +164,169 @@ export function reader({ sharedState, sharedBuffer } ) {
|
|
|
146
164
|
|
|
147
165
|
return count
|
|
148
166
|
}
|
|
149
|
-
|
|
150
|
-
return { readSome }
|
|
151
167
|
}
|
|
152
168
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
169
|
/**
|
|
170
|
-
*
|
|
170
|
+
* Writer for the ring buffer.
|
|
171
171
|
*/
|
|
172
|
-
export
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
172
|
+
export class Writer {
|
|
173
|
+
#state
|
|
174
|
+
#size
|
|
175
|
+
#int32
|
|
176
|
+
#data
|
|
177
|
+
#readPos
|
|
178
|
+
#writePos
|
|
179
|
+
#yielding
|
|
180
|
+
#corked
|
|
181
|
+
#pending
|
|
182
|
+
#onYield
|
|
183
|
+
#logger
|
|
184
|
+
#uncorkBound
|
|
185
|
+
|
|
186
|
+
constructor(state , { yield: onYield, logger } = {}) {
|
|
187
|
+
const sharedBuffer = state instanceof SharedArrayBuffer ? state : state.buffer
|
|
188
|
+
|
|
189
|
+
if (onYield != null && typeof onYield !== 'function') {
|
|
190
|
+
throw new TypeError('onYield must be a function')
|
|
191
|
+
}
|
|
190
192
|
|
|
191
|
-
|
|
192
|
-
// This helps V8 maintain a stable hidden class for the object,
|
|
193
|
-
// which is a key optimization (zero-copy read).
|
|
194
|
-
const data = { buffer, view, offset: 0, length: 0, byteOffset: 0, byteLength: 0 }
|
|
193
|
+
const size = sharedBuffer.byteLength - STATE_BYTES
|
|
195
194
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
let writePos = Atomics.load(state, WRITE_INDEX) | 0
|
|
195
|
+
this.#state = new Int32Array(sharedBuffer, 0, STATE_BYTES >> 2)
|
|
196
|
+
this.#size = size
|
|
197
|
+
this.#int32 = new Int32Array(sharedBuffer, STATE_BYTES)
|
|
200
198
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
// This object is reused to avoid creating new objects in a hot path.
|
|
200
|
+
// This helps V8 maintain a stable hidden class for the object,
|
|
201
|
+
// which is a key optimization (zero-copy read).
|
|
202
|
+
this.#data = {
|
|
203
|
+
buffer: Buffer.from(sharedBuffer, STATE_BYTES, size),
|
|
204
|
+
view: new DataView(sharedBuffer, STATE_BYTES, size),
|
|
205
|
+
byteOffset: 0,
|
|
206
|
+
byteLength: 0,
|
|
207
|
+
}
|
|
204
208
|
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
// Local copies of the pointers. The `| 0` is a hint to the V8 JIT
|
|
210
|
+
// compiler that these are 32-bit integers, enabling optimizations.
|
|
211
|
+
this.#readPos = Atomics.load(this.#state, READ_INDEX) | 0
|
|
212
|
+
this.#writePos = Atomics.load(this.#state, WRITE_INDEX) | 0
|
|
213
|
+
|
|
214
|
+
this.#yielding = 0
|
|
215
|
+
this.#corked = 0
|
|
216
|
+
this.#pending = 0
|
|
217
|
+
this.#onYield = onYield
|
|
218
|
+
this.#logger = logger
|
|
219
|
+
this.#uncorkBound = this.uncork.bind(this)
|
|
207
220
|
}
|
|
208
221
|
|
|
209
222
|
/**
|
|
210
223
|
* Pauses the writer thread to wait for the reader to catch up.
|
|
211
224
|
*/
|
|
212
|
-
|
|
213
|
-
if (yielding > 128) {
|
|
225
|
+
#yield(delay ) {
|
|
226
|
+
if (this.#yielding > 128) {
|
|
214
227
|
throw new Error('Detected possible deadlock: writer yielding too many times')
|
|
215
228
|
}
|
|
216
229
|
|
|
217
230
|
// First, ensure the very latest write position is visible to the reader.
|
|
218
|
-
|
|
231
|
+
this.flushSync()
|
|
219
232
|
|
|
220
|
-
if (onYield) {
|
|
221
|
-
yielding += 1
|
|
233
|
+
if (this.#onYield) {
|
|
234
|
+
this.#yielding += 1
|
|
222
235
|
try {
|
|
223
236
|
// Call the user-provided yield function, if any. This can be important
|
|
224
237
|
// if the writer is waiting for the reader to process data which would
|
|
225
238
|
// otherwise deadlock.
|
|
226
|
-
onYield()
|
|
239
|
+
this.#onYield()
|
|
227
240
|
} finally {
|
|
228
|
-
yielding -= 1
|
|
241
|
+
this.#yielding -= 1
|
|
229
242
|
}
|
|
230
243
|
}
|
|
231
244
|
|
|
232
245
|
// Atomics.wait is the most efficient way to pause. It puts the thread
|
|
233
246
|
// to sleep, consuming no CPU, until the reader changes the READ_INDEX.
|
|
234
247
|
if (delay > 0) {
|
|
235
|
-
Atomics.wait(state, READ_INDEX, readPos, delay)
|
|
248
|
+
Atomics.wait(this.#state, READ_INDEX, this.#readPos, delay)
|
|
236
249
|
} else {
|
|
237
250
|
// @ts-expect-error Atomics.pause is Stage 3, available in Node.js 25+
|
|
238
251
|
Atomics.pause()
|
|
239
252
|
}
|
|
240
253
|
|
|
241
254
|
// After waking up, refresh the local view of the reader's position.
|
|
242
|
-
readPos = Atomics.load(state, READ_INDEX) | 0
|
|
255
|
+
this.#readPos = Atomics.load(this.#state, READ_INDEX) | 0
|
|
243
256
|
}
|
|
244
257
|
|
|
245
258
|
/**
|
|
246
259
|
* Tries to acquire enough space in the buffer for a new message.
|
|
247
260
|
*/
|
|
248
|
-
|
|
249
|
-
// Total space required: payload + its 4-byte length header + a potential
|
|
261
|
+
#acquire(len ) {
|
|
262
|
+
// Total space required: aligned payload + its 4-byte length header + a potential
|
|
250
263
|
// 4-byte header for the *next* message (for wrap-around check).
|
|
251
|
-
const required = len + 4 + 4
|
|
264
|
+
const required = ((len + 3) & ~3) + 4 + 4
|
|
265
|
+
const size = this.#size
|
|
266
|
+
const state = this.#state
|
|
267
|
+
const int32 = this.#int32
|
|
252
268
|
|
|
253
|
-
if (writePos >= readPos) {
|
|
269
|
+
if (this.#writePos >= this.#readPos) {
|
|
254
270
|
// Case 1: The writer is ahead of the reader. [ 0 - R ... W - size ]
|
|
255
271
|
// There is free space from W to the end (s) and from 0 to R.
|
|
256
272
|
|
|
257
|
-
if (size - writePos >= required) {
|
|
273
|
+
if (size - this.#writePos >= required) {
|
|
258
274
|
// Enough space at the end of the buffer.
|
|
259
275
|
return true
|
|
260
276
|
}
|
|
261
277
|
|
|
262
|
-
readPos = state[READ_INDEX] | 0
|
|
263
|
-
if (readPos === 0) {
|
|
264
|
-
|
|
278
|
+
this.#readPos = state[READ_INDEX] | 0
|
|
279
|
+
if (this.#readPos === 0) {
|
|
280
|
+
this.#yield(0)
|
|
265
281
|
}
|
|
266
282
|
|
|
267
283
|
// Not enough space at the end. Check if there's space at the beginning.
|
|
268
|
-
if (readPos === 0) {
|
|
284
|
+
if (this.#readPos === 0) {
|
|
269
285
|
// Reader is at the beginning, so no space to wrap around into.
|
|
270
286
|
return false
|
|
271
287
|
}
|
|
272
288
|
|
|
273
289
|
// Mark the current position with a wrap-around signal (-1).
|
|
274
|
-
|
|
290
|
+
int32[this.#writePos >> 2] = -1
|
|
275
291
|
|
|
276
292
|
// Reset writer position to the beginning.
|
|
277
|
-
writePos = 0
|
|
293
|
+
this.#writePos = 0
|
|
278
294
|
|
|
279
|
-
if (writePos + 4 > size) {
|
|
295
|
+
if (this.#writePos + 4 > size) {
|
|
280
296
|
// assertion
|
|
281
|
-
throw new Error(
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Write position ${this.#writePos} with next header exceeds buffer size ${size}`,
|
|
299
|
+
)
|
|
282
300
|
}
|
|
283
|
-
if (writePos === readPos) {
|
|
301
|
+
if (this.#writePos === this.#readPos) {
|
|
284
302
|
// assertion
|
|
285
|
-
throw new Error(
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Write position ${this.#writePos} cannot equal read position ${this.#readPos}`,
|
|
305
|
+
)
|
|
286
306
|
}
|
|
287
307
|
|
|
288
|
-
Atomics.store(state, WRITE_INDEX, writePos)
|
|
308
|
+
Atomics.store(state, WRITE_INDEX, this.#writePos)
|
|
289
309
|
}
|
|
290
310
|
|
|
291
311
|
// Case 2: The writer has wrapped around. [ 0 ... W - R ... s ]
|
|
292
312
|
// The only free space is between W and R.
|
|
293
313
|
|
|
294
|
-
readPos = state[READ_INDEX] | 0
|
|
295
|
-
if (readPos - writePos < required) {
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return readPos - writePos >= required
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* "Uncorks" the stream by publishing the pending write position.
|
|
304
|
-
* This is called from a microtask to batch atomic stores.
|
|
305
|
-
*/
|
|
306
|
-
function _uncork() {
|
|
307
|
-
corked -= 1
|
|
308
|
-
if (corked === 0) {
|
|
309
|
-
_flush()
|
|
314
|
+
this.#readPos = state[READ_INDEX] | 0
|
|
315
|
+
if (this.#readPos - this.#writePos < required) {
|
|
316
|
+
this.#yield(0)
|
|
310
317
|
}
|
|
311
|
-
}
|
|
312
318
|
|
|
313
|
-
|
|
314
|
-
if (pending > 0) {
|
|
315
|
-
Atomics.store(state, WRITE_INDEX, writePos)
|
|
316
|
-
pending = 0
|
|
317
|
-
}
|
|
319
|
+
return this.#readPos - this.#writePos >= required
|
|
318
320
|
}
|
|
319
321
|
|
|
320
322
|
/**
|
|
321
323
|
* Performs the actual write into the buffer after space has been acquired.
|
|
322
324
|
*/
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
) {
|
|
328
|
-
const dataPos = writePos + 4
|
|
329
|
-
|
|
330
|
-
data.offset = dataPos
|
|
325
|
+
#write (dataCap , fn , opaque ) {
|
|
326
|
+
const dataPos = this.#writePos + 4
|
|
327
|
+
const data = this.#data
|
|
328
|
+
|
|
331
329
|
data.byteOffset = dataPos
|
|
332
|
-
data.length = dataCap
|
|
333
330
|
data.byteLength = dataCap
|
|
334
331
|
|
|
335
332
|
// The user-provided function writes the data and returns the final position.
|
|
@@ -347,56 +344,54 @@ export function writer(
|
|
|
347
344
|
throw new RangeError(`"fn" returned a number ${dataLen} that exceeds capacity ${dataCap}`)
|
|
348
345
|
}
|
|
349
346
|
|
|
347
|
+
const size = this.#size
|
|
350
348
|
if (dataPos + dataLen > size) {
|
|
351
349
|
// assertion
|
|
352
350
|
throw new Error(`Data position ${dataPos} with length ${dataLen} exceeds buffer size ${size}`)
|
|
353
351
|
}
|
|
354
352
|
|
|
355
|
-
const
|
|
353
|
+
const alignedLen = (dataLen + 3) & ~3
|
|
354
|
+
const nextPos = this.#writePos + 4 + alignedLen
|
|
356
355
|
|
|
357
356
|
if (nextPos + 4 > size) {
|
|
358
357
|
// assertion
|
|
359
358
|
throw new Error(`Write position ${nextPos} with next header exceeds buffer size ${size}`)
|
|
360
359
|
}
|
|
361
|
-
if (nextPos === readPos) {
|
|
360
|
+
if (nextPos === this.#readPos) {
|
|
362
361
|
// assertion
|
|
363
|
-
throw new Error(`Write position ${nextPos} cannot equal read position ${readPos}`)
|
|
362
|
+
throw new Error(`Write position ${nextPos} cannot equal read position ${this.#readPos}`)
|
|
364
363
|
}
|
|
365
364
|
|
|
366
365
|
// Write the actual length of the data into the 4-byte header.
|
|
367
|
-
|
|
368
|
-
writePos += 4 +
|
|
369
|
-
pending += 4 +
|
|
366
|
+
this.#int32[this.#writePos >> 2] = dataLen
|
|
367
|
+
this.#writePos += 4 + alignedLen
|
|
368
|
+
this.#pending += 4 + alignedLen
|
|
370
369
|
|
|
371
370
|
// This is the "corking" optimization. Instead of calling Atomics.store
|
|
372
371
|
// on every write, we batch them. We either write when a certain
|
|
373
372
|
// amount of data is pending (HWM_BYTES) or at the end of the current
|
|
374
373
|
// event loop tick. This drastically reduces atomic operation overhead.
|
|
375
|
-
if (pending >= HWM_BYTES) {
|
|
376
|
-
Atomics.store(state, WRITE_INDEX, writePos)
|
|
377
|
-
pending = 0
|
|
378
|
-
} else if (corked === 0) {
|
|
379
|
-
corked += 1
|
|
380
|
-
setImmediate(
|
|
374
|
+
if (this.#pending >= HWM_BYTES) {
|
|
375
|
+
Atomics.store(this.#state, WRITE_INDEX, this.#writePos)
|
|
376
|
+
this.#pending = 0
|
|
377
|
+
} else if (this.#corked === 0) {
|
|
378
|
+
this.#corked += 1
|
|
379
|
+
setImmediate(this.#uncorkBound)
|
|
381
380
|
}
|
|
382
381
|
}
|
|
383
382
|
|
|
384
383
|
/**
|
|
385
|
-
*
|
|
386
|
-
* wait until space is available.
|
|
384
|
+
* Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
|
|
387
385
|
* Writing more than "len" bytes in the callback will cause undefined behavior.
|
|
388
386
|
*/
|
|
389
|
-
|
|
390
|
-
len ,
|
|
391
|
-
fn ,
|
|
392
|
-
opaque ,
|
|
393
|
-
) {
|
|
387
|
+
writeSync (len , fn , opaque ) {
|
|
394
388
|
if (typeof len !== 'number') {
|
|
395
389
|
throw new TypeError('"len" must be a non-negative number')
|
|
396
390
|
}
|
|
397
391
|
if (len < 0) {
|
|
398
392
|
throw new RangeError(`"len" ${len} is negative`)
|
|
399
393
|
}
|
|
394
|
+
const size = this.#size
|
|
400
395
|
if (len >= 2 ** 31 || len > size - 8) {
|
|
401
396
|
throw new Error(`"len" ${len} exceeds maximum allowed size ${size - 8}`)
|
|
402
397
|
}
|
|
@@ -404,48 +399,53 @@ export function writer(
|
|
|
404
399
|
throw new TypeError('"fn" must be a function')
|
|
405
400
|
}
|
|
406
401
|
|
|
407
|
-
if (!
|
|
402
|
+
if (!this.#acquire(len)) {
|
|
408
403
|
const startTime = performance.now()
|
|
409
404
|
let yieldCount = 0
|
|
410
405
|
let yieldTime = 0
|
|
411
|
-
for (let n = 0; !
|
|
406
|
+
for (let n = 0; !this.#acquire(len); n++) {
|
|
412
407
|
if (performance.now() - startTime > 60e3) {
|
|
413
408
|
throw new Error('Timeout while waiting for space in the buffer')
|
|
414
409
|
}
|
|
415
|
-
|
|
410
|
+
this.#yield(3)
|
|
416
411
|
yieldCount += 1
|
|
417
412
|
yieldTime += 3
|
|
418
413
|
}
|
|
419
414
|
const elapsedTime = performance.now() - startTime
|
|
420
|
-
logger?.warn(
|
|
421
|
-
{
|
|
415
|
+
this.#logger?.warn(
|
|
416
|
+
{
|
|
417
|
+
yieldLength: len,
|
|
418
|
+
readPos: this.#readPos,
|
|
419
|
+
writePos: this.#writePos,
|
|
420
|
+
elapsedTime,
|
|
421
|
+
yieldCount,
|
|
422
|
+
yieldTime,
|
|
423
|
+
},
|
|
422
424
|
'yielded',
|
|
423
425
|
)
|
|
424
426
|
}
|
|
425
427
|
|
|
426
|
-
|
|
428
|
+
this.#write(len, fn, opaque)
|
|
427
429
|
|
|
428
|
-
if (writePos === readPos) {
|
|
429
|
-
throw new Error(
|
|
430
|
+
if (this.#writePos === this.#readPos) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`Write position ${this.#writePos} cannot equal read position ${this.#readPos}`,
|
|
433
|
+
)
|
|
430
434
|
}
|
|
431
435
|
}
|
|
432
436
|
|
|
433
437
|
/**
|
|
434
|
-
*
|
|
438
|
+
* Non-blocking write attempt. Returns `false` if the buffer is full.
|
|
435
439
|
* Writing more than "len" bytes in the callback will cause undefined behavior.
|
|
436
440
|
*/
|
|
437
|
-
|
|
438
|
-
function tryWrite (
|
|
439
|
-
len ,
|
|
440
|
-
fn ,
|
|
441
|
-
opaque ,
|
|
442
|
-
) {
|
|
441
|
+
tryWrite (len , fn , opaque ) {
|
|
443
442
|
if (typeof len !== 'number') {
|
|
444
443
|
throw new TypeError('"len" must be a non-negative number')
|
|
445
444
|
}
|
|
446
445
|
if (len < 0) {
|
|
447
446
|
throw new RangeError(`"len" ${len} is negative`)
|
|
448
447
|
}
|
|
448
|
+
const size = this.#size
|
|
449
449
|
if (len >= 2 ** 31 || len > size - 8) {
|
|
450
450
|
throw new Error(`"len" ${len} exceeds maximum allowed size ${size - 8}`)
|
|
451
451
|
}
|
|
@@ -453,29 +453,54 @@ export function writer(
|
|
|
453
453
|
throw new TypeError('"fn" must be a function')
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
if (!
|
|
456
|
+
if (!this.#acquire(len)) {
|
|
457
457
|
return false
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
|
|
460
|
+
this.#write(len, fn, opaque)
|
|
461
461
|
|
|
462
|
-
if (writePos === readPos) {
|
|
463
|
-
throw new Error(
|
|
462
|
+
if (this.#writePos === this.#readPos) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`Write position ${this.#writePos} cannot equal read position ${this.#readPos}`,
|
|
465
|
+
)
|
|
464
466
|
}
|
|
465
467
|
|
|
466
468
|
return true
|
|
467
469
|
}
|
|
468
470
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Batches multiple writes within the callback. The write pointer is only
|
|
473
|
+
* published to the reader when cork returns, reducing atomic operation overhead.
|
|
474
|
+
*/
|
|
475
|
+
cork (callback ) {
|
|
476
|
+
this.#corked += 1
|
|
471
477
|
if (callback != null) {
|
|
472
478
|
try {
|
|
473
479
|
return callback()
|
|
474
480
|
} finally {
|
|
475
|
-
|
|
481
|
+
this.uncork()
|
|
476
482
|
}
|
|
477
483
|
}
|
|
478
484
|
}
|
|
479
485
|
|
|
480
|
-
|
|
486
|
+
/**
|
|
487
|
+
* Publishes the pending write position to the reader.
|
|
488
|
+
*/
|
|
489
|
+
uncork() {
|
|
490
|
+
if (this.#corked === 0) {
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
this.#corked -= 1
|
|
495
|
+
if (this.#corked === 0) {
|
|
496
|
+
this.flushSync()
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
flushSync() {
|
|
501
|
+
if (this.#pending > 0) {
|
|
502
|
+
Atomics.store(this.#state, WRITE_INDEX, this.#writePos)
|
|
503
|
+
this.#pending = 0
|
|
504
|
+
}
|
|
505
|
+
}
|
|
481
506
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/shared",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -26,6 +26,5 @@
|
|
|
26
26
|
"oxlint-tsgolint": "^0.13.0",
|
|
27
27
|
"rimraf": "^6.1.3",
|
|
28
28
|
"typescript": "^5.9.3"
|
|
29
|
-
}
|
|
30
|
-
"gitHead": "3648df9e97a19a6ebdf497afb1845a01b5301460"
|
|
29
|
+
}
|
|
31
30
|
}
|