fix: stop a large diff and a second SSH error from taking the app down

Two failure modes that only show up under conditions the tests never reached.

renderDiff built one span per diff line with no bound. A regenerated lock file
is an ordinary change: 50,000 lines produce 4 MB of markup and 50,000 elements
that then have to be parsed and laid out inside the full shell replacement, and
200,000 lines produce 16 MB. The rendered view now stops at 2,000 lines and says
how many were left out; ui.diff keeps the whole change, so Copy diff, the editor
and hunk staging are unaffected. The line scan also runs once now instead of
three times.

withClient registered the connection error handler with once(). A connection
that fails and then emits a second error while it is being torn down - a reset
during client.end() is the ordinary case - leaves that event unhandled, and an
unhandled 'error' on an EventEmitter reaches the uncaughtException handler,
which calls app.exit(1). The handler stays attached and ignores anything after
the first failure.

Both are covered by tests that were confirmed to fail without the fix, together
with the SSH paths that had none: host key mismatch reporting, the trusted
fingerprint requirement for exec and upload, and remote upload path validation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-23 14:44:33 +02:00
co-authored by Claude Opus 5
parent a5666e95f2
commit 5cecaa080d
7 changed files with 188 additions and 27 deletions
+31
View File
@@ -272,6 +272,37 @@ test("sections that used to be injected after render are part of the rendered ma
await expect(auditPanel.locator("table.data-table")).toContainText("deployment.requested");
});
test("a very large diff is capped instead of freezing the window", async ({ page }) => {
const selected = await page.evaluate(() => {
const withChanges = ui.repositories.find((repository) => repository.localStatus?.counts?.changed);
if (!withChanges) return null;
selectRepository(withChanges.id);
return withChanges.fullName;
});
expect(selected, "the demo needs a repository with local changes").not.toBeNull();
await expect(page.locator(".diff-view")).toBeVisible();
const measured = await page.evaluate(() => {
const newline = String.fromCharCode(10);
const lines = ["diff --git a/package-lock.json b/package-lock.json"];
for (let index = 0; index < 40_000; index += 1) lines.push(`+ "package-${index}": "^1.2.3",`);
ui.diff = lines.join(newline);
ui.repositoryTab = "changes";
const started = performance.now();
render();
return {
renderMs: performance.now() - started,
rendered: document.querySelectorAll(".diff-line").length,
storedLines: ui.diff.split(newline).length,
};
});
expect(measured.storedLines).toBe(40_001);
expect(measured.rendered).toBeLessThan(2100);
expect(measured.renderMs).toBeLessThan(3000);
await expect(page.locator(".diff-view")).toContainText("more lines are not shown");
});
test("an unchanged render leaves the existing DOM in place", async ({ page }) => {
await page.locator('[data-action="select-repo"]').first().click();
const marked = await page.evaluate(() => {
+98
View File
@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// SshService resolves ssh2 lazily, so replacing the cached module is enough to
// drive a real connection lifecycle without a server.
const ssh2Path = require.resolve("ssh2");
const realSsh2 = require("ssh2");
function withFakeSsh2(Client, run) {
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: { ...realSsh2, Client } };
try {
return run();
} finally {
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: realSsh2 };
}
}
const { SshService } = require("../src/main/ssh-service.cjs");
function store(server = {}) {
return {
getServer: () => ({ id: "unraid", host: "tower", port: 22, username: "root", authType: "password", basePath: "/mnt/user/appdata", hostFingerprint: "SHA256:trusted", ...server }),
getServerCredentials: () => ({ password: "secret", passphrase: "" }),
};
}
test("a connection that fails twice rejects once and never terminates the process", async () => {
class DoubleFailingClient extends EventEmitter {
connect() {
setImmediate(() => this.emit("error", Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" })));
}
end() {
// The socket resets shortly after teardown. An unhandled 'error' event on
// an EventEmitter takes the whole main process down.
setImmediate(() => this.emit("error", new Error("read ECONNRESET")));
}
}
const service = withFakeSsh2(DoubleFailingClient, () => new SshService({ store: store(), diagnostics: null }));
await assert.rejects(
() => withFakeSsh2(DoubleFailingClient, () => service.exec("unraid", "true")),
(error) => {
assert.equal(error.code, "ECONNREFUSED");
assert.match(error.message, /SSH connection failed/);
return true;
},
);
// Give the delayed teardown error time to land while the test is still running.
await new Promise((resolve) => setTimeout(resolve, 50));
});
test("a host key that does not match the trusted fingerprint is reported as an identity change", async () => {
class MismatchingClient extends EventEmitter {
connect(options) {
options.hostVerifier(Buffer.from("a different host key"));
setImmediate(() => this.emit("error", new Error("handshake failed")));
}
end() {}
}
const service = withFakeSsh2(MismatchingClient, () => new SshService({ store: store(), diagnostics: null }));
await assert.rejects(
() => withFakeSsh2(MismatchingClient, () => service.exec("unraid", "true")),
(error) => {
assert.equal(error.code, "SSH_HOST_KEY_MISMATCH");
assert.match(error.message, /SSH host identity changed/);
assert.equal(error.expectedFingerprint, "SHA256:trusted");
assert.ok(error.observedFingerprint.startsWith("SHA256:"));
return true;
},
);
});
test("running a command requires a trusted host fingerprint", async () => {
const service = new SshService({ store: store({ hostFingerprint: "" }), diagnostics: null });
await assert.rejects(() => service.exec("unraid", "true"), (error) => {
assert.equal(error.code, "SSH_HOST_NOT_TRUSTED");
return true;
});
await assert.rejects(() => service.uploadBuffer("unraid", "/mnt/user/appdata/x", "data"), (error) => {
assert.equal(error.code, "SSH_HOST_NOT_TRUSTED");
return true;
});
});
test("a remote upload path may not escape into an arbitrary location", () => {
const service = new SshService({ store: store(), diagnostics: null });
assert.equal(service.ensureUploadTarget("/mnt/user/appdata/app/file.tar"), "/mnt/user/appdata/app/file.tar");
assert.equal(service.ensureUploadTarget("\\mnt\\user\\appdata\\app"), "/mnt/user/appdata/app");
for (const value of ["relative/path", "/mnt/../etc/passwd", "/mnt/user/../../etc", "", null]) {
assert.throws(() => service.ensureUploadTarget(value), /absolute safe Unix path/);
}
});