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

  1. Audit call sites to ensure Call() is never invoked after Shutdown(); guard with a mutex or done-channel around the shutdown/call pair
  2. Acquire the client fresh from the plugin registry per request instead of caching a dynamicLibraryClient across reloads
  3. 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

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


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