router-for-me/CLIProxyAPI · error

prepare plugin write: %w

Error message

prepare plugin write: %w

What it means

Wrapped error from InstallArchive when the caller-supplied options.BeforeWrite hook returns an error. BeforeWrite is invoked only when overwriting an already-installed plugin, as a last-chance consistency gate right before the atomic file replacement (e.g. to flush/stop the loaded plugin). Its error is surfaced verbatim under 'prepare plugin write' and cancels the write.

Source

Thrown at internal/pluginstore/install.go:293

		existingData, errReadExisting := os.ReadFile(targetPath)
		if errReadExisting != nil {
			return InstallResult{}, fmt.Errorf("read target plugin: %w", errReadExisting)
		}
		if bytes.Equal(existingData, libraryData) {
			return InstallResult{
				ID:          id,
				Version:     strings.TrimSpace(plugin.Version),
				Path:        targetPath,
				Overwritten: true,
				Skipped:     true,
			}, nil
		}
	}
	// Re-check immediately before replacing an existing file: the same version
	// may have been loaded while the archive was being downloaded and verified.
	if overwritten && options.BeforeWrite != nil {
		if errBeforeWrite := options.BeforeWrite(); errBeforeWrite != nil {
			return InstallResult{}, fmt.Errorf("prepare plugin write: %w", errBeforeWrite)
		}
	}
	if overwritten && loadedPluginInstallBlocked(options) {
		return InstallResult{}, ErrLoadedPluginLocked
	}
	if errWrite := writeFileAtomic(targetPath, libraryData, mode); errWrite != nil {
		return InstallResult{}, errWrite
	}
	return InstallResult{
		ID:          id,
		Version:     strings.TrimSpace(plugin.Version),
		Path:        targetPath,
		Overwritten: overwritten,
	}, nil
}

func installTargetPath(options InstallOptions, id string, version string) (string, error) {
	version = normalizeVersion(version)

View on GitHub (pinned to 78f0c4079e)

Solutions

  1. Make BeforeWrite tolerant of the states you consider safe (e.g. treat 'already unloaded' as success, return nil)
  2. Serialize concurrent installs of the same plugin with a lock so the hook's assumptions hold
  3. Return nil from the hook when the precondition you guard against is actually acceptable, and let the idempotency byte-compare handle no-op installs

Example fix

// before
options.BeforeWrite = func() error {
    return livePlugins.Unload(id) // errors if plugin already gone or busy
}

// after
options.BeforeWrite = func() error {
    if err := livePlugins.Unload(id); err != nil {
        if errors.Is(err, errNotLoaded) {
            return nil // nothing to quiesce; safe to write
        }
        return err
    }
    return nil
}
Defensive patterns

Strategy: try-catch

Try / catch

options.BeforeWrite = func() error {
    if err := quiesce(plugin.ID); err != nil && !errors.Is(err, errAlreadyQuiet) {
        return err
    }
    return nil
}
if _, err := pluginstore.InstallArchive(data, plugin, options); err != nil {
    if strings.Contains(err.Error(), "prepare plugin write:") {
        // hook vetoed: plugin still loaded; unload properly and retry
    }
}

Prevention

When it happens

Trigger: Passing InstallOptions{BeforeWrite: func() error {...}} whose body fails: a hook that tries to unload/unregister a live plugin and gets a busy/timeout error, or one that re-checks state and finds a conflicting concurrent update.

Common situations: Hooks that quiesce a running process before upgrade and fail when the process refuses shutdown; race with another installer that changed state between the initial stat and the write; hook code with its own bugs (nil map, closed channel) returning errors.

Related errors


AI-assisted analysis of router-for-me/CLIProxyAPI@78f0c4079e (2026-08-15). Data as JSON: /api/errors/d75d710e32d28adb. Report an issue: GitHub.