agalwood/Motrix · error · AppError

PluginManifestInvalid

PluginManifestInvalid

Error message

plugin.update.builtin_staging_not_found

What it means

Thrown by BuiltinUpdater.commit(stagingId) when the given stagingId is not present in the in-memory pending Map. pending is populated only by stage() and cleared by commit()/cancel(); it is not persisted. Used to refuse committing an unknown, already-committed, already-cancelled, or post-restart staging id.

Source

Thrown at src/core/plugin/update/builtin-updater.ts:142

        pluginId: entry.id,
        stagingDir,
      })
      return {
        stagingId,
        trustChanged: diff.changed,
        added: diff.added,
        newVersion: parsed.version,
      }
    } catch (e) {
      await rm(stagingDir, { recursive: true, force: true })
      throw e
    }
  }

  async commit(stagingId: string): Promise<{ pluginId: string }> {
    const staged = this.pending.get(stagingId)
    if (!staged) {
      throw new AppError(
        ErrorCode.PluginManifestInvalid,
        'plugin.update.builtin_staging_not_found'
      )
    }
    const finalDir = path.join(this.opts.overlayDir, staged.pluginId)
    const now = this.opts.now ?? Date.now
    const backup = path.join(
      this.opts.overlayDir,
      `.bak-${staged.pluginId}-${now()}`
    )
    let hadPrevious = true
    try {
      await rename(finalDir, backup)
    } catch {
      hadPrevious = false
    }
    try {
      await rename(staged.stagingDir, finalDir)

View on GitHub (pinned to 1a708ee577)

Solutions

  1. Treat commit() as ephemeral: re-stage() after any process restart before committing.
  2. Track committed/cancelled stagingIds client-side and avoid double-commit.
  3. If the staging dir still exists on disk, do not call commit() — re-stage from scratch since pending state is gone.

Example fix

// before
//   const { stagingId } = await updater.stage(entry, effective)
//   // ... process restarts, pending Map is empty ...
//   await updater.commit(stagingId)  // throws builtin_staging_not_found
// after
//   // pending does not survive restart; re-stage if needed, or commit before restart
//   const result = await updater.stage(entry, effective)
//   await updater.commit(result.stagingId)  // same process lifetime
Defensive patterns

Strategy: validation

Validate before calling

function isStaged(updater: BuiltinUpdater, stagingId: string): boolean {
  // pending is private; track stagingIds you received from stage() in your own set
  return receivedStagingIds.has(stagingId) && !consumedStagingIds.has(stagingId);
}

Try / catch

try {
  await updater.commit(stagingId);
} catch (e) {
  if (e instanceof AppError && e.message === 'plugin.update.builtin_staging_not_found') {
    // staging was already committed/cancelled or process restarted; re-stage if update is still wanted
  } else throw e;
}

Prevention

When it happens

Trigger: Calling commit(stagingId) where: stagingId was never returned by stage(); commit() or cancel() already removed it; or the process restarted (the pending Map is per-instance memory).

Common situations: Two-phase UI that calls stage() in one process and commit() in another (e.g. renderer requests commit after an Electron main restart); retry logic calling commit() twice for the same staging id; calling commit() after the user already hit cancel(); a stale stagingId read from a log/old state.

Related errors


AI-assisted analysis of agalwood/Motrix@1a708ee577 (2026-08-12). Data as JSON: /api/errors/db1695c9b99aaade. Report an issue: GitHub.