Profiling at the memory high-water mark - not at idle, which is what every earlier
round measured - found the largest single retainer in every snapshot was one V8
Context holding a 16.00 MiB ArrayBuffer. Expanding it through the dominator tree
named it: ssh2's crypto module, whose POLY1305_WASM_MODULE instantiates a
WebAssembly memory for ChaCha20-Poly1305 as soon as it is required.
Homarr has no SSH feature and does not depend on ssh2. The chain is
dockerode -> docker-modem -> ssh2, and docker-modem/lib/modem.js requires './ssh'
at module scope while using it in exactly one place, already guarded by
`protocol === 'ssh'`. A pnpm patch moves the require inside that branch. SSH-based
Docker hosts still work - require() is cached, so they pay it once - and every
socket-based install, which is everyone else, never loads it.
Measured over four alternated runs on a restored real backup, external memory:
before after
board loaded 25.4, 25.1 8.3, 11.2
nav-01 28.5, 25.4 10.1, 8.6
multi-tab 31.1, 35.8 13.0, 10.0
post-soak 21.4, 21.5 5.5, 7.2
Consistently -15 to -18 MiB, ranges non-overlapping, and 30 fewer files loaded
(1470 -> 1440).
Peak RSS does NOT improve, and that is worth stating plainly: V8 sizes its heap
against available headroom, so freeing native memory lets it grow further before
collecting. What changes is the composition - a fixed 16 MiB of unusable WASM
buffer becomes headroom the app can actually use, which matters under a container
memory limit but does not move the peak number.
Also adds the instruments this needed. A heap snapshot can never explain a peak,
because V8 runs a full GC before serialising one and reports only survivors:
- memory-probe.cjs gains in-process allocation sampling, on-demand snapshots via
v8.writeHeapSnapshot, an smaps endpoint, and require-graph recording.
- analyze-allocations.mjs diffs two stages to isolate what one stage allocated.
- analyze-smaps-stages.mjs splits each stage's RSS into JS heap, JIT, native and
file-backed pages.
- analyze-heap-dominators.mjs can now expand the largest retainer, which is what
identified ssh2. A plain graph walk escapes through __proto__ and is useless.
One trap recorded in the findings: taking the peak snapshot over CDP delivers it as
JS string chunks and added ~134 MiB of ArrayBuffers to the process being measured,
producing a fake 693 MiB reading. v8.writeHeapSnapshot streams from C++ instead, and
the probe now reports its own observerCostMiB so the cost of looking is visible.
28 lines
1.3 KiB
Diff
28 lines
1.3 KiB
Diff
diff --git a/lib/modem.js b/lib/modem.js
|
|
index b45a798d63647d55e0193fd7e82c2e36785d9b5f..d2dbf42969fc0ebf39b30c1a27373c33c2d20333 100644
|
|
--- a/lib/modem.js
|
|
+++ b/lib/modem.js
|
|
@@ -3,7 +3,6 @@ var querystring = require('querystring'),
|
|
fs = require('fs'),
|
|
path = require('path'),
|
|
url = require('url'),
|
|
- ssh = require('./ssh'),
|
|
HttpDuplex = require('./http_duplex'),
|
|
debug = require('debug')('modem'),
|
|
utils = require('./utils'),
|
|
@@ -263,8 +262,13 @@ Modem.prototype.buildRequest = function (options, context, data, callback) {
|
|
var connectionTimeoutTimer;
|
|
var finished = false;
|
|
|
|
+ // Loaded here rather than at module scope: './ssh' pulls in ssh2, whose crypto module
|
|
+ // instantiates a ~16 MiB WebAssembly memory for ChaCha20-Poly1305 as soon as it is
|
|
+ // required. Measured at +14 MiB RSS for a require alone, paid by every Homarr install
|
|
+ // even though Docker is reached over a socket and this branch never runs. require() is
|
|
+ // cached, so SSH connections pay it once and behave exactly as before.
|
|
var opts = self.protocol === 'ssh' ? Object.assign(options, {
|
|
- agent: ssh(Object.assign({}, self.sshOptions, {
|
|
+ agent: require('./ssh')(Object.assign({}, self.sshOptions, {
|
|
'host': self.host,
|
|
'port': self.port,
|
|
'username': self.username,
|