{"record":{"id":"92e572a1b30717e2","repo":"router-for-me/CLIProxyAPI","slug":"plugin-client-is-closed-92e572","errorCode":null,"errorMessage":"plugin client is closed","messagePattern":"plugin client is closed","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/pluginhost/loader_unix.go","lineNumber":167,"sourceCode":"\trc := C.cliproxy_call_init(initSymbol, hostAPI, &client.api)\n\tif rc != 0 {\n\t\tclient.Shutdown()\n\t\treturn nil, fmt.Errorf(\"cliproxy_plugin_init returned %d\", int(rc))\n\t}\n\tif uint32(client.api.abi_version) != pluginHostABIVersion {\n\t\tclient.Shutdown()\n\t\treturn nil, fmt.Errorf(\"plugin ABI version %d is not supported\", uint32(client.api.abi_version))\n\t}\n\tif client.api.call == nil || client.api.free_buffer == nil {\n\t\tclient.Shutdown()\n\t\treturn nil, fmt.Errorf(\"plugin function table is incomplete\")\n\t}\n\treturn client, nil\n}\n\nfunc (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {\n\tif c == nil || c.api.call == nil {\n\t\treturn nil, fmt.Errorf(\"plugin client is closed\")\n\t}\n\tif ctx != nil {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil, ctx.Err()\n\t\tdefault:\n\t\t}\n\t}\n\n\tcMethod := C.CString(method)\n\tdefer C.free(unsafe.Pointer(cMethod))\n\tvar cRequest unsafe.Pointer\n\tif len(request) > 0 {\n\t\tcRequest = C.CBytes(request)\n\t\tdefer C.free(cRequest)\n\t}\n\tvar response C.cliproxy_buffer\n\trc := C.cliproxy_call_plugin(c.api.call, cMethod, (*C.uint8_t)(cRequest), C.size_t(len(request)), &response)","sourceCodeStart":149,"sourceCodeEnd":185,"githubUrl":"https://github.com/router-for-me/CLIProxyAPI/blob/78f0c4079e3e6273d65d03b5549cffc898703264/internal/pluginhost/loader_unix.go#L149-L185","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nclient := getPlugin(id)\nresp, err := client.Call(ctx, method, body) // may run after Shutdown\n\n// after\nclient := getPlugin(id)\nif client == nil {\n    return nil, fmt.Errorf(\"plugin %s is not loaded\", id)\n}\nresp, err := client.Call(ctx, method, body)\nif err != nil && strings.Contains(err.Error(), \"plugin client is closed\") {\n    client = reloadPlugin(id) // re-acquire after hot-reload race\n    resp, err = client.Call(ctx, method, body)\n}","handlingStrategy":"try-catch","validationCode":"if client == nil {\n    return fmt.Errorf(\"plugin client unavailable\")\n}","typeGuard":null,"tryCatchPattern":"out, err := client.Call(ctx, method, body)\nif err != nil {\n    if strings.Contains(err.Error(), \"plugin client is closed\") {\n        // re-resolve client after hot-reload and retry once\n        client = host.LookupPlugin(id)\n        if client != nil {\n            out, err = client.Call(ctx, method, body)\n        }\n    }\n    if err != nil {\n        return fallbackOrError(err)\n    }\n}","preventionTips":["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"],"tags":["plugin","cgo","lifecycle","dynamic-library"],"backgroundTag":null,"analyzedSha":"78f0c4079e3e6273d65d03b5549cffc898703264","analyzedAt":"2026-08-15T12:26:37.444Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}