router-for-me/CLIProxyAPI · error
plugin client is closed
Error message
plugin client is closed
What it means
Thrown by the unix (cgo) dynamic-library plugin client when Call() is invoked on a client that has already been shut down or never fully opened. The guard checks c == nil or c.api.call == nil, both of which are the post-Shutdown state. It is a lifecycle misuse error, not a plugin-internal failure.
Source
Thrown at internal/pluginhost/loader_unix.go:167
rc := C.cliproxy_call_init(initSymbol, hostAPI, &client.api)
if rc != 0 {
client.Shutdown()
return nil, fmt.Errorf("cliproxy_plugin_init returned %d", int(rc))
}
if uint32(client.api.abi_version) != pluginHostABIVersion {
client.Shutdown()
return nil, fmt.Errorf("plugin ABI version %d is not supported", uint32(client.api.abi_version))
}
if client.api.call == nil || client.api.free_buffer == nil {
client.Shutdown()
return nil, fmt.Errorf("plugin function table is incomplete")
}
return client, nil
}
func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {
if c == nil || c.api.call == nil {
return nil, fmt.Errorf("plugin client is closed")
}
if ctx != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var cRequest unsafe.Pointer
if len(request) > 0 {
cRequest = C.CBytes(request)
defer C.free(cRequest)
}
var response C.cliproxy_buffer
rc := C.cliproxy_call_plugin(c.api.call, cMethod, (*C.uint8_t)(cRequest), C.size_t(len(request)), &response)View on GitHub (pinned to 78f0c4079e)
Solutions
- Audit call sites to ensure Call() is never invoked after Shutdown(); guard with a mutex or done-channel around the shutdown/call pair
- Acquire the client fresh from the plugin registry per request instead of caching a dynamicLibraryClient across reloads
- If Open() fails, return the error up and abandon the client object entirely instead of reusing it
Example fix
// before
client := getPlugin(id)
resp, err := client.Call(ctx, method, body) // may run after Shutdown
// after
client := getPlugin(id)
if client == nil {
return nil, fmt.Errorf("plugin %s is not loaded", id)
}
resp, err := client.Call(ctx, method, body)
if err != nil && strings.Contains(err.Error(), "plugin client is closed") {
client = reloadPlugin(id) // re-acquire after hot-reload race
resp, err = client.Call(ctx, method, body)
} Defensive patterns
Strategy: try-catch
Validate before calling
if client == nil {
return fmt.Errorf("plugin client unavailable")
} Try / catch
out, err := client.Call(ctx, method, body)
if err != nil {
if strings.Contains(err.Error(), "plugin client is closed") {
// re-resolve client after hot-reload and retry once
client = host.LookupPlugin(id)
if client != nil {
out, err = client.Call(ctx, method, body)
}
}
if err != nil {
return fallbackOrError(err)
}
} Prevention
- Never cache plugin clients across config reloads; look them up per request
- Wrap plugin Call sites with a recover() in your own glue code — a cgo boundary can also abort
- Serialize Shutdown with in-flight calls using a WaitGroup
When it happens
Trigger: Calling dynamicLibraryClient.Call() after Shutdown() was called; calling Call() on a client whose Open() failed after the function-table check nulled out the api; concurrent goroutine shuts the client down while another is mid-call.
Common situations: Hot-reloading plugins (config watcher triggers shutdown+reopen) while an in-flight request still holds the old client; error paths that shut the client down then fall through to use it; storing the client in a map that is cleared on reload without synchronizing callers.
Related errors
- plugin call %s returned %d: %s
- standard dynamic library plugin loading requires cgo on this
- plugin client is closed
- plugin client is closed
- plugin host is unavailable
AI-assisted analysis of router-for-me/CLIProxyAPI@78f0c4079e (2026-08-15).
Data as JSON: /api/errors/92e572a1b30717e2.
Report an issue: GitHub.