From 0263a1bd72e985776b5fa893c10ee822a0295863 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 13 Aug 2026 14:16:05 -0700 Subject: [PATCH 1/3] fix(desktop): classify out-of-space update failures by free disk, not message text The macOS update staging tools report a full volume in text we do not own: ditto prints an errno-free line and Squirrel forwards NSError strings localised to the user's system language. The existing guard needed a Node errno prefix plus an updater path marker, so none of them matched and every occurrence was captured. Each message embeds the staging path, so each one also grouped as a brand-new Sentry issue. Ask the filesystem instead: statfs the volume holding the updater caches and treat a volume that cannot hold a staged update as the user's environment. That is language-independent and needs no path parsing, which matters because the Cocoa variant carries no path at all. The log line and the error status sent to the renderer are unchanged, so the app still tells the user the update failed. --- apps/desktop/src/main/lib/auto-updater.ts | 42 +++++------ .../lib/update-error-classification.test.ts | 75 +++++++++++++++++++ .../main/lib/update-error-classification.ts | 43 +++++++++++ 3 files changed, 135 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/main/lib/update-error-classification.test.ts create mode 100644 apps/desktop/src/main/lib/update-error-classification.ts diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index b0cff585f9f..c146dd46cef 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { statfsSync } from "node:fs"; import * as Sentry from "@sentry/electron/main"; import { app, dialog } from "electron"; import log from "electron-log/main"; @@ -6,6 +7,7 @@ import { autoUpdater, type UpdateCheckResult } from "electron-updater"; import { env } from "main/env.main"; import { setSkipQuitConfirmation } from "main/index"; import { appState } from "main/lib/app-state"; +import { isEnvironmentUpdateError } from "main/lib/update-error-classification"; import { gte, prerelease } from "semver"; import { AUTO_UPDATE_STATUS, @@ -83,31 +85,16 @@ function isNetworkError(error: Error | string): boolean { return SILENT_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); } -const ENVIRONMENT_ERRNO_CODES = [ - "ENOENT", - "EACCES", - "EPERM", - "EBUSY", - "ENOSPC", -]; -const UPDATER_PATH_MARKERS = ["-updater", "shipit"]; - -// Update failures owned by the user's machine, not by us: a corrupt or -// half-removed updater cache, an app bundle that can't be written, and stalled -// requests. Squirrel and electron-updater surface these as plain messages -// without errno properties, so match on the text. -function isEnvironmentUpdateError(message: string): boolean { - const lowerMessage = message.toLowerCase(); - if ( - lowerMessage.includes("read-only volume") || - lowerMessage.includes("the request timed out") - ) { - return true; +// Free bytes on the volume backing the updater caches, which sit beside our app +// data. Returns null when the volume can't be queried, so an unknown answer +// never reads as "out of space". +function freeStagingBytes(): number | null { + try { + const { bavail, bsize } = statfsSync(app.getPath("userData")); + return bavail * bsize; + } catch { + return null; } - return ( - ENVIRONMENT_ERRNO_CODES.some((code) => message.includes(`${code}:`)) && - UPDATER_PATH_MARKERS.some((marker) => lowerMessage.includes(marker)) - ); } // electron-updater starts the auto-download inside checkForUpdates and hands @@ -376,7 +363,12 @@ export function setupAutoUpdater(): void { ); void clearCachedUpdate(`error: ${error?.message ?? "unknown"}`); emitStatus(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); - if (!isEnvironmentUpdateError(error?.message ?? String(error))) { + if ( + !isEnvironmentUpdateError( + error?.message ?? String(error), + freeStagingBytes(), + ) + ) { Sentry.captureException(error); } }); diff --git a/apps/desktop/src/main/lib/update-error-classification.test.ts b/apps/desktop/src/main/lib/update-error-classification.test.ts new file mode 100644 index 00000000000..c6fbc5514a8 --- /dev/null +++ b/apps/desktop/src/main/lib/update-error-classification.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { isEnvironmentUpdateError } from "./update-error-classification"; + +const GIGABYTE = 1024 * 1024 * 1024; +const FULL_VOLUME = 40 * 1024 * 1024; +const ROOMY_VOLUME = 60 * GIGABYTE; + +// Captured from production, with the account name and archive digest replaced. +const DITTO_NO_SPACE_EN = [ + "ditto: /Users//Library/Caches/com.superset.desktop.ShipIt/update.aBcDeF1/Superset.app/Contents/Resources/app.asar: No space left on device", + "ditto: Couldn't read pkzip signature.", +].join("\n"); +const SQUIRREL_NO_SPACE_ES = + "El archivo “.zip” no puede guardarse porque no queda suficiente espacio.: No hay suficiente espacio. Elimina algunos archivos del volumen e inténtalo de nuevo."; + +const CHECKSUM_MISMATCH = + "sha512 checksum mismatch, expected 1PbOs3lC, got fT2wPk9d"; +const SIGNATURE_FAILURE = + 'Could not get code signature for running application: Error: Command failed: codesign --verify -vvvv "/Applications/Superset.app"'; +const FEED_FAILURE = + "HttpError: 404 Not Found\nCannot parse update info from latest-mac.yml"; + +describe("isEnvironmentUpdateError", () => { + test("classifies a full staging volume without reading the message", () => { + expect(isEnvironmentUpdateError(DITTO_NO_SPACE_EN, FULL_VOLUME)).toBe(true); + expect(isEnvironmentUpdateError(SQUIRREL_NO_SPACE_ES, FULL_VOLUME)).toBe( + true, + ); + }); + + test("reports those same failures when the volume has room", () => { + expect(isEnvironmentUpdateError(DITTO_NO_SPACE_EN, ROOMY_VOLUME)).toBe( + false, + ); + expect(isEnvironmentUpdateError(SQUIRREL_NO_SPACE_ES, ROOMY_VOLUME)).toBe( + false, + ); + }); + + test("reports genuine updater bugs", () => { + for (const message of [ + CHECKSUM_MISMATCH, + SIGNATURE_FAILURE, + FEED_FAILURE, + ]) { + expect(isEnvironmentUpdateError(message, ROOMY_VOLUME)).toBe(false); + expect(isEnvironmentUpdateError(message, null)).toBe(false); + } + }); + + test("suppresses every updater failure while the volume is full", () => { + expect(isEnvironmentUpdateError(CHECKSUM_MISMATCH, FULL_VOLUME)).toBe(true); + }); + + test("keeps the existing errno and read-only classifications", () => { + expect( + isEnvironmentUpdateError( + "ENOENT: no such file or directory, rename '/Users//Library/Caches/superset-updater/pending/x.zip'", + ROOMY_VOLUME, + ), + ).toBe(true); + expect( + isEnvironmentUpdateError( + "Error: Cannot update while running on a read-only volume", + ROOMY_VOLUME, + ), + ).toBe(true); + expect( + isEnvironmentUpdateError( + "ENOENT: no such file or directory", + ROOMY_VOLUME, + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/lib/update-error-classification.ts b/apps/desktop/src/main/lib/update-error-classification.ts new file mode 100644 index 00000000000..ed913854c86 --- /dev/null +++ b/apps/desktop/src/main/lib/update-error-classification.ts @@ -0,0 +1,43 @@ +const ENVIRONMENT_ERRNO_CODES = [ + "ENOENT", + "EACCES", + "EPERM", + "EBUSY", + "ENOSPC", +]; +const UPDATER_PATH_MARKERS = ["-updater", "shipit"]; + +// Staging an update downloads a ~600MB archive into the user's cache directory +// and unpacks it alongside itself, so a volume with less than this free cannot +// hold one however we behave. +export const UPDATE_STAGING_MIN_FREE_BYTES = 1024 * 1024 * 1024; + +// Update failures owned by the user's machine, not by us. A full volume is the +// common one, and neither staging tool gives us a code to match: `ditto` prints +// an errno-free line and Squirrel forwards NSError text localised to the user's +// language, so ask the filesystem how much room is left rather than reading the +// words. The rest — a corrupt or half-removed updater cache, an app bundle that +// can't be written, stalled requests — arrive as plain messages without errno +// properties, so those still match on the text. +export function isEnvironmentUpdateError( + message: string, + freeStagingBytes: number | null, +): boolean { + if ( + freeStagingBytes !== null && + freeStagingBytes < UPDATE_STAGING_MIN_FREE_BYTES + ) { + return true; + } + const lowerMessage = message.toLowerCase(); + if ( + lowerMessage.includes("read-only volume") || + lowerMessage.includes("the request timed out") + ) { + return true; + } + return ( + ENVIRONMENT_ERRNO_CODES.some((code) => message.includes(`${code}:`)) && + UPDATER_PATH_MARKERS.some((marker) => lowerMessage.includes(marker)) + ); +} From 8624121146c47bf420a3079f0383dbcfd032a87d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 13 Aug 2026 15:02:45 -0700 Subject: [PATCH 2/3] fix(desktop): stop the free-space check muting our own updater defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disk heuristic ran before any message inspection, so below the staging threshold it returned true for everything — a corrupt download, a bad code signature, an unreadable feed. A full disk causes none of those, and the test suite asserted the over-match rather than catching it. Check the failures we can positively attribute to ourselves first and let them report regardless of disk, then apply the heuristic to the remainder. --- .../lib/update-error-classification.test.ts | 19 +++++++++++++++++-- .../main/lib/update-error-classification.ts | 19 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/lib/update-error-classification.test.ts b/apps/desktop/src/main/lib/update-error-classification.test.ts index c6fbc5514a8..0c14617c4bc 100644 --- a/apps/desktop/src/main/lib/update-error-classification.test.ts +++ b/apps/desktop/src/main/lib/update-error-classification.test.ts @@ -48,8 +48,23 @@ describe("isEnvironmentUpdateError", () => { } }); - test("suppresses every updater failure while the volume is full", () => { - expect(isEnvironmentUpdateError(CHECKSUM_MISMATCH, FULL_VOLUME)).toBe(true); + test("still reports our own defects even while the volume is full", () => { + // A full disk does not corrupt a download or break a signature. If these + // were suppressed, a bad release would go unreported for exactly the + // users least able to recover from it. + for (const message of [ + CHECKSUM_MISMATCH, + SIGNATURE_FAILURE, + FEED_FAILURE, + ]) { + expect(isEnvironmentUpdateError(message, FULL_VOLUME)).toBe(false); + } + }); + + test("still treats an unrecognised failure on a full volume as environmental", () => { + expect( + isEnvironmentUpdateError("something we have never seen", FULL_VOLUME), + ).toBe(true); }); test("keeps the existing errno and read-only classifications", () => { diff --git a/apps/desktop/src/main/lib/update-error-classification.ts b/apps/desktop/src/main/lib/update-error-classification.ts index ed913854c86..209357009cc 100644 --- a/apps/desktop/src/main/lib/update-error-classification.ts +++ b/apps/desktop/src/main/lib/update-error-classification.ts @@ -12,6 +12,18 @@ const UPDATER_PATH_MARKERS = ["-updater", "shipit"]; // hold one however we behave. export const UPDATE_STAGING_MIN_FREE_BYTES = 1024 * 1024 * 1024; +// Failures that are ours no matter what the disk looks like: we served a bad +// artifact, signed it wrong, or published an unreadable feed. A full volume +// does not cause any of these, so they must be checked before the free-space +// heuristic — otherwise a genuine release defect goes unreported for every +// user who happens to be low on space. +const UPDATER_DEFECT_PATTERNS = [ + "checksum mismatch", + "code signature", + "codesign", + "cannot parse update info", +]; + // Update failures owned by the user's machine, not by us. A full volume is the // common one, and neither staging tool gives us a code to match: `ditto` prints // an errno-free line and Squirrel forwards NSError text localised to the user's @@ -23,13 +35,18 @@ export function isEnvironmentUpdateError( message: string, freeStagingBytes: number | null, ): boolean { + const lowerMessage = message.toLowerCase(); + if ( + UPDATER_DEFECT_PATTERNS.some((pattern) => lowerMessage.includes(pattern)) + ) { + return false; + } if ( freeStagingBytes !== null && freeStagingBytes < UPDATE_STAGING_MIN_FREE_BYTES ) { return true; } - const lowerMessage = message.toLowerCase(); if ( lowerMessage.includes("read-only volume") || lowerMessage.includes("the request timed out") From a81fa98bb91eae923eba6451cdcc92085b657185 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 13 Aug 2026 15:09:53 -0700 Subject: [PATCH 3/3] fix(desktop): cover the remaining electron-updater defect messages The first pass listed only checksum-mismatch, signature and feed-parse failures, so "Cannot find channel", "No files provided" and the no-checksum variant were still suppressed on a low-disk machine. Each is a bad release artifact that a full volume cannot cause. Broadened the checksum entry to catch the no-checksum wording too, and pinned all six against both a full and a roomy volume. Flagged by an automated reviewer on this PR and verified against electron-updater's own error strings. --- .../src/main/lib/update-error-classification.test.ts | 10 ++++++++++ .../src/main/lib/update-error-classification.ts | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/lib/update-error-classification.test.ts b/apps/desktop/src/main/lib/update-error-classification.test.ts index 0c14617c4bc..c57c576c437 100644 --- a/apps/desktop/src/main/lib/update-error-classification.test.ts +++ b/apps/desktop/src/main/lib/update-error-classification.test.ts @@ -19,6 +19,12 @@ const SIGNATURE_FAILURE = 'Could not get code signature for running application: Error: Command failed: codesign --verify -vvvv "/Applications/Superset.app"'; const FEED_FAILURE = "HttpError: 404 Not Found\nCannot parse update info from latest-mac.yml"; +// Further electron-updater release-artifact defects. Each must survive a full +// volume; a gap here silences a bad release for the users least able to cope. +const MISSING_CHANNEL = "Cannot find channel “latest-mac.yml” update info"; +const NO_FILES = "No files provided in update info"; +const NO_CHECKSUM = + "Update info doesn't contain nor sha256 neither sha512 checksum"; describe("isEnvironmentUpdateError", () => { test("classifies a full staging volume without reading the message", () => { @@ -56,8 +62,12 @@ describe("isEnvironmentUpdateError", () => { CHECKSUM_MISMATCH, SIGNATURE_FAILURE, FEED_FAILURE, + MISSING_CHANNEL, + NO_FILES, + NO_CHECKSUM, ]) { expect(isEnvironmentUpdateError(message, FULL_VOLUME)).toBe(false); + expect(isEnvironmentUpdateError(message, ROOMY_VOLUME)).toBe(false); } }); diff --git a/apps/desktop/src/main/lib/update-error-classification.ts b/apps/desktop/src/main/lib/update-error-classification.ts index 209357009cc..52304b11e70 100644 --- a/apps/desktop/src/main/lib/update-error-classification.ts +++ b/apps/desktop/src/main/lib/update-error-classification.ts @@ -17,11 +17,16 @@ export const UPDATE_STAGING_MIN_FREE_BYTES = 1024 * 1024 * 1024; // does not cause any of these, so they must be checked before the free-space // heuristic — otherwise a genuine release defect goes unreported for every // user who happens to be low on space. +// Keep this list ahead of the release-artifact failure modes electron-updater +// can surface. A missing entry means that defect is silently suppressed for +// every user low on disk, which is the failure this ordering exists to prevent. const UPDATER_DEFECT_PATTERNS = [ - "checksum mismatch", + "checksum", // mismatch, and "doesn't contain nor sha256 neither sha512 checksum" "code signature", "codesign", "cannot parse update info", + "cannot find channel", + "no files provided", ]; // Update failures owned by the user's machine, not by us. A full volume is the