Description
When Termix is deployed via Docker, the SSH terminal exhibits noticeable frame delivery jitter. Holding Enter produces visibly uneven line output — smooth for a few seconds, then a brief pause, repeating cyclically. It feels like connecting to a high-latency server, even when the target is a low-latency LAN host (RTT < 5ms).
Bare-metal deployment (same code, same machine, no Docker) is perfectly smooth.
Root Cause
The Dockerfile uses npm ci --ignore-scripts which skips all native addon compilation. The subsequent npm rebuild only rebuilds better-sqlite3 and bcryptjs, missing ssh2.
This causes ssh2's crypto layer to fall back to a pure JavaScript implementation (the native ssh2/lib/protocol/crypto/build/Release/sshcrypto.node is absent). Under high-frequency SSH data streams, the JS crypto blocks the Node.js event loop, causing WebSocket messages to be delivered in bursts rather than evenly.
Fix
In docker/Dockerfile, add ssh2 to the npm rebuild command:
RUN npm ci --omit=dev --ignore-scripts && \
- npm rebuild better-sqlite3 bcryptjs && \
+ npm rebuild better-sqlite3 bcryptjs ssh2 && \
npm cache clean --force
Quantified Evidence
Using the benchmark script below, testing against the same SSH target from inside the Docker container vs bare-metal:
| Metric |
Docker (no native crypto) |
Fixed Docker / Bare-metal |
| Messages (10s) |
858 |
1136–1216 |
| p95 latency |
60ms |
21ms |
| Frame drops (>50ms) |
17.4% |
0.1% |
Steps to Reproduce
- Deploy Termix using the official Docker image
- Verify native crypto is missing:
docker exec <container> find /app/node_modules/ssh2 -name "*.node"
# Expected: no output (sshcrypto.node missing)
- Add an SSH host on the local network (RTT < 5ms), open terminal, hold Enter
- Run the benchmark script (below) to quantify the jitter
Benchmark Script
Save as ssh-stream-bench.mjs and run in a directory with the ssh2 package available:
ssh-stream-bench.mjs (click to expand)
import { Client } from 'ssh2';
const HOST = process.argv[2];
const PORT = parseInt(process.argv[3]) || 22;
const USERNAME = process.argv[4];
const PASSWORD = process.argv[5];
const DURATION = (parseInt(process.argv[6]) || 10) * 1000;
if (!HOST || !USERNAME || !PASSWORD) {
console.error('Usage: node ssh-stream-bench.mjs <host> <port> <user> <password> [duration_s]');
process.exit(1);
}
console.log(`SSH Stream Benchmark — ${USERNAME}@${HOST}:${PORT} — ${DURATION/1000}s — Node ${process.version}`);
const gaps = [];
let lastTime = 0, count = 0;
const conn = new Client();
conn.on('ready', () => {
conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => {
if (err) { console.error(err.message); process.exit(1); }
setTimeout(() => {
const iv = setInterval(() => stream.write('\n'), 20);
setTimeout(() => { clearInterval(iv); stream.end(); conn.end(); }, DURATION);
}, 1500);
stream.on('data', () => {
const now = performance.now();
count++;
if (lastTime > 0) gaps.push(now - lastTime);
lastTime = now;
});
});
});
conn.on('close', () => {
const s = [...gaps].sort((a, b) => a - b);
const sum = s.reduce((a, b) => a + b, 0), mean = sum / s.length;
const sd = Math.sqrt(s.reduce((x, g) => x + (g - mean) ** 2, 0) / s.length);
const p95 = s[Math.floor(s.length * 0.95)];
const drops = s.filter(x => x > 50).length;
console.log(JSON.stringify({
msgs: count, p95_ms: +p95.toFixed(2),
drops_gt50ms: drops, drops_pct: +((drops / s.length) * 100).toFixed(1)
}, null, 2));
process.exit(0);
});
conn.connect({ host: HOST, port: PORT, username: USERNAME, password: PASSWORD });
Compare:
# Bare-metal baseline (smooth):
node ssh-stream-bench.mjs <ssh-host> 22 <user> <pass> 10
# Inside Docker container (stuttery):
docker exec <container> node ssh-stream-bench.mjs <ssh-host> 22 <user> <pass> 10
# Fixed container (should match baseline):
docker exec <fixed-container> node ssh-stream-bench.mjs <ssh-host> 22 <user> <pass> 10
Environment
- Termix version: 2.3.1
- Docker image:
ghcr.io/lukegus/termix:latest
- Host OS: Ubuntu 25.10, x86_64
- Node.js: v22 / v24 (reproducible on both)
- SSH target: LAN Linux host
Description
When Termix is deployed via Docker, the SSH terminal exhibits noticeable frame delivery jitter. Holding Enter produces visibly uneven line output — smooth for a few seconds, then a brief pause, repeating cyclically. It feels like connecting to a high-latency server, even when the target is a low-latency LAN host (RTT < 5ms).
Bare-metal deployment (same code, same machine, no Docker) is perfectly smooth.
Root Cause
The Dockerfile uses
npm ci --ignore-scriptswhich skips all native addon compilation. The subsequentnpm rebuildonly rebuildsbetter-sqlite3andbcryptjs, missingssh2.This causes ssh2's crypto layer to fall back to a pure JavaScript implementation (the native
ssh2/lib/protocol/crypto/build/Release/sshcrypto.nodeis absent). Under high-frequency SSH data streams, the JS crypto blocks the Node.js event loop, causing WebSocket messages to be delivered in bursts rather than evenly.Fix
In
docker/Dockerfile, addssh2to thenpm rebuildcommand:Quantified Evidence
Using the benchmark script below, testing against the same SSH target from inside the Docker container vs bare-metal:
Steps to Reproduce
Benchmark Script
Save as
ssh-stream-bench.mjsand run in a directory with thessh2package available:ssh-stream-bench.mjs (click to expand)
Compare:
Environment
ghcr.io/lukegus/termix:latest