Skip to content

macOS: auto-update crash loop, spawns 'updater.app' but shipped helper is 'updater' (ENOENT) #587

Description

@InfiniteLoopAlchemist

Version: 0.2.17 (osx-arm64), guid 019e1325-54c3-7000-b0ec-4c3f7e7d20d4
OS: macOS 26.5.2, Apple Silicon (arm64)

Summary

On macOS, applying an auto-update kills the app on every launch, in an infinite loop. The updater helper is spawned as updater.app, but the file actually shipped/installed is a plain executable named updater (no extension), so the spawn fails with ENOENT. The app then exits anyway, so from the user's point of view wow.export just dies at "Restarting application..." on every single launch (it re-downloads the update and crashes again each time).

runtime.log

[10:50:34] Checking for updater application at /Users/<user>/Library/Application Support/wow.export/.update/updater.app
[10:50:34] Updater exists check: false
[10:50:34] Spawning updater process: /Users/<user>/Library/Application Support/wow.export/updater.app with parent PID 8257
[10:50:34] ERROR: Failed to spawn updater: spawn /Users/<user>/Library/Application Support/wow.export/updater.app ENOENT
[10:50:34] Updater spawned successfully (PID: NaN), detaching...

Root cause

In src/constants.js / the bundled app.js:

var UPDATER_EXT = { win32: ".exe", darwin: ".app" };
...
HELPER: "updater" + (UPDATER_EXT[process.platform] || "")

so on darwin constants.UPDATE.HELPER is updater.app. But what's actually on disk in the install root is a plain Mach-O executable named updater:

-rwxr-xr-x  1 user staff 59884256  updater

and the downloaded update payload doesn't contain updater.app either (the "Updater exists check" against .update/updater.app returns false). So cp.spawn(helperApp, ...) in launchUpdater() fails with ENOENT.

Note that even if a real updater.app bundle existed, cp.spawn() on a .app directory wouldn't work on macOS anyway (it's a directory, not an executable); it would need to spawn the inner Contents/MacOS/... binary or the bare helper executable. Since the shipped artifact is already the bare updater executable, dropping the .app extension for darwin looks like the fix.

Secondary bug: the app exits even though the spawn failed

In launchUpdater(), spawn failure is only surfaced via the async error event, and the throw err inside that handler never propagates to the surrounding try/catch:

const child = cp.spawn(helperApp, [process.pid], { detached: true, stdio: "ignore" });
child.on("error", (err) => {
  log.write("ERROR: Failed to spawn updater: %s", err.message);
  throw err;   // <- lands nowhere useful
});
await new Promise((resolve) => setTimeout(resolve, 100));
log.write("Updater spawned successfully (PID: %d), detaching...", child.pid);  // logs PID: NaN
child.unref();
process.exit();  // <- exits even though no updater is running

That's why the log claims "Updater spawned successfully (PID: NaN)" right after the ENOENT, and why the app hard-exits instead of recovering. Guarding process.exit() on the spawn actually having a PID (or checking child.pid !== undefined) would at least let the app fall back to running the current version instead of dying in a loop.

Workaround

Launching with --disable-auto-update avoids the loop entirely.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    📋 Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions