# Troubleshoot desktop releases



Use this guide when **desktop OTA** or **`pear stage`** behaves unexpectedly after you have a [deployment directory](/pear/explanation/deployment-releasing-apps-p2p#deployment-directory-and-release-lines) and a release line. For general Pear CLI and deployment issues, see [Troubleshoot common issues](/pear/how-to/troubleshooting).

<Callout type="info">
  **Need the `pear` CLI?** Install it from **[install.pears.com](https://install.pears.com)**, or prefix any command below with `npx`. See [Install & upgrade](/pear/reference/pear/cli#install) for details.
</Callout>

## App did not update [#app-did-not-update]

### Bump `version` [#bump-version]

An update will not apply unless `package.json` **`version`** changes. Use `npm version patch` (or the level you intend) before rebuilding and staging.

### Stage to the link the app follows [#stage-to-the-link-the-app-follows]

There is no release pointer to advance after `pear stage`. [`pear-runtime-updater`](https://github.com/holepunchto/pear-runtime-updater) listens for `append` on the [Hypercore](/p2p/reference/building-blocks/hypercore) behind the app's `upgrade` link. When the drive grows, it checks out the latest length and reads `package.json` from it. If that `version` is higher than the running build's, it downloads `by-arch/<host>/app/<name>` from the checkout, where `<host>` is the running machine's `<platform>-<arch>`, such as `darwin-arm64`, and `<name>` is the `name` the app passes to `PearRuntime`. A staged drive with nothing at that path makes the updater emit an `update not found` error.

Staging ends by printing the new **`Latest`** length, the post-stage versioned link, and the link you staged to:

```text skip="sample-output"
Staging complete!

^Latest: 1191

pear://0.1191.<stage-key>
[  pear://<stage-link>  ]
```

Any build whose `upgrade` field is that stage link sees the drive grow to the new `Latest` length, so once the staged `version` is higher and the link is [seeded](#seed-the-link), it needs no further command, only the wait described under [Tune `PearRuntime` `delay` for live OTA visibility](#tune-pearruntime-delay-for-live-ota-visibility). A stage link and a production link are different drives, though. `pear stage` appends only to the link you name, so a build whose `upgrade` is a provision link or the production multisig link is not affected. The single-key `pear release` command was **removed in Pear v3**—release to production with [`pear provision`](/pear/how-to/operate-an-app/manual-deployment/deployment#6-provision) followed by [`pear multisig`](/pear/reference/pear/cli#pear-multisig) quorum cosigning, which is what writes the production drive. If a build did not update, compare the `[  pear://<stage-link>  ]` line above with its `upgrade` field; see [Confirm the `upgrade` link](#confirm-the-upgrade-link).

### Tune `PearRuntime` `delay` for live OTA visibility [#tune-pearruntime-delay-for-live-ota-visibility]

`pear-runtime-updater` polls aggressively only during the first **60 seconds** after launch (the boot grace period). After that, every detected change is scheduled with a random delay of up to **one hour** by default. The delay is sampled **once at construction**, so each new `append` event reschedules against the same offset. If you stage frequently, the timer can reset forever—you may not see the **Update ready** button until the app restarts.

`PearRuntime` forwards its constructor options straight to the updater, so you can shorten or remove the delay:

```js skip="snippet-illustration"
pear = new PearRuntime({
  dir,
  app: appPath,
  updates,
  version,
  upgrade,
  name: productName + extension,
  store,
  swarm,
  delay: 0 // detect every append immediately; default is up to 1 hour
})
```

**Trade-off:** the long random delay exists on purpose for production. It spreads update checks across many clients so seeders do not get a thundering-herd when a new release goes out. Use `delay: 0` (or a few seconds) for development and tutorials; keep the default—or set a value in the **tens of minutes**—for production builds with many users.

Restarting the running app is a quick workaround without recompiling: a restart resets the boot grace period, and the next poll happens within seconds. This is also why an OTA can appear to "only work after a restart"—detection waits for the next scheduled poll, and a restart triggers one immediately.

### Worker did not exit cleanly [#worker-did-not-exit-cleanly]

Bare workers spawned through [`pear-runtime`](https://github.com/holepunchto/pear-runtime) (`PearRuntime.run(...)`, as in the `hello-pear-electron`-shaped `getWorker(...)` helper) are plain OS child processes. Electron does **not** signal them on exit, so any code path that quits without tearing the worker down leaves it running. The worker keeps an exclusive lock on its [Corestore](/p2p/reference/helpers/corestore) directory, and the next launch hangs forever on [`await store.ready()`](/p2p/reference/helpers/corestore#await-storeready).

Look for the symptom in `ps`:

```bash skip="diagnostic-command"
ps -A -o pid,command | grep bare-sidecar
```

If a row points at your old `.app` (or a now-deleted deployment directory), kill it:

```bash skip="diagnostic-command"
kill <pid>
```

Then fix the host so it can't recur:

1. Register a `before-quit` listener per worker that destroys its IPC pipe (`pipe.destroy()`), so any quit tears every worker down. Remove the listener when the worker emits `exit`.
2. Relaunch through `app.quit()`, not `app.exit()`. `app.quit()` runs `before-quit` and lets each worker shut down; `app.exit()` skips Electron's clean-shutdown path entirely, so the `before-quit` listeners never fire and the workers leak.

The reference wiring lives in [Read the Electron main process](/p2p/getting-started/build-a-peer-to-peer-chat/reshape-into-a-production-app#read-the-electron-main-process) (the per-worker `before-quit` listeners and the `app:afterUpdate` handler that calls `app.relaunch()` then `app.quit()`).

### Apply finishes the swap before relaunch [#apply-finishes-the-swap-before-relaunch]

`pear.updater.applyUpdate()` is **async**—it atomically swaps the new app bundle on disk and removes the staging copy. The renderer's **Update ready** button must wait for the swap to finish before triggering the relaunch. In the example, the updater worker awaits `applyUpdate()` and only then writes `pear:updateApplied` back over the IPC pipe; the main process resolves the `pear:applyUpdate` handler on that message, and the renderer calls `app:afterUpdate` afterwards:

```js skip="snippet-illustration"
// updater worker (workers/main.js)
pipe.on('data', async (data) => {
  if (data.toString() === 'pear:applyUpdate') {
    await pear.updater.applyUpdate()
    pipe.write('pear:updateApplied')
  }
})
```

If the relaunch fires before the swap completes, it may load the previous bundle. The symptom is that clicking **Update ready** appears to do nothing, but quitting the app manually and reopening it shows the new version.

### Confirm the `upgrade` link [#confirm-the-upgrade-link]

The `upgrade` field must point at the **`pear://` link** you actually staged to. If it points at an old line, clients will track the wrong drive.

### Seed the link [#seed-the-link]

The upgrade target must be **seeded** so peers can find it:

```bash skip="release-flow"
pear seed pear://your-link-here
```

Keep **always-on seeders** for production links; user laptops alone are a fragile source.

### Seeder came online after the client [#seeder-came-online-after-the-client]

Discovery is asynchronous. If the seeder was offline when the client first looked up the key, the client may retry on a **roughly fifteen-minute** cadence. Wait, or add more seeders, then restart the client once the key is announced.

### Seeder unreachable [#seeder-unreachable]

Add the key to **multiple** reachable seeders so OTA does not depend on one network path.

## Recovering from lost write access [#recovering-from-lost-write-access]

* **Staged** and **provisioned** drives are **machine-bound** in normal setups—if you lose the data that holds write capability to those keys, you cannot "undelete" the same keys from thin air.
* **Multisig** production drives are **not** machine-bound. Write access is determined by signing capability, so a quorum of signers can keep producing releases even if every other key is lost.

**Check first.** On Pear 3.6.0 or later, `pear info <link>` shows a `writable` row: `false` means this machine does not hold the key pair for that link. That applies to stage and provision links. A multisig production link is written by a quorum of signers, so it reads `false` even when signing works. See [Preflight a release before staging](/pear/how-to/operate-an-app/manual-deployment/preflight-a-release#2-check-that-this-machine-can-write).

**If a stage link is lost:** create a new link with `pear touch`, seed it, stage to it, and repoint **stage-line** builds' `upgrade` fields as needed.

**If a provision key is lost** and you already have a multisig production drive, you can rebuild a clean provision drive from production. Mint a fresh target key with `pear touch`, then run two `pear provision` commands in order:

```sh skip="release-flow"
# 1. Seed the new target with the production drive's current contents
pear provision <versioned-production-key> <target-key> <versioned-production-key>

# 2. Sync from the most recent stage onto the new target, anchored on production
pear provision <versioned-stage-key> <target-key> <versioned-production-key>
```

The multisig config does not reference the provision link, so there is nothing to update in `pear.json` (see [Create the multisig config](/pear/how-to/operate-an-app/multisig/set-up-multisig#create-the-multisig-config)). Instead, pass the new provision link as the `<source-link>` to [`pear multisig verify` and `pear multisig commit`](/pear/how-to/operate-an-app/multisig/sign-with-multisig) on the next release. For multisig-specific recovery—interrupted commits and `INCOMPATIBLE_SOURCE_AND_TARGET`—see [Troubleshoot multisig](/pear/how-to/operate-an-app/multisig/troubleshoot-multisig).

To automate this in CI, the [`pear-provision-recover`](/pear/reference/ci-and-release/github-actions#pear-app-cicd) action rebuilds a writable provision drive from a remote key—pass the lost drive's `key`, target `length`/`blobs-length`, and a fresh `primary-key` and `name`.

## Node ABI mismatch during make [#node-abi-mismatch-during-make]

If `npm run make` fails with a `NODE_MODULE_VERSION` mismatch like:

```text skip="sample-error"
The module '.../node_modules/macos-alias/build/Release/volume.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 127. This version of Node.js requires
NODE_MODULE_VERSION 141.
```

the native add-ons in `node_modules` were compiled for a different Node ABI than the Node version that runs `electron-forge make`. This usually happens after `nvm use`, a Node upgrade, or installing dependencies under one Node version and running the make under another.

Rebuild against the current Node:

```bash skip="machine-state"
npm rebuild
```

If you have multiple Node versions installed, pin one for the project—for example with an [`.nvmrc`](https://github.com/nvm-sh/nvm#nvmrc) file and an `engines.node` field in `package.json`—so installs and builds always use the same ABI.

`NODE_MODULE_VERSION` to Node version mapping is in the [Node.js release schedule](https://nodejs.org/en/about/previous-releases) (for example, `127` is Node 22, `141` is Node 25).

## `pear stage` shows huge size growth [#pear-stage-shows-huge-size-growth]

### Deployment folder nested inside the app [#deployment-folder-nested-inside-the-app]

If the **deployment directory** (output of `pear build`) lives **inside** the application tree that gets staged, each stage can **re-include the previous deployment blob**, so diffs grow without bound.

**Fix:** always pass `--target` pointing to a sibling directory **beside** the app root. Note that omitting `--target` does **not** protect you: `pear build` then writes a `<name>-<version>` folder into the directory you run it *from*, so run it from a parent directory outside the app tree if you leave `--target` off. Never run:

```bash skip="signed-build"
# Bad: target inside the app you are about to stage
pear build ... --package ./my-app/package.json --target ./my-app/deploy-out
```

**Do** run from a parent directory:

```bash skip="signed-build"
# Good: target next to the app
pear build ... --package ./my-app/package.json --target ./deploy-out/my-app-1.2.3
```

Keeping one folder per version also makes **rollback** easier: you can re-stage or re-provision from an older deployment directory.

## Where to go next [#where-to-go-next]

* [Deploy your application](/pear/how-to/operate-an-app/manual-deployment/deployment)—dry-run staging and provision commands.
* [Preflight a release before staging](/pear/how-to/operate-an-app/manual-deployment/preflight-a-release)—check every architecture and your write access before you stage.
* [Build desktop distributables](/pear/how-to/operate-an-app/build-and-package/build-desktop-distributables)—correct `pear build` invocation before staging.
* [Release pipeline](/pear/explanation/deployment-releasing-apps-p2p)—which link each build should follow.

<Callout type="info">
  **Related documentation**

  **Getting Started:** [Part 1: Chat](/p2p/getting-started/build-a-peer-to-peer-chat/build-a-peer-to-peer-chat) · [Part 2: Production-shaped](/p2p/getting-started/build-a-peer-to-peer-chat/reshape-into-a-production-app) · [Part 3: Ship](/p2p/getting-started/build-a-peer-to-peer-chat/ship) · [Part 4: Update](/p2p/getting-started/build-a-peer-to-peer-chat/update)

  **Operating an app**

  * [Desktop release npm scripts](/pear/reference/ci-and-release/desktop-release-npm-scripts)
  * [Distribute as a binary](/pear/how-to/operate-an-app/build-and-package/distribute-as-binary)
  * [Manage installed applications](/pear/how-to/manage-installed-applications)
  * [Troubleshoot common issues](/pear/how-to/troubleshooting)
</Callout>
