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
+5 -1
View File
@@ -135,7 +135,11 @@ class SshService {
finish(resolve, data);
} catch (error) { finish(reject, error); }
});
client.once('error', async (error) => {
// Deliberately not `once`: a connection that already failed can emit a
// second error while it is being torn down, and an unhandled 'error' event
// on an EventEmitter terminates the main process.
client.on('error', async (error) => {
if (settled) return;
const observed = connection.getObservedFingerprint();
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
const wrapped = new Error(mismatch
+16 -6
View File
@@ -206,9 +206,14 @@ function linkedWorkloadsForRepository(repository) {
);
}
function diffAtmosphere(diff) {
// A regenerated lock file is an ordinary change and runs into tens of thousands
// of lines. One element per line freezes the window while it is parsed and laid
// out, so the tail is left out of the rendered view only.
const DIFF_RENDER_LINE_LIMIT = 2000;
function diffAtmosphere(diff, allLines = null) {
if (!ui.selectedFile) return "";
const lines = String(diff || "").split("\n");
const lines = allLines || String(diff || "").split("\n");
const additions = lines.filter(
(line) => line.startsWith("+") && !line.startsWith("+++"),
).length;
@@ -224,8 +229,9 @@ function diffAtmosphere(diff) {
function renderDiff(diff) {
if (!diff)
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
const rendered = escapeHtml(diff)
.split("\n")
const lines = String(diff).split("\n");
const rendered = lines
.slice(0, DIFF_RENDER_LINE_LIMIT)
.map((line) => {
const type =
line.startsWith("+") && !line.startsWith("+++")
@@ -235,10 +241,14 @@ function renderDiff(diff) {
: line.startsWith("@@")
? "hunk"
: "";
return `<span class="diff-line ${type}">${line || " "}</span>`;
return `<span class="diff-line ${type}">${escapeHtml(line) || " "}</span>`;
})
.join("");
return `${rendered}${diffAtmosphere(diff)}`;
const hidden = Math.max(0, lines.length - DIFF_RENDER_LINE_LIMIT);
const notice = hidden
? `<span class="diff-line hunk">… ${hidden.toLocaleString()} more line${hidden === 1 ? "" : "s"} are not shown. Copy diff and the editor still give you the complete change.</span>`
: "";
return `${rendered}${notice}${diffAtmosphere(diff, lines)}`;
}
function fileStatusCode(file) {
if (file.conflict) return "U";