fix(desktop): stop re-entering Squirrel.Mac staging while an update is already staged
Problem
Seven fingerprints, one family, ~38 events since 2026-08-30, macOS/arm64 only:
Squirrel.Mac refuses the app it staged in ~/Library/Caches/com.superset.desktop.ShipIt/update.<id>/Superset.app.
| Issue | Reason string | OSStatus |
|---|---|---|
| DESKTOP-14Y (25) | code failed to satisfy specified code requirement(s) | -67050 |
| DESKTOP-15M (4) | code object is not signed at all | -67062 |
| DESKTOP-150 (2) | 代码对象根本未签名 (same, localised) | -67062 |
| DESKTOP-15K (2) | invalid resource directory (directory or signature have been modified) | — |
| DESKTOP-14S (2) | the code cannot be read by the verifier (file system permissions etc.) | — |
| DESKTOP-16E (1) | UNIX[Operation not permitted] | — |
| DESKTOP-169 (2) | ditto: …/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude: No such file or directory | — |
The update never installs, so the user stays on an old version. DESKTOP-14Y is
one machine on a metronome — 06:21:24, 10:21:25, 14:21:38, 18:24:20, 22:24:13, 02:24:13 … — exactly UPDATE_CHECK_INTERVAL_MS (4h), from 2026-09-04 to
2026-09-08 and still going. Because clearCachedUpdate() wipes the cache on
every error, each cycle re-pulls the whole 431 MB archive from GitHub.
Root cause
First, what it is not. The strong lead in the brief — something written into
or removed from the bundle after signing, asar.unpacked, notarization, the
bundled agent-sdk binary — is disproven. I downloaded the exact artifact the
stable updater fetches and ran Squirrel's own pipeline over it:
### sha512 vs manifest (expect hXEnp3FoyrgmXlikKrQhXSCfQf6q0+fn9R5kVVGB3m4BThG3mc9U53w/p1nRxAfliZjPFDKcpICHzdzalDi1Uw==)
hXEnp3FoyrgmXlikKrQhXSCfQf6q0+fn9R5kVVGB3m4BThG3mc9U53w/p1nRxAfliZjPFDKcpICHzdzalDi1Uw==
### ditto -x -k (exactly what Squirrel.Mac runs)
ditto exit=0
Superset.app
### codesign --verify --deep --strict
out/Superset.app: valid on disk
out/Superset.app: satisfies its Designated Requirement
### does it satisfy the DR of the INSTALLED 1.24.2 stable app (Squirrel's actual test)?
DR: identifier "com.superset.desktop" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "2HVBK29S3C"
out/Superset.app: valid on disk
out/Superset.app: satisfies its Designated Requirement
out/Superset.app: explicit requirement satisfied
I also round-tripped a shipped bundle through the archiver electron-builder
actually uses for the mac zip (7za a -bd -mx=7 -mtc=off -mm=Deflate -mcu, from
app-builder-lib/out/targets/archive.js) and back out through ditto -x -k: all
14 framework symlinks survive and the extracted copy is valid on disk /
satisfies its Designated Requirement. The artifact is sound. There is no
build or packaging fix to make. Contents/CodeResources (the staple ticket,
written after signing) is excluded from the seal by design, and the installed
bundle's mtimes show nothing written after it.
What the seven fingerprints actually are. They are one call site.
nm -u / strings on the shipped Squirrel.framework:
_SecCodeCopySelf
_SecCodeCopyDesignatedRequirement
_SecStaticCodeCreateWithPath
_SecStaticCodeCheckValidityWithErrors
_SecCopyErrorMessageString
...
SQRLCodeSignature.m
Code signature at URL %@ did not pass validation
/usr/bin/ditto
DITTOABORT
So Squirrel takes the running app's designated requirement, runs
SecStaticCodeCheckValidityWithErrors over the staged copy, and prints
SecCopyErrorMessageString(status) — which is why one variant arrives in
Chinese. Seven fingerprints are seven OSStatus values describing one thing: the
staged copy was not a complete, readable, signed bundle at the moment it was
checked.
What we do that puts two staging runs against that directory.
MacUpdater.updateDownloaded() resolves its promise on the proxy response's
finish event — i.e. when the archive has finished streaming to Squirrel, not
when Squirrel has finished unpacking ~2 GB and verifying it. On this machine
that gap is over two minutes (20:12:54 … requested by Squirrel.Mac →
20:14:50 Proxy server … is closed). Inside that gap both of electron-updater's
guards (checkForUpdatesPromise, downloadPromise) are already null, so any of
the six entry points into checkForUpdates() — startup, the 4-hour timer, the
updates pill, the notice dialog, the update-required page, the menu/tray — runs
the whole cycle again, hits the download cache, and calls
nativeUpdater.checkForUpdates() a second time. (Squirrel refuses that
particular call — see below — so this is a second staging request, not a
proven second concurrent unpack.) The ShipIt log shows the shape of it:
2026-08-21 19:03:31.312 ShipIt[87825] Detected this as an install request
2026-08-21 21:31:28.820 ShipIt[9617] Detected this as an install request
2026-08-21 22:40:54.800 ShipIt[9617] Beginning installation
Squirrel answers the second request by refusing: its checkForUpdatesCommand is
built with initWithEnabled:signalBlock: and a RACCommand will not run while
disabled or already executing. The refusal message is
"The command is disabled and cannot be executed" — and that string lives in
ReactiveObjC, next to RACCommandErrorDomain and RACUnderlyingCommandErrorKey:
$ strings -a Superset.app/Contents/Frameworks/ReactiveObjC.framework/Versions/A/ReactiveObjC | grep -i "command is disabled"
The command is disabled and cannot be executed
update-error-classification.ts matched that exact sentence as
LAUNCHD_JOB_DISABLED, commented "launchd refusing to start the ShipIt job
because the user or their MDM disabled it", and dropped it as environmental.
It is not launchd. It is us asking Squirrel to stage an update it is already
staging, silently reclassified as the user's machine.
Fix
- Gate both check entry points on
isUpdateReadyToInstall(), the pending-update
state we already track. Once electron-updater hands the archive over, a repeat
check cannot stage anything newer anyway — Squirrel is committed until the app
relaunches — so today it only re-downloads the archive and points a second
staging run at the same cache directory.checkForUpdatesInteractive()says so
instead of silently doing nothing. No new predicate: the existing one is exactly
the window. - Delete the
LAUNCHD_JOB_DISABLEDrule and its wrong comment, and flip the test
that asserted the misattribution. This starts reporting that error rather than
quieting one; if it still fires after this change, the guard is incomplete and
we want to know. - Attach the free-space figure to the report.
freeStagingBytes()is already
computed on every error for classification and then thrown away, and it is the
one fact that separates a release defect from a volume that could never have
held a 431 MB archive plus its ~2.1 GB expansion. Without it every staging
failure arrives undecidable — which is why I cannot honestly classify the
remaining fingerprints either way today.
Net: −5 lines of wrong classifier, one reused predicate, one field on an existing
captureException.
Second pass: the ditto rule below it (added in review)
The rule directly under the one above is justified by the same kind of
unverified premise:
ditto naming a file it could not find under ShipIt's staging directory is a
staged copy that lost its parent mid-unpack. It prints no errno, and nothing
reuses it: Squirrel stages every attempt into a fresh directory and the
download it was unpacked from is cleared on error.
First, a correction to my own writeup. The claim that this PR already
disproves that premise is not something I established. What I proved is that a
concurrent checkForUpdates() is refused by Squirrel's RACCommand — which
argues against two concurrent unpacks, not for them. "Fresh directory per
attempt" is also true on the evidence: DESKTOP-14S shows the same user with
update.xe9Xc1e and update.LbdolAd on two different days.
The premise is false for a different reason, which I found by dumping Squirrel's
selector table:
$ otool -v -s __TEXT __objc_methname Squirrel.framework/Versions/A/Squirrel | tr ' ' '\n' | grep -aE '^(removeUpdate|uniqueTemporary|storageURL|unzipArchive)'
removeUpdateDirectoriesInStorageURL:excludingURL:
storageURL
uniqueTemporaryDirectoryForUpdate
unzipArchiveAtURL:intoDirectoryAtURL:
storageURL is ~/Library/Caches/<appId>.ShipIt. So Squirrel makes a unique
update.<id> and sweeps every other update.* out of that directory. A
fresh directory per attempt is not an isolation guarantee — it is the trigger
for deleting the other trees. The comment reads the sweep's precondition as
proof that nothing touches the tree, when the sweep is the thing that touches it,
and the sweep needs no concurrency at all: any later staging run removes an
earlier one.
Why (b) is not available. I looked for an environmental shape the
double-staging shape cannot produce, and there isn't one in the message.
Disk-full is already handled ahead of this rule (ENOSPC text plus the free-space
check). What is left is "an ancestor of the staging path disappeared mid-unpack",
and ditto: <path>: No such file or directory is byte-identical whether the
remover was macOS purging ~/Library/Caches, a cleaner app, the user, or
Squirrel's own sweep. There is no field to match on, so the rule cannot be
re-derived to match a real distinction.
So: (a). Rule deleted, SHIPIT_STAGING_PATH deleted with it (its only
caller), test flipped to expect a report. The message carries no ENOENT:
token, so it falls through to reported rather than being caught by the generic
errno rule. I also deleted the "does not take the ShipIt name alone as proof"
test: with the rule gone every ditto: shape returns false, so both of its
negatives asserted nothing. The flipped test keeps one real assertion — the same
message on a full volume is still environmental, pinning that the free-space
check stays ahead of it.
I cannot prove this is what happened in DESKTOP-169, and I am not claiming it.
That is the point: I cannot prove it either way, and a wrong "environmental" is
invisible while a wrong "ours" gets triaged.
The two rules I did not touch
"read-only volume"— asserts the volume the updater runs on is mounted
read-only, a fixed property of the machine for the whole session; sweeping a
siblingupdate.*directory cannot make a volume read-only, so the
double-staging path cannot produce it. (Worth noting separately: I could not
find that exact phrase emitted by electron-updater 6.8.3, Squirrel, or the
Electron framework, so the pattern may be matching nothing — dead weight
rather than a wrong silence, and out of scope here.)"the request timed out"— Foundation'sNSURLErrorTimedOuttext, i.e. a
transport failure fetching the feed or the archive; the staging path produces
vanished-tree and partially-written-bundle shapes, never a timeout. One honest
caveat: Squirrel fetches the archive from a server inside our main process,
so a stalled event loop could in principle starve that read into a timeout —
self-inflicted, but not the double-staging path.
Both are English-only substring matches, and DESKTOP-150 (代码对象根本未签名)
proves this text reaches us localised — so they under-match for non-English
users, which errs toward reporting.
Deliberately not in this change
- No issue is reclassified as environmental. I could not prove it per
fingerprint, and the events carry no disk telemetry to prove it with. That is
what the new context field is for. - DESKTOP-169 is already fixed on main and unreleased.
16aad61a1(#7283,
2026-09-07 19:51 PT) classifiesditto:+ ShipIt staging path + "No such file
or directory" as environmental. The event is 2026-09-07 10:21 PT, the user was
on 1.26.0, andgit tag --contains 16aad61a1is empty. It stops on its own when
1.27.0 ships. I did not touch it. - Every one of these errors can be reported to Sentry twice, and I did not
dedupe it.MacUpdateremitserrorfrom its constructor listener, and while
the archive is still streaming it also rejects theupdateDownloadedpromise,
whichdownloadUpdate().catchturns into a secondemit("error"). Evidence:
DESKTOP-150, -15K, -15M and -169 each have exactly two events at the identical
second, while DESKTOP-14Y's 25 are all distinct (itsonce("error")had already
been removed onfinish); andmain.loglogs "Network unavailable, will retry
later" twice per occurrence. Fixing it halves the event count on issues you are
deliberately watching, so that is your call, not mine. UPDATE_STAGING_MIN_FREE_BYTESis 1 GiB and its comment is wrong. Staging
needs electron-updater's 431 MB pending archive plus Squirrel's own copy plus
the ~2.1 GB expansion — measured, not estimated. Raising it widens the
environmental bucket, which is exactly what you told me not to do on
unproven ground, so I left it.- electron-updater never deletes the pending archive after a successful
install. This machine is running1.27.0-canary.20260908001920and
~/Library/Caches/@supersetdesktop-updater/pending/Superset-Canary-1.27.0-canary.20260908001920-arm64.zip
(411 MB) is still there. Separate leak, separate change. - This does not claim to fix all seven. It removes one proven source of
concurrent staging and one proven misclassification. I did not prove that
re-entrancy is what truncates each individual staged copy — unlinking the
pending archive does not break an in-flight read on macOS, andserver.close()
does not kill in-flight sockets, so I am not claiming a mechanism I could not
demonstrate.
Verification
bunx biome check apps/desktop/src/main/lib/:
Checked 154 files in 90ms. No fixes applied.
bun run typecheck (apps/desktop):
$ bun run generate:icons && bun run generate:routes
$ bun run scripts/generate-file-icons.ts
Generated file icons: 1067 SVGs copied, 2046 file names, 1152 extensions, 4528 folder names
$ tsr generate
$ tsc --noEmit
bun run check:i18n — both new strings translated into all 16 non-English locales, by hand:
$ bunx --bun lingui compile --strict --verbose --workers 1
Compiling message catalogs…
en ⇒ locales/en/messages.ts
ja ⇒ locales/ja/messages.ts
zh-CN ⇒ locales/zh-CN/messages.ts
fr ⇒ locales/fr/messages.ts
ko ⇒ locales/ko/messages.ts
zh-TW ⇒ locales/zh-TW/messages.ts
es ⇒ locales/es/messages.ts
de ⇒ locales/de/messages.ts
pt-BR ⇒ locales/pt-BR/messages.ts
it ⇒ locales/it/messages.ts
ru ⇒ locales/ru/messages.ts
tr ⇒ locales/tr/messages.ts
pl ⇒ locales/pl/messages.ts
nl ⇒ locales/nl/messages.ts
id ⇒ locales/id/messages.ts
cs ⇒ locales/cs/messages.ts
vi ⇒ locales/vi/messages.ts
Done in 536ms
Full bun test for the package (not just the touched files):
3663 pass
1 fail
1 error
Ran 3664 tests across 397 files. [25.00s]
(3664 rather than 3665: the vacuous ShipIt-name test was deleted, as above.)
The one failure is pre-existing and unrelated — a module-mock leak that breaks
@dnd-kit/core at import time in
src/renderer/.../useSidebarDnd/useSidebarDnd.test.ts:
# Unhandled error between tests
13 | const DndMonitorContext = /*#__PURE__*/React.createContext(null);
^
TypeError: React.createContext is not a function. (In 'React.createContext(null)', 'React.createContext' is undefined)
at <anonymous> (…/@dnd-kit/core/dist/core.cjs.development.js:13:46)
Proven pre-existing by reverting only my diff (git checkout -- apps/desktop packages/i18n) and re-running:
=== BASELINE (my diff reverted) ===
exit=1
# Unhandled error between tests
3664 pass
1 fail
Ran 3665 tests across 397 files. [24.84s]
Identical count and identical error. The diff was then re-applied from a patch
and the touched suites re-run:
39 pass
0 fail
116 expect() calls
Ran 39 tests across 2 files. [155.00ms]
Refs DESKTOP-14Y, DESKTOP-15M, DESKTOP-15K, DESKTOP-150, DESKTOP-14S, DESKTOP-16E, DESKTOP-169
https://claude.ai/code/session_01GKgqvJqxDZv9qiWUDz7NVW
Summary by cubic
Stops the macOS updater from re-entering Squirrel.Mac staging while an update is already staged, which was failing code-signature verification on arm64 and leaving users stuck on old versions.
- Gates
checkForUpdates()andcheckForUpdatesInteractive()on the existing pending-update state, so a repeat check can no longer point a second staging run at the same ShipIt cache. - The interactive path now tells the user the update installs at restart instead of silently doing nothing.
- Removes the wrong launchd classifier so Squirrel.Mac's refusal message now reaches Sentry instead of being dropped as environmental.
- Adds the free-staging-bytes figure to every staging error so the events become decidable.
- Adds the two new dialog strings to all locale catalogs; the Korean string keeps its particle on the word so it renders correctly with any version number.
Refs DESKTOP-14Y, DESKTOP-15M, DESKTOP-15K, DESKTOP-150, DESKTOP-14S, DESKTOP-16E, DESKTOP-169.
Summary by CodeRabbit
-
New Features
- Update checks now recognize when an update is already ready to install, avoiding unnecessary network checks.
- Interactive update actions explain that the update will be installed on the next restart.
- Added localized update-status messages across supported languages.
-
Bug Fixes
- Improved update error reporting with available storage context.
- Refined update installation error classification for more accurate diagnostics.