Skip to content

[BUG] SSH terminal frame delivery jitter in Docker deployment (ssh2 native crypto not compiled) #767

Description

@Qiuser

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

  1. Deploy Termix using the official Docker image
  2. Verify native crypto is missing:
    docker exec <container> find /app/node_modules/ssh2 -name "*.node"
    # Expected: no output (sshcrypto.node missing)
  3. Add an SSH host on the local network (RTT < 5ms), open terminal, hold Enter
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions