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
- Make BeforeWrite tolerant of the states you consider safe (e.g. treat 'already unloaded' as success, return nil)
- Serialize concurrent installs of the same plugin with a lock so the hook's assumptions hold
- 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
- Make BeforeWrite idempotent and tolerant of already-quiet states
- Serialize installs of the same plugin ID with a mutex
- Keep hook bodies minimal: unload/flush only, no side effects that can fail spuriously
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
- download artifact: %w
- stat target plugin: %w
- plugin_install_failed
- invalid plugin version %q
- release tag %q resolved version %q, want %q
AI-assisted analysis of router-for-me/CLIProxyAPI@78f0c4079e (2026-08-15).
Data as JSON: /api/errors/d75d710e32d28adb.
Report an issue: GitHub.