diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..5792a54
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,124 @@
+# AGENTS.md - Coding Guidelines for go-plugin
+
+## Project Overview
+
+`github.com/hashicorp/go-plugin` is a Go library for building plugin systems using
+process isolation with net/rpc or gRPC transport. Plugins are standalone binaries
+that communicate with a host process. Go 1.24, MPL-2.0 license.
+
+## Build / Lint / Test Commands
+
+```bash
+# Build all packages
+go build ./...
+
+# Run all tests with race detection
+go test -race ./...
+
+# Run all tests with verbose output and coverage
+go test -race ./... -v -coverprofile=coverage.out
+
+# Run a single test
+go test -race -run TestClient -v .
+go test -race -run TestServer_testMode -v ./...
+
+# Run tests in a specific package
+go test -race -v ./internal/cmdrunner
+
+# Lint
+golangci-lint run
+
+# Format check (CI fails if files are changed)
+go fmt ./...
+
+# Regenerate protobuf code (requires buf)
+buf generate --path test/grpc/test.proto
+```
+
+## Code Style
+
+### File Headers
+
+Every `.go` file must start with the copyright header:
+
+```go
+// Copyright IBM Corp. 2016, 2025
+// SPDX-License-Identifier: MPL-2.0
+```
+
+### Package Declarations
+
+- Root package is `plugin`.
+- Internal packages live under `internal/` (e.g., `internal/cmdrunner`,
+ `internal/grpcmux`, `internal/plugin`).
+- Public sub-packages: `runner/`, `test/grpc/`.
+
+### Imports
+
+Group imports in three blocks separated by blank lines:
+1. Standard library
+2. Third-party packages
+3. Internal packages (`github.com/hashicorp/go-plugin/...`)
+
+Use named imports sparingly; only when disambiguation is needed:
+
+```go
+import (
+ "context"
+ "fmt"
+
+ hclog "github.com/hashicorp/go-hclog"
+ "github.com/hashicorp/go-plugin/internal/grpcmux"
+ "google.golang.org/grpc"
+)
+```
+
+### Naming Conventions
+
+- Exported types and functions use PascalCase: `Client`, `ServeConfig`, `HandshakeConfig`.
+- Unexported uses camelCase: `managedClients`, `defaultPluginLogBufferSize`.
+- Interfaces define behavior; use `-er` suffix where appropriate (`Runner`).
+- Test helpers accept `testing.TB` (not `*testing.T`) to support both tests and benchmarks.
+- Prefix test functions with `Test`: `TestClient`, `TestServer_testMode`.
+- Use underscores in test names for readability: `TestClient_killStart`.
+
+### Error Handling
+
+- Return errors; do not panic in library code.
+- Define sentinel errors as package-level `var` using `errors.New`:
+ ```go
+ var ErrProcessNotFound = cmdrunner.ErrProcessNotFound
+ var ErrChecksumsDoNotMatch = errors.New("checksums did not match")
+ ```
+- Wrap errors with `fmt.Errorf("context: %w", err)` when adding context.
+- Discard cleanup errors with `_ =`: `_ = l.Close()`.
+
+### Types and Interfaces
+
+- Struct fields are documented with comments above each field.
+- Use interface compliance checks in test files:
+ ```go
+ var _ Plugin = (*testInterfacePlugin)(nil)
+ var _ Plugin = new(NetRPCUnsupportedPlugin)
+ ```
+- Prefer embedding for composition: `NetRPCUnsupportedPlugin` is designed for embedding.
+
+### Testing Patterns
+
+- Tests are in `*_test.go` files in the same package (not `_test` package).
+- Use `t.Fatal` / `t.Fatalf` for unexpected errors, not `t.Error`.
+- Use `t.TempDir()` for temporary directories; clean up with `defer`.
+- Use `context.WithCancel` with `defer cancel()` for context-based tests.
+- Helper processes are spawned via `helperProcess()` in `plugin_test.go`.
+
+### Logging
+
+- Use `github.com/hashicorp/go-hclog` for all logging.
+- Prefer `hclog.New()` with explicit `LoggerOptions` in non-test code.
+- Logger is passed via `ClientConfig.Logger` or created with sensible defaults.
+
+### gRPC / Protobuf
+
+- Proto definitions in `test/grpc/test.proto`.
+- Generated code uses `buf` with settings in `buf.yaml` / `buf.gen.yaml`.
+- Do not edit generated `*.pb.go` files directly.
diff --git a/README.md b/README.md
index 50baee0..236f2bb 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,15 @@ created for [Packer](https://www.packer.io), it is additionally in use by
[Boundary](https://www.boundaryproject.io),
and [Waypoint](https://www.waypointproject.io).
-While the plugin system is over RPC, it is currently only designed to work
-over a local [reliable] network. Plugins over a real network are not supported
-and will lead to unexpected behavior.
+## Remote Plugin Support
+
+**New!** The plugin system now supports reliable gRPC communication over the
+network. Plugins can run on different machines and communicate over TCP/IP
+with built-in keepalive for reliable connections.
+
+For local plugins (subprocess-based), see the traditional usage below.
+For remote plugins (network-based), see the [Remote Plugin Support](#remote-plugin-support-1)
+section.
This plugin system has been used on millions of machines across many different
projects and has proven to be battle hardened and ready for production use.
@@ -75,6 +81,70 @@ reattach.
checksum and RPC communications can be configured to use TLS. The host process
must be properly secured to protect this configuration.
+## Remote Plugin Support
+
+The plugin system now supports reliable gRPC communication over the network.
+Unlike the traditional local subprocess-based approach, remote plugins can
+run on different machines and communicate over TCP/IP.
+
+### Key Features
+
+- **Network Transport**: Plugins communicate over TCP/IP instead of local sockets
+- **Keepalive**: Automatic keepalive pings maintain reliable connections over
+ potentially unreliable networks
+- **Health Checks**: Clients can verify server availability before connecting
+- **TLS Support**: Can use TLS for secure connections over untrusted networks
+- **Graceful Shutdown**: Server handles signals for clean shutdown
+
+### Server Usage
+
+```go
+import (
+ "net"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/health"
+ "github.com/hashicorp/go-plugin"
+)
+
+// Create a TCP listener
+lis, _ := net.Listen("tcp", ":50051")
+
+// Create server with keepalive enforcement
+grpcServer := grpc.NewServer(
+ grpc.KeepaliveEnforcementPolicy(
+ plugin.DefaultGRPCServerKeepaliveEnforcementPolicy(),
+ ),
+)
+
+// Register health check
+healthCheck := health.NewServer()
+healthCheck.SetServingStatus(plugin.GRPCServiceName, grpc_health_v1.HealthCheckResponse_SERVING)
+grpc_health_v1.RegisterHealthServer(grpcServer, healthCheck)
+
+// Register plugins and serve
+grpcServer.Serve(lis)
+```
+
+### Client Usage
+
+```go
+import "github.com/hashicorp/go-plugin"
+
+// Connect to remote server with keepalive
+client, err := plugin.NewGRPCRemoteClient(&plugin.GRPCRemoteClientConfig{
+ Addr: "localhost:50051",
+ Plugins: pluginMap,
+})
+defer client.Close()
+
+// Dispense and use the plugin
+raw, _ := client.Dispense("kv")
+kv := raw.(MyPlugin)
+kv.Put("key", []byte("value"))
+```
+
+See the `examples/remote` directory for a complete working example.
+
## Architecture
The HashiCorp plugin system works by launching subprocesses and communicating
diff --git a/examples/remote/README.md b/examples/remote/README.md
new file mode 100644
index 0000000..5886b37
--- /dev/null
+++ b/examples/remote/README.md
@@ -0,0 +1,98 @@
+# Remote Plugin Example
+
+This example demonstrates how to use go-plugin for remote gRPC plugin
+communication over the network. Unlike the local subprocess-based examples,
+this shows how to connect to a plugin server that may be running on a
+different machine.
+
+## Features
+
+- **Network Transport**: Plugins communicate over TCP/IP instead of local sockets
+- **Keepalive**: Automatic keepalive pings maintain reliable connections
+- **Health Checks**: Clients can verify server availability before connecting
+- **Graceful Shutdown**: Server handles SIGTERM for clean shutdown
+
+## Running the Example
+
+### 1. Start the Server
+
+```bash
+cd server
+go run . -addr :50051
+```
+
+The server will listen on port 50051 and accept connections from anywhere.
+
+### 2. Run the Client
+
+In another terminal:
+
+```bash
+cd client
+
+# Put a value
+go run . -addr localhost:50051 put mykey "hello world"
+
+# Get the value
+go run . -addr localhost:50051 get mykey
+
+# Health check
+go run . -addr localhost:50051 -health
+```
+
+## Code Structure
+
+- `shared/` - Shared interface and gRPC implementation
+- `server/` - Remote plugin server
+- `client/` - Remote plugin client
+
+## Key Differences from Local Plugins
+
+1. **No Subprocess Management**: The server runs as a standalone process
+2. **Network Addresses**: Uses `host:port` addresses instead of local sockets
+3. **Keepalive**: Built-in keepalive for reliable network transport
+4. **TLS Support**: Can use TLS for secure connections over untrusted networks
+
+## API Usage
+
+### Server Side
+
+```go
+import (
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/health"
+ "github.com/hashicorp/go-plugin"
+)
+
+// Create server with keepalive
+grpcServer := grpc.NewServer(
+ grpc.KeepaliveEnforcementPolicy(
+ plugin.DefaultGRPCServerKeepaliveEnforcementPolicy(),
+ ),
+)
+
+// Register health check
+healthCheck := health.NewServer()
+healthCheck.SetServingStatus(plugin.GRPCServiceName, grpc_health_v1.HealthCheckResponse_SERVING)
+grpc_health_v1.RegisterHealthServer(grpcServer, healthCheck)
+
+// Register plugins and serve
+grpcServer.Serve(listener)
+```
+
+### Client Side
+
+```go
+import "github.com/hashicorp/go-plugin"
+
+// Connect to remote server with keepalive
+client, err := plugin.NewGRPCRemoteClient(&plugin.GRPCRemoteClientConfig{
+ Addr: "localhost:50051",
+ Plugins: pluginMap,
+})
+defer client.Close()
+
+// Use the plugin
+raw, err := client.Dispense("kv")
+kv := raw.(KV)
+```
diff --git a/examples/remote/client/main.go b/examples/remote/client/main.go
new file mode 100644
index 0000000..710a6c4
--- /dev/null
+++ b/examples/remote/client/main.go
@@ -0,0 +1,105 @@
+// Copyright IBM Corp. 2016, 2025
+// SPDX-License-Identifier: MPL-2.0
+
+// This is an example of a remote plugin client that connects to a gRPC
+// plugin server over the network. Unlike local plugins which are subprocess-based,
+// this client connects to a remote server that may be on a different machine.
+//
+// The client uses keepalive to maintain reliable connections over potentially
+// unreliable network connections.
+//
+// Usage:
+//
+// go run . -addr localhost:50051 [get|put] [args...]
+//
+// Example:
+//
+// # Start the server in one terminal:
+// cd server && go run . -addr :50051
+//
+// # In another terminal, put a value:
+// cd client && go run . -addr localhost:50051 put mykey "hello world"
+//
+// # Get the value:
+// cd client && go run . -addr localhost:50051 get mykey
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/hashicorp/go-plugin"
+ "github.com/hashicorp/go-plugin/examples/remote/shared"
+)
+
+func main() {
+ addr := flag.String("addr", "localhost:50051", "Address of the remote plugin server")
+ healthCheckOnly := flag.Bool("health", false, "Only perform health check and exit")
+ flag.Parse()
+
+ args := flag.Args()
+ if len(args) < 1 && !*healthCheckOnly {
+ fmt.Println("Usage: client -addr
[get|put] [args...]")
+ fmt.Println(" client -addr -health")
+ os.Exit(1)
+ }
+
+ // Optionally perform a health check first
+ if *healthCheckOnly {
+ fmt.Printf("Checking health of %s...\n", *addr)
+ err := plugin.GRPCRemoteClientHealthCheck(context.Background(), *addr)
+ if err != nil {
+ log.Fatalf("Health check failed: %v", err)
+ }
+ fmt.Println("Server is healthy!")
+ return
+ }
+
+ // Create a remote gRPC client. This connects to the server over the
+ // network with automatic keepalive for reliable connections.
+ client, err := plugin.NewGRPCRemoteClient(&plugin.GRPCRemoteClientConfig{
+ Addr: *addr,
+ Plugins: shared.PluginMap,
+ })
+ if err != nil {
+ log.Fatalf("Failed to connect: %v", err)
+ }
+ defer client.Close()
+
+ // Dispense the KV plugin from the remote server
+ raw, err := client.Dispense("kv")
+ if err != nil {
+ log.Fatalf("Failed to dispense plugin: %v", err)
+ }
+
+ kv := raw.(shared.KV)
+
+ // Execute the requested command
+ switch args[0] {
+ case "get":
+ if len(args) < 2 {
+ log.Fatal("Usage: client get ")
+ }
+ value, err := kv.Get(args[1])
+ if err != nil {
+ log.Fatalf("Get failed: %v", err)
+ }
+ fmt.Println(string(value))
+
+ case "put":
+ if len(args) < 3 {
+ log.Fatal("Usage: client put ")
+ }
+ err := kv.Put(args[1], []byte(args[2]))
+ if err != nil {
+ log.Fatalf("Put failed: %v", err)
+ }
+ fmt.Println("OK")
+
+ default:
+ log.Fatalf("Unknown command: %s (use 'get' or 'put')", args[0])
+ }
+}
diff --git a/examples/remote/server/main.go b/examples/remote/server/main.go
new file mode 100644
index 0000000..f83271a
--- /dev/null
+++ b/examples/remote/server/main.go
@@ -0,0 +1,82 @@
+// Copyright IBM Corp. 2016, 2025
+// SPDX-License-Identifier: MPL-2.0
+
+// This is an example of a remote plugin server that serves a gRPC plugin
+// over the network. Unlike local plugins which are subprocess-based,
+// this server can be accessed from anywhere on the network.
+//
+// Usage:
+//
+// go run . -addr :50051
+//
+// This server uses keepalive to maintain reliable connections over the
+// network, making it suitable for cross-machine plugin communication.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/hashicorp/go-plugin"
+ "github.com/hashicorp/go-plugin/examples/remote/shared"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/health"
+ "google.golang.org/grpc/health/grpc_health_v1"
+)
+
+func main() {
+ addr := flag.String("addr", ":50051", "Address to listen on (e.g. :50051 or 0.0.0.0:50051)")
+ flag.Parse()
+
+ // Create a TCP listener for the network
+ lis, err := net.Listen("tcp", *addr)
+ if err != nil {
+ log.Fatalf("Failed to listen: %v", err)
+ }
+ fmt.Printf("Remote plugin server listening on %s\n", lis.Addr().String())
+
+ // Create a gRPC server with keepalive enforcement for reliable
+ // network connections. This ensures the server accepts keepalive
+ // pings from remote clients.
+ grpcServer := grpc.NewServer(
+ grpc.KeepaliveEnforcementPolicy(
+ plugin.DefaultGRPCServerKeepaliveEnforcementPolicy(),
+ ),
+ )
+
+ // Register health check service - this allows clients to verify
+ // the server is available before attempting to use plugins.
+ healthCheck := health.NewServer()
+ healthCheck.SetServingStatus(
+ plugin.GRPCServiceName,
+ grpc_health_v1.HealthCheckResponse_SERVING,
+ )
+ grpc_health_v1.RegisterHealthServer(grpcServer, healthCheck)
+
+ // Register our plugin
+ kvPlugin := &shared.KVGRPCPlugin{Impl: shared.NewInMemoryKV()}
+ if err := kvPlugin.GRPCServer(nil, grpcServer); err != nil {
+ log.Fatalf("Failed to register plugin: %v", err)
+ }
+
+ // Handle graceful shutdown
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ <-sigCh
+ fmt.Println("\nShutting down server...")
+ grpcServer.GracefulStop()
+ }()
+
+ fmt.Println("Server ready. Clients can connect to:", lis.Addr().String())
+ fmt.Println("Press Ctrl+C to stop.")
+
+ if err := grpcServer.Serve(lis); err != nil {
+ log.Fatalf("Failed to serve: %v", err)
+ }
+}
diff --git a/examples/remote/shared/interface.go b/examples/remote/shared/interface.go
new file mode 100644
index 0000000..53b7d67
--- /dev/null
+++ b/examples/remote/shared/interface.go
@@ -0,0 +1,110 @@
+// Copyright IBM Corp. 2016, 2025
+// SPDX-License-Identifier: MPL-2.0
+
+// Package shared contains shared data between the host and plugins.
+// This is used by both the remote plugin server and client.
+package shared
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/go-plugin"
+ "github.com/hashicorp/go-plugin/examples/grpc/proto"
+ "google.golang.org/grpc"
+)
+
+// Handshake is a common handshake that is shared by plugin and host.
+var Handshake = plugin.HandshakeConfig{
+ ProtocolVersion: 1,
+ MagicCookieKey: "BASIC_PLUGIN",
+ MagicCookieValue: "hello",
+}
+
+// PluginMap is the map of plugins we can dispense.
+var PluginMap = map[string]plugin.Plugin{
+ "kv": &KVGRPCPlugin{},
+}
+
+// KV is the interface that we're exposing as a plugin.
+type KV interface {
+ Put(key string, value []byte) error
+ Get(key string) ([]byte, error)
+}
+
+// KVGRPCPlugin is the implementation of plugin.GRPCPlugin for remote serving.
+type KVGRPCPlugin struct {
+ plugin.NetRPCUnsupportedPlugin
+ Impl KV
+}
+
+func (p *KVGRPCPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error {
+ proto.RegisterKVServer(s, &GRPCServer{Impl: p.Impl})
+ return nil
+}
+
+func (p *KVGRPCPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
+ return &GRPCClient{client: proto.NewKVClient(c)}, nil
+}
+
+// GRPCClient is an implementation of KV that talks over gRPC.
+type GRPCClient struct {
+ client proto.KVClient
+}
+
+func (c *GRPCClient) Put(key string, value []byte) error {
+ _, err := c.client.Put(context.Background(), &proto.PutRequest{
+ Key: key,
+ Value: value,
+ })
+ return err
+}
+
+func (c *GRPCClient) Get(key string) ([]byte, error) {
+ resp, err := c.client.Get(context.Background(), &proto.GetRequest{
+ Key: key,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return resp.Value, nil
+}
+
+// GRPCServer is the gRPC server that GRPCClient talks to.
+type GRPCServer struct {
+ proto.UnimplementedKVServer
+ Impl KV
+}
+
+func (s *GRPCServer) Put(ctx context.Context, req *proto.PutRequest) (*proto.Empty, error) {
+ return &proto.Empty{}, s.Impl.Put(req.Key, req.Value)
+}
+
+func (s *GRPCServer) Get(ctx context.Context, req *proto.GetRequest) (*proto.GetResponse, error) {
+ v, err := s.Impl.Get(req.Key)
+ return &proto.GetResponse{Value: v}, err
+}
+
+// InMemoryKV is a simple in-memory KV store for demo purposes.
+type InMemoryKV struct {
+ store map[string][]byte
+}
+
+func NewInMemoryKV() *InMemoryKV {
+ return &InMemoryKV{store: make(map[string][]byte)}
+}
+
+func (kv *InMemoryKV) Put(key string, value []byte) error {
+ kv.store[key] = value
+ fmt.Printf("PUT %s = %s\n", key, string(value))
+ return nil
+}
+
+func (kv *InMemoryKV) Get(key string) ([]byte, error) {
+ value, ok := kv.store[key]
+ if !ok {
+ return nil, fmt.Errorf("key not found: %s", key)
+ }
+ fmt.Printf("GET %s = %s\n", key, string(value))
+ return value, nil
+}
diff --git a/grpc_remote.go b/grpc_remote.go
new file mode 100644
index 0000000..66a500b
--- /dev/null
+++ b/grpc_remote.go
@@ -0,0 +1,208 @@
+// Copyright IBM Corp. 2016, 2025
+// SPDX-License-Identifier: MPL-2.0
+
+package plugin
+
+import (
+ "context"
+ "crypto/tls"
+ "math"
+ "time"
+
+ "github.com/hashicorp/go-hclog"
+ "github.com/hashicorp/go-plugin/internal/plugin"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/keepalive"
+)
+
+// GRPCRemoteClientConfig configures a remote gRPC plugin client that connects
+// to a plugin server running on a network address, rather than a local
+// subprocess.
+//
+// Remote gRPC clients are designed to work reliably over network connections
+// with automatic keepalive, reconnection, and long-lived streaming support.
+type GRPCRemoteClientConfig struct {
+ // Addr is the network address of the remote plugin server.
+ // This should be in "host:port" format for TCP connections.
+ Addr string
+
+ // TLSConfig, if set, enables TLS for the connection.
+ TLSConfig *tls.Config
+
+ // Plugins are the plugins that can be consumed.
+ Plugins PluginSet
+
+ // Logger is used for logging. If none is provided, a default logger
+ // will be created.
+ Logger hclog.Logger
+
+ // GRPCDialOptions allows passing custom gRPC dial options.
+ // Keepalive options will be merged with these defaults if
+ // KeepaliveParams are not set.
+ GRPCDialOptions []grpc.DialOption
+
+ // KeepaliveParams configures client-side keepalive for reliable
+ // network transport. If nil, sensible defaults are used suitable
+ // for remote connections.
+ KeepaliveParams *keepalive.ClientParameters
+
+ // KeepaliveEnforcementPolicy configures server-side keepalive
+ // enforcement. This is used only when the server configures
+ // keepalive with this policy.
+ KeepaliveEnforcementPolicy *keepalive.EnforcementPolicy
+}
+
+// DefaultGRPCRemoteKeepaliveParams returns keepalive parameters suitable
+// for reliable operation over network connections. These defaults send
+// keepalive pings every 10 seconds and wait 20 seconds for a response
+// before considering the connection dead.
+func DefaultGRPCRemoteKeepaliveParams() keepalive.ClientParameters {
+ return keepalive.ClientParameters{
+ // Send keepalive pings every 10 seconds when there is no activity.
+ Time: 10 * time.Second,
+ // Wait 20 seconds for a keepalive ping response before considering
+ // the connection dead.
+ Timeout: 20 * time.Second,
+ // Send keepalive pings even if there are no active streams.
+ // This ensures the connection stays alive during idle periods.
+ PermitWithoutStream: true,
+ }
+}
+
+// DefaultGRPCServerKeepaliveEnforcementPolicy returns an enforcement policy
+// suitable for remote gRPC servers. Use this when creating the gRPC server
+// to ensure it accepts keepalive pings from remote clients.
+func DefaultGRPCServerKeepaliveEnforcementPolicy() keepalive.EnforcementPolicy {
+ return keepalive.EnforcementPolicy{
+ // Allow clients to send keepalive pings without active streams.
+ MinTime: 10 * time.Second,
+ PermitWithoutStream: true,
+ }
+}
+
+// NewGRPCRemoteClient creates a new GRPCClient connected to a remote plugin
+// server at the given address. Unlike the subprocess-based Client, this
+// connects directly to a running gRPC server over the network.
+//
+// The returned GRPCClient uses keepalive to maintain reliable connections
+// over potentially unreliable networks. Callers should call Close() when
+// done to release resources.
+func NewGRPCRemoteClient(config *GRPCRemoteClientConfig) (*GRPCClient, error) {
+ if config.Logger == nil {
+ config.Logger = hclog.New(&hclog.LoggerOptions{
+ Output: hclog.DefaultOutput,
+ Level: hclog.Trace,
+ Name: "plugin",
+ })
+ }
+
+ conn, err := dialRemoteGRPCConn(config)
+ if err != nil {
+ return nil, err
+ }
+
+ doneCtx, cancel := context.WithCancel(context.Background())
+ _ = cancel // cancel is called by GRPCClient.Close via its Close method
+
+ // Start the broker for auxiliary gRPC connections
+ brokerGRPCClient := newGRPCBrokerClient(conn)
+ broker := newGRPCBroker(brokerGRPCClient, config.TLSConfig, UnixSocketConfig{}, nil, nil)
+ go broker.Run()
+ go func() { _ = brokerGRPCClient.StartStream() }()
+
+ // Start stdio client - this may fail gracefully if the server doesn't
+ // support it (e.g. for non-Go plugins).
+ stdioClient, err := newGRPCStdioClient(doneCtx, config.Logger.Named("stdio"), conn)
+ if err != nil {
+ config.Logger.Warn("failed to create stdio client", "err", err)
+ }
+ if stdioClient != nil {
+ go stdioClient.Run(nil, nil)
+ }
+
+ cl := &GRPCClient{
+ Conn: conn,
+ Plugins: config.Plugins,
+ doneCtx: doneCtx,
+ broker: broker,
+ controller: plugin.NewGRPCControllerClient(conn),
+ }
+
+ return cl, nil
+}
+
+// dialRemoteGRPCConn creates a gRPC connection to a remote server with
+// keepalive and appropriate settings for reliable network transport.
+func dialRemoteGRPCConn(config *GRPCRemoteClientConfig) (*grpc.ClientConn, error) {
+ opts := make([]grpc.DialOption, 0)
+
+ // Configure TLS
+ if config.TLSConfig == nil {
+ opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ } else {
+ opts = append(opts, grpc.WithTransportCredentials(
+ credentials.NewTLS(config.TLSConfig)))
+ }
+
+ // Configure keepalive for reliable network transport
+ keepaliveParams := DefaultGRPCRemoteKeepaliveParams()
+ if config.KeepaliveParams != nil {
+ keepaliveParams = *config.KeepaliveParams
+ }
+ opts = append(opts, grpc.WithKeepaliveParams(keepaliveParams))
+
+ // Set large message size limits
+ opts = append(opts,
+ grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(math.MaxInt32)),
+ grpc.WithDefaultCallOptions(grpc.MaxCallSendMsgSize(math.MaxInt32)))
+
+ // Add any custom dial options (merging with defaults)
+ opts = append(opts, config.GRPCDialOptions...)
+
+ conn, err := grpc.Dial(config.Addr, opts...)
+ if err != nil {
+ return nil, err
+ }
+
+ return conn, nil
+}
+
+// NewGRPCRemoteServer creates a gRPC server suitable for remote plugin hosting.
+// It configures keepalive enforcement to work reliably with remote clients
+// and returns a server that can be registered with plugin services.
+//
+// This is a convenience function; you can also create the server directly
+// using grpc.NewServer with the appropriate options.
+func NewGRPCRemoteServer(opts ...grpc.ServerOption) *grpc.Server {
+ // Add keepalive enforcement policy for remote clients
+ opts = append(opts,
+ grpc.KeepaliveEnforcementPolicy(DefaultGRPCServerKeepaliveEnforcementPolicy()))
+
+ return grpc.NewServer(opts...)
+}
+
+// GRPCRemoteClientHealthCheck performs a health check on a remote gRPC plugin
+// server. This can be used to verify that a remote server is available and
+// healthy before attempting to use it.
+func GRPCRemoteClientHealthCheck(ctx context.Context, addr string, opts ...grpc.DialOption) error {
+ // Add keepalive for reliability
+ keepaliveParams := DefaultGRPCRemoteKeepaliveParams()
+ opts = append(opts, grpc.WithKeepaliveParams(keepaliveParams))
+ opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
+
+ conn, err := grpc.Dial(addr, opts...)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ client := grpc_health_v1.NewHealthClient(conn)
+ _, err = client.Check(ctx, &grpc_health_v1.HealthCheckRequest{
+ Service: GRPCServiceName,
+ })
+
+ return err
+}
diff --git a/grpc_remote_test.go b/grpc_remote_test.go
new file mode 100644
index 0000000..f9ce597
--- /dev/null
+++ b/grpc_remote_test.go
@@ -0,0 +1,154 @@
+// Copyright IBM Corp. 2016, 2025
+// SPDX-License-Identifier: MPL-2.0
+
+package plugin
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/health"
+ "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/keepalive"
+)
+
+func TestGRPCRemoteClient(t *testing.T) {
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lis.Close()
+
+ s := grpc.NewServer()
+ registerTestGRPCServices(t, s)
+ go s.Serve(lis)
+ defer s.Stop()
+
+ client, err := NewGRPCRemoteClient(&GRPCRemoteClientConfig{
+ Addr: lis.Addr().String(),
+ Plugins: testGRPCPluginMap,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+
+ if err := client.Ping(); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := client.Dispense("test")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ impl, ok := raw.(testInterface)
+ if !ok {
+ t.Fatalf("expected testInterface, got %T", raw)
+ }
+
+ result := impl.Double(21)
+ if result != 42 {
+ t.Fatalf("expected 42, got %d", result)
+ }
+}
+
+func TestGRPCRemoteClient_WithKeepalive(t *testing.T) {
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lis.Close()
+
+ s := grpc.NewServer(
+ grpc.KeepaliveEnforcementPolicy(DefaultGRPCServerKeepaliveEnforcementPolicy()),
+ )
+ registerTestGRPCServices(t, s)
+ go s.Serve(lis)
+ defer s.Stop()
+
+ client, err := NewGRPCRemoteClient(&GRPCRemoteClientConfig{
+ Addr: lis.Addr().String(),
+ Plugins: testGRPCPluginMap,
+ KeepaliveParams: &keepalive.ClientParameters{
+ Time: 1 * time.Second,
+ Timeout: 5 * time.Second,
+ PermitWithoutStream: true,
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+
+ if err := client.Ping(); err != nil {
+ t.Fatal(err)
+ }
+
+ time.Sleep(100 * time.Millisecond)
+
+ if err := client.Ping(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGRPCRemoteClient_HealthCheck(t *testing.T) {
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lis.Close()
+
+ s := grpc.NewServer()
+ healthCheck := health.NewServer()
+ healthCheck.SetServingStatus(GRPCServiceName, grpc_health_v1.HealthCheckResponse_SERVING)
+ grpc_health_v1.RegisterHealthServer(s, healthCheck)
+ go s.Serve(lis)
+ defer s.Stop()
+
+ err = GRPCRemoteClientHealthCheck(context.Background(), lis.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGRPCRemoteClient_HealthCheckFails(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+
+ err := GRPCRemoteClientHealthCheck(ctx, "127.0.0.1:1")
+ if err == nil {
+ t.Fatal("expected health check to fail for non-existent server")
+ }
+}
+
+func TestGRPCRemoteClient_ServerNotRunning(t *testing.T) {
+ client, err := NewGRPCRemoteClient(&GRPCRemoteClientConfig{
+ Addr: "127.0.0.1:1",
+ Plugins: testGRPCPluginMap,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+
+ if err := client.Ping(); err == nil {
+ t.Fatal("expected ping to fail when server is not running")
+ }
+}
+
+func registerTestGRPCServices(t *testing.T, s *grpc.Server) {
+ t.Helper()
+
+ healthCheck := health.NewServer()
+ healthCheck.SetServingStatus(GRPCServiceName, grpc_health_v1.HealthCheckResponse_SERVING)
+ grpc_health_v1.RegisterHealthServer(s, healthCheck)
+
+ testPlugin := &testGRPCInterfacePlugin{}
+ if err := testPlugin.GRPCServer(nil, s); err != nil {
+ t.Fatal(err)
+ }
+}