@torrent-tv/proxy 2.9.69 → 2.9.71
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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/server.js +8 -2
- package/services/data-channel-handler.js +496 -469
- package/services/torrent-worker/channel.js +222 -0
- package/services/torrent-worker/client.js +264 -0
- package/services/torrent-worker/pool-adapter.js +171 -0
- package/services/torrent-worker/protocol.js +103 -0
- package/services/torrent-worker/worker.js +255 -0
- package/utils/perf.js +121 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Message protocol between the main thread and the torrent worker.
|
|
3
|
+
*
|
|
4
|
+
* **Why the torrent gets its own thread.** Profiling the live proxy during a
|
|
5
|
+
* seek (2026-08-02) found the main thread ~85% busy, and busy with WebTorrent:
|
|
6
|
+
* buffer concatenation in `uint8-util` ~15%, `_updateWire` and its wrapper ~9%,
|
|
7
|
+
* garbage collection ~5% — and no piece hashing anywhere, which had been the
|
|
8
|
+
* standing assumption. Serving a segment shares that thread, so reading an
|
|
9
|
+
* already-finished 10 MB file off SSD took 12-23 s while handing it to the
|
|
10
|
+
* channel took 125 ms. Two unrelated jobs — one talking to fifty peers in small
|
|
11
|
+
* bursts, one owing a viewer a prompt answer — were queued behind each other
|
|
12
|
+
* for no reason but sharing a thread. Three of the four cores sat idle.
|
|
13
|
+
*
|
|
14
|
+
* **Why this shape, measured rather than assumed** (see the numbers below):
|
|
15
|
+
*
|
|
16
|
+
* | approach | 10 MB |
|
|
17
|
+
* |--------------------------------------------|---------|
|
|
18
|
+
* | structured clone (copying) | 37 ms |
|
|
19
|
+
* | transferable `ReadableStream` (the standard)| 104 ms |
|
|
20
|
+
* | **this: transfer inside a stream wrapper** | **4.8 ms** |
|
|
21
|
+
* | one whole buffer, no chunking | 0.49 ms |
|
|
22
|
+
*
|
|
23
|
+
* The standard transferable stream is the obvious choice and the wrong one: it
|
|
24
|
+
* negotiates every chunk across the boundary and costs 22x this design. Copying
|
|
25
|
+
* is worse still. So the worker transfers ownership of large buffers, and the
|
|
26
|
+
* main thread wraps the arriving buffers in an ordinary `ReadableStream` —
|
|
27
|
+
* standard interface outside, ownership transfer inside. Callers cannot tell
|
|
28
|
+
* the difference; the cost is a tenth of a percent of a segment's playing time.
|
|
29
|
+
*
|
|
30
|
+
* Chunk size follows from the same measurements: a round trip costs ~100 µs, so
|
|
31
|
+
* 64 KB chunks would spend 13 ms per segment on overhead against 0.5 ms sent
|
|
32
|
+
* whole. {@link STREAM_CHUNK_BYTES} of 1 MB puts a 10 MB segment at ten
|
|
33
|
+
* messages — about 1 ms — while still allowing a read to be cancelled promptly
|
|
34
|
+
* and keeping peak memory bounded.
|
|
35
|
+
*
|
|
36
|
+
* Torrent objects cannot cross a thread boundary, so the main thread names them
|
|
37
|
+
* by `sourceKey` (the identifier the registry already uses) and the worker owns
|
|
38
|
+
* the objects.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Commands sent main thread → worker.
|
|
43
|
+
*
|
|
44
|
+
* @readonly
|
|
45
|
+
* @enum {string}
|
|
46
|
+
*/
|
|
47
|
+
export const Command = {
|
|
48
|
+
/** Add (or join) a torrent; resolves when metadata is ready. */
|
|
49
|
+
ADD_SOURCE: "add-source",
|
|
50
|
+
/** Claim a file for reading, so it is not evicted while in use. */
|
|
51
|
+
ACQUIRE_FILE: "acquire-file",
|
|
52
|
+
/** Drop a claim; the worker applies its own idle-removal policy. */
|
|
53
|
+
RELEASE_FILE: "release-file",
|
|
54
|
+
/** File list and metadata for a source. */
|
|
55
|
+
LIST_FILES: "list-files",
|
|
56
|
+
/** Live download figures for the progress display. */
|
|
57
|
+
FILE_STATS: "file-stats",
|
|
58
|
+
/** Reorder piece selection around a read position (seek prioritisation). */
|
|
59
|
+
PRIORITIZE: "prioritize",
|
|
60
|
+
/** Read a byte range; the body arrives as CHUNK messages. */
|
|
61
|
+
READ_RANGE: "read-range",
|
|
62
|
+
/** Abandon an in-flight READ_RANGE (viewer gone, seek superseded). */
|
|
63
|
+
CANCEL_READ: "cancel-read",
|
|
64
|
+
/** Pre-fetch the head and tail a codec probe needs. */
|
|
65
|
+
PREFETCH_EDGES: "prefetch-edges",
|
|
66
|
+
/** Shut the client down, optionally deleting downloaded data. */
|
|
67
|
+
DESTROY_ALL: "destroy-all"
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Messages sent worker → main thread.
|
|
72
|
+
*
|
|
73
|
+
* @readonly
|
|
74
|
+
* @enum {string}
|
|
75
|
+
*/
|
|
76
|
+
export const Event = {
|
|
77
|
+
/** A command completed; carries its result. */
|
|
78
|
+
RESULT: "result",
|
|
79
|
+
/** A command failed; carries a message (`Error`s do not survive the boundary). */
|
|
80
|
+
ERROR: "error",
|
|
81
|
+
/** One piece of a READ_RANGE body; its bytes are transferred, never copied. */
|
|
82
|
+
CHUNK: "chunk",
|
|
83
|
+
/** A READ_RANGE ended; no further CHUNKs bear that request id. */
|
|
84
|
+
READ_END: "read-end",
|
|
85
|
+
/** The main thread consumed a chunk — see {@link STREAM_HIGH_WATER_CHUNKS}. */
|
|
86
|
+
CHUNK_ACK: "chunk-ack",
|
|
87
|
+
/** A log line, so worker output reaches the same place as everything else. */
|
|
88
|
+
LOG: "log"
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Bytes per CHUNK message. See the file header for why 1 MB.
|
|
93
|
+
*/
|
|
94
|
+
export const STREAM_CHUNK_BYTES = 1024 * 1024;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How many chunks may be in flight before the worker waits for an acknowledgement.
|
|
98
|
+
*
|
|
99
|
+
* Unbounded sending would let a fast disk outrun the channel and rebuild, in the
|
|
100
|
+
* message queue, exactly the memory the transfers were saving. Two in flight
|
|
101
|
+
* keeps the pipe full without letting it grow.
|
|
102
|
+
*/
|
|
103
|
+
export const STREAM_HIGH_WATER_CHUNKS = 2;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The torrent thread: WebTorrent and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Everything that made the main thread unresponsive lives here now — peer
|
|
5
|
+
* connections, buffer concatenation, piece bookkeeping, garbage collection from
|
|
6
|
+
* all of it. The main thread keeps only what owes a viewer a prompt answer.
|
|
7
|
+
*
|
|
8
|
+
* This file deliberately holds no HTTP, no session logic and no knowledge of
|
|
9
|
+
* HLS: it answers the commands in `protocol.js` and streams bytes back. That
|
|
10
|
+
* boundary is what keeps the split honest — anything added here will compete
|
|
11
|
+
* with the torrent for this thread, which is exactly the problem being solved.
|
|
12
|
+
*
|
|
13
|
+
* The existing `TorrentPool` is reused wholesale rather than reimplemented. It
|
|
14
|
+
* already carries the parts that took field failures to get right — refcounted
|
|
15
|
+
* file claims, idle removal, the global disk cap with LRU eviction, seek-aware
|
|
16
|
+
* piece prioritisation, adaptive upload — and none of that changes by moving
|
|
17
|
+
* threads.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
21
|
+
import { TorrentPool } from "../torrent-pool.js";
|
|
22
|
+
import { createSendStream } from "./channel.js";
|
|
23
|
+
import { Command, Event, STREAM_CHUNK_BYTES } from "./protocol.js";
|
|
24
|
+
|
|
25
|
+
const pool = new TorrentPool({ maxDiskBytes: workerData?.maxDiskBytes });
|
|
26
|
+
|
|
27
|
+
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|
|
28
|
+
const torrentsByKey = new Map();
|
|
29
|
+
/** File-claim release callbacks, keyed `${sourceKey}:${fileIndex}`. */
|
|
30
|
+
const releaseByClaim = new Map();
|
|
31
|
+
/** In-flight reads, so a cancel can stop one mid-body. */
|
|
32
|
+
const readsById = new Map();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Forward a log line to the main thread, so worker output is not lost or
|
|
36
|
+
* interleaved separately from everything else.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} message
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
function log(message) {
|
|
42
|
+
parentPort.postMessage({ type: Event.LOG, message });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The torrent for a sourceKey, or throw a message the caller can surface.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} sourceKey
|
|
49
|
+
* @returns {import("webtorrent").Torrent}
|
|
50
|
+
*/
|
|
51
|
+
function requireTorrent(sourceKey) {
|
|
52
|
+
const torrent = torrentsByKey.get(sourceKey);
|
|
53
|
+
if (!torrent) {
|
|
54
|
+
throw new Error(`Unknown source ${sourceKey}.`);
|
|
55
|
+
}
|
|
56
|
+
return torrent;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Stream a byte range back as CHUNK messages.
|
|
61
|
+
*
|
|
62
|
+
* Reads through WebTorrent's own read stream — which serves already-downloaded
|
|
63
|
+
* pieces from disk and waits for the rest — and forwards it in
|
|
64
|
+
* {@link STREAM_CHUNK_BYTES} pieces, transferring ownership of each so nothing
|
|
65
|
+
* is copied across the boundary. `createSendStream` applies the backpressure,
|
|
66
|
+
* so a fast disk cannot outrun the main thread and rebuild the queue in memory.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} params
|
|
69
|
+
* @param {number} params.id - Request id; CHUNK/READ_END carry it.
|
|
70
|
+
* @param {string} params.sourceKey
|
|
71
|
+
* @param {number} params.fileIndex
|
|
72
|
+
* @param {number | null} params.start - Inclusive, or null for the whole file.
|
|
73
|
+
* @param {number | null} params.end - Inclusive.
|
|
74
|
+
* @returns {Promise<void>}
|
|
75
|
+
*/
|
|
76
|
+
async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
77
|
+
const torrent = requireTorrent(sourceKey);
|
|
78
|
+
const file = torrent.files?.[fileIndex];
|
|
79
|
+
if (!file) {
|
|
80
|
+
throw new Error(`File ${fileIndex} not found in ${sourceKey}.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sender = createSendStream({ port: parentPort, requestId: id });
|
|
84
|
+
readsById.set(id, sender);
|
|
85
|
+
|
|
86
|
+
const options = start === null || start === undefined ? {} : { start, end };
|
|
87
|
+
const source = file.createReadStream(options);
|
|
88
|
+
|
|
89
|
+
// Coalesce WebTorrent's own chunking (piece-sized, often much smaller) up to
|
|
90
|
+
// our chunk size: a round trip costs ~100 µs, so sending its native pieces
|
|
91
|
+
// straight through would multiply the crossings for no benefit.
|
|
92
|
+
let pendingParts = [];
|
|
93
|
+
let pendingBytes = 0;
|
|
94
|
+
|
|
95
|
+
const flush = async () => {
|
|
96
|
+
if (pendingBytes === 0) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const merged = pendingParts.length === 1 ? pendingParts[0] : Buffer.concat(pendingParts, pendingBytes);
|
|
100
|
+
pendingParts = [];
|
|
101
|
+
pendingBytes = 0;
|
|
102
|
+
await sender.send(merged);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
for await (const part of source) {
|
|
107
|
+
if (sender.isCancelled()) {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
pendingParts.push(part);
|
|
111
|
+
pendingBytes += part.length;
|
|
112
|
+
if (pendingBytes >= STREAM_CHUNK_BYTES) {
|
|
113
|
+
await flush();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!sender.isCancelled()) {
|
|
117
|
+
await flush();
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
readsById.delete(id);
|
|
121
|
+
sender.end();
|
|
122
|
+
// A cancelled read must stop the underlying torrent stream too, or the
|
|
123
|
+
// pieces keep being fetched for a viewer who has gone.
|
|
124
|
+
if (typeof source.destroy === "function") {
|
|
125
|
+
source.destroy();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run one command and return its result.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} command
|
|
134
|
+
* @param {object} params
|
|
135
|
+
* @param {number} id
|
|
136
|
+
* @returns {Promise<unknown>}
|
|
137
|
+
*/
|
|
138
|
+
async function runCommand(command, params, id) {
|
|
139
|
+
switch (command) {
|
|
140
|
+
case Command.ADD_SOURCE: {
|
|
141
|
+
const torrent = await pool.getTorrent(params.sourceType, params.source);
|
|
142
|
+
torrentsByKey.set(params.sourceKey, torrent);
|
|
143
|
+
return {
|
|
144
|
+
infoHash: torrent.infoHash,
|
|
145
|
+
name: torrent.name,
|
|
146
|
+
// Files cross as plain data; the objects stay here.
|
|
147
|
+
files: (torrent.files ?? []).map((file, index) => ({
|
|
148
|
+
index,
|
|
149
|
+
name: file.name,
|
|
150
|
+
path: file.path,
|
|
151
|
+
length: file.length
|
|
152
|
+
}))
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case Command.LIST_FILES: {
|
|
157
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
158
|
+
return (torrent.files ?? []).map((file, index) => ({
|
|
159
|
+
index,
|
|
160
|
+
name: file.name,
|
|
161
|
+
path: file.path,
|
|
162
|
+
length: file.length
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case Command.ACQUIRE_FILE: {
|
|
167
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
168
|
+
const claimKey = `${params.sourceKey}:${params.fileIndex}`;
|
|
169
|
+
// One claim per key; a second acquire without release would leak the
|
|
170
|
+
// first release callback and pin the file forever.
|
|
171
|
+
if (!releaseByClaim.has(claimKey)) {
|
|
172
|
+
releaseByClaim.set(claimKey, pool.acquireFile(torrent, params.fileIndex));
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case Command.RELEASE_FILE: {
|
|
178
|
+
const claimKey = `${params.sourceKey}:${params.fileIndex}`;
|
|
179
|
+
const release = releaseByClaim.get(claimKey);
|
|
180
|
+
if (release) {
|
|
181
|
+
releaseByClaim.delete(claimKey);
|
|
182
|
+
release();
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case Command.FILE_STATS: {
|
|
188
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
189
|
+
return pool.getFileStats(torrent, params.fileIndex, {
|
|
190
|
+
resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case Command.PRIORITIZE: {
|
|
195
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
196
|
+
pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case Command.PREFETCH_EDGES: {
|
|
201
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
202
|
+
return pool.prefetchFileEdges(torrent, params.fileIndex, params.headBytes, params.tailBytes, params.timeoutMs);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case Command.READ_RANGE: {
|
|
206
|
+
// Streams its own reply; the caller's promise resolves once the body has
|
|
207
|
+
// been fully sent, which is what lets the client await completion.
|
|
208
|
+
await streamRange({
|
|
209
|
+
id,
|
|
210
|
+
sourceKey: params.sourceKey,
|
|
211
|
+
fileIndex: params.fileIndex,
|
|
212
|
+
start: params.start ?? null,
|
|
213
|
+
end: params.end ?? null
|
|
214
|
+
});
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case Command.CANCEL_READ: {
|
|
219
|
+
readsById.get(params.readId)?.cancel();
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case Command.DESTROY_ALL: {
|
|
224
|
+
for (const [, release] of releaseByClaim) {
|
|
225
|
+
release();
|
|
226
|
+
}
|
|
227
|
+
releaseByClaim.clear();
|
|
228
|
+
torrentsByKey.clear();
|
|
229
|
+
await pool.destroyAll();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
default:
|
|
234
|
+
throw new Error(`Unknown torrent-worker command: ${command}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
parentPort.on("message", async (message) => {
|
|
239
|
+
// Chunk acknowledgements are not commands — they release backpressure on an
|
|
240
|
+
// in-flight read.
|
|
241
|
+
if (message?.type === Event.CHUNK_ACK) {
|
|
242
|
+
readsById.get(message.id)?.ack();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { command, id, params } = message ?? {};
|
|
247
|
+
try {
|
|
248
|
+
const result = await runCommand(command, params ?? {}, id);
|
|
249
|
+
parentPort.postMessage({ type: Event.RESULT, id, result });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
parentPort.postMessage({ type: Event.ERROR, id, error: error?.message ?? String(error) });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
log("torrent worker started");
|
package/utils/perf.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Runtime performance instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* Exists because a field seek took minutes and every explanation offered for it
|
|
5
|
+
* — encoder too slow, torrent too slow, channel too slow, event loop starved —
|
|
6
|
+
* was a guess. The numbers that would have settled it were not being recorded.
|
|
7
|
+
* This records them.
|
|
8
|
+
*
|
|
9
|
+
* Two things are measured:
|
|
10
|
+
*
|
|
11
|
+
* - **Event loop delay** (`perf_hooks.monitorEventLoopDelay`). If synchronous
|
|
12
|
+
* work (torrent piece hashing, large buffer handling) blocks the loop, every
|
|
13
|
+
* read and every send waits behind it, and the symptom looks exactly like a
|
|
14
|
+
* slow network. The histogram distinguishes the two beyond argument.
|
|
15
|
+
* - **Named operation timings**, so a slow transfer can be attributed to the
|
|
16
|
+
* step that actually consumed the time rather than to the whole.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately cheap: a histogram sampled every 20 ms, and plain arithmetic per
|
|
19
|
+
* operation. No trace files, no profiler — `--trace-events-enabled` or
|
|
20
|
+
* `--cpu-prof` remain available for a deeper look when these figures point
|
|
21
|
+
* somewhere specific.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
|
|
25
|
+
|
|
26
|
+
const NANOSECONDS_PER_MILLISECOND = 1e6;
|
|
27
|
+
// Sampling interval for the loop-delay histogram. 20 ms is fine enough to catch
|
|
28
|
+
// the stalls that matter (tens of ms and up) without measurable overhead.
|
|
29
|
+
const LOOP_SAMPLE_INTERVAL_MS = 20;
|
|
30
|
+
|
|
31
|
+
const loopDelay = monitorEventLoopDelay({ resolution: LOOP_SAMPLE_INTERVAL_MS });
|
|
32
|
+
loopDelay.enable();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Event-loop delay since the last {@link resetEventLoopDelay}, in milliseconds.
|
|
36
|
+
*
|
|
37
|
+
* `mean` is the everyday cost; `max` and `p99` are what a single blocking spell
|
|
38
|
+
* does to whatever was waiting. A transfer that looks network-bound but shows a
|
|
39
|
+
* large `max` here was not network-bound at all.
|
|
40
|
+
*
|
|
41
|
+
* @returns {{ meanMs: number, p99Ms: number, maxMs: number }}
|
|
42
|
+
*/
|
|
43
|
+
export function eventLoopDelay() {
|
|
44
|
+
return {
|
|
45
|
+
meanMs: loopDelay.mean / NANOSECONDS_PER_MILLISECOND,
|
|
46
|
+
p99Ms: loopDelay.percentile(99) / NANOSECONDS_PER_MILLISECOND,
|
|
47
|
+
maxMs: loopDelay.max / NANOSECONDS_PER_MILLISECOND
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start a fresh measurement window for the loop-delay histogram, so a reported
|
|
53
|
+
* figure describes one operation rather than the process's whole lifetime.
|
|
54
|
+
*
|
|
55
|
+
* @returns {void}
|
|
56
|
+
*/
|
|
57
|
+
export function resetEventLoopDelay() {
|
|
58
|
+
loopDelay.reset();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Accumulates how long the parts of one operation took.
|
|
63
|
+
*
|
|
64
|
+
* Usage: `mark()` after each step; `summary()` renders `step=12.3ms` pairs in
|
|
65
|
+
* the order they were marked.
|
|
66
|
+
*/
|
|
67
|
+
export class OperationTimer {
|
|
68
|
+
#startedAt;
|
|
69
|
+
#lastMarkAt;
|
|
70
|
+
#marks;
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
this.#startedAt = performance.now();
|
|
74
|
+
this.#lastMarkAt = this.#startedAt;
|
|
75
|
+
this.#marks = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Record the time since the previous mark under `name`.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} name
|
|
82
|
+
* @returns {number} Milliseconds since the previous mark.
|
|
83
|
+
*/
|
|
84
|
+
mark(name) {
|
|
85
|
+
const now = performance.now();
|
|
86
|
+
const elapsed = now - this.#lastMarkAt;
|
|
87
|
+
this.#lastMarkAt = now;
|
|
88
|
+
this.#marks.push([name, elapsed]);
|
|
89
|
+
return elapsed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Add a figure measured elsewhere (a running total, a count) so it appears in
|
|
94
|
+
* the same line as the timings.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} name
|
|
97
|
+
* @param {number} value
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
add(name, value) {
|
|
101
|
+
this.#marks.push([name, value]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Total elapsed time since construction, in milliseconds.
|
|
106
|
+
*
|
|
107
|
+
* @returns {number}
|
|
108
|
+
*/
|
|
109
|
+
totalMs() {
|
|
110
|
+
return performance.now() - this.#startedAt;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* All marks as `name=12.3ms` pairs, in order.
|
|
115
|
+
*
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
summary() {
|
|
119
|
+
return this.#marks.map(([name, value]) => `${name}=${value.toFixed(1)}ms`).join(" ");
|
|
120
|
+
}
|
|
121
|
+
}
|