Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 81 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Generated protobuf bindings are included. Consumers do not need protoc or the de
- `runtimev1`: protobuf/gRPC bindings, API versions, and `ValidateInfo` for compatibility checks.
- `plugin`: shared handshake, plugin name, and gRPC registration/client bridge.
- `server`: executable serving entry point.
- `supervisor`: an opt-in go-plugin runner that owns a leased plugin process tree.
- `conformance`: reusable protocol behavior suite and structured-error assertions.
- `conformance/fake`: persistent fake driver for host integration tests.

Expand Down Expand Up @@ -262,13 +263,84 @@ CI runs these probes on Linux, macOS, and Windows with the other race tests;
| Abrupt plugin death | The runtime child survives until the independent test observer kills it |
| Abrupt host death | Transport loss cancels the RPC and reaps the child, but the plugin survives until the observer kills it |

The last two tests deliberately record ownership gaps in the current transport.
A passing probe suite does not mean abrupt process-tree cleanup is implemented.
`exec.CommandContext` and `Client.Kill()` alone do not establish a complete
process-tree policy. Windows cannot deliver `os.Interrupt` through
`os.Process.Signal`, so cancellation exercises the forced-kill fallback there.

These results block runtime cutover until explicit host/plugin/descendant
ownership is designed and tested on all supported platforms. The fixture is an
experiment, not an exported process supervisor. Streaming stress and executable
trust/environment compatibility remain separate host-hardening work.
The last two baseline tests deliberately retain the plain transport's ownership
gaps. The owned-runner regressions add a grandchild and disable the fixture's
response to RPC cancellation. They verify that plugin death, host death, and
explicit client cleanup terminate all three runtime processes without the test
observer killing survivors. A handshake-timeout regression also verifies cleanup
before the runtime becomes ready. The observer remains a fallback only when a
test fails.

## Owning a runtime process tree

Build the dedicated supervisor helper alongside your runtime binary:

```sh
go build -o devsy-runtime-supervisor ./cmd/devsy-runtime-supervisor
```

Configure an SDK client with an explicitly verified absolute path for each
executable:

```go
client := hplugin.NewClient(&hplugin.ClientConfig{
HandshakeConfig: plugin.Handshake(),
VersionedPlugins: map[int]hplugin.PluginSet{
plugin.ProtocolVersion: plugin.ClientPlugins(),
},
AllowedProtocols: []hplugin.Protocol{hplugin.ProtocolGRPC},
RunnerFunc: supervisor.Runner(supervisor.Options{
SupervisorBinary: verifiedSupervisorPath,
RuntimeBinary: verifiedRuntimePath,
Args: runtimeArgs,
}),
StartTimeout: 15 * time.Second,
})
defer client.Kill()
```

`hplugin` is `github.com/hashicorp/go-plugin`; `plugin` and `supervisor` are SDK
packages. Use `RunnerFunc` instead of `Cmd`. The host verifies both executables
through its existing binary distribution mechanism before configuring this
runner; go-plugin's `SecureConfig` checks a `Cmd` path and is not applicable to
this runner. An embedding host can also expose `supervisor.Main(args)` as a
dedicated helper command in its own executable, selected by `SupervisorArgs`.
`Main` always exits its process and must only run in that helper process.
The SDK's module releases do not distribute helper executables automatically.

One supervisor belongs to one go-plugin client. `Client.Kill()` closes the host
lease and waits for the supervisor to be reaped. Abrupt host death closes the
same pipe through the OS, so cleanup continues without a live host. Plugin exit
also triggers cleanup. There is no global process pool or implicit cache.
Runtime arguments and environment configuration travel to the supervisor through
an inherited pipe; the supervisor forwards handshake stdout and diagnostics
stderr without logging the configuration. Buffered diagnostics remain available
after process reaping, until the reader drains them.

| Platform | Ownership mechanism |
| --- | --- |
| Linux | Dedicated plugin process group; supervisor adopts and reaps orphaned descendants as a subreaper |
| macOS | Dedicated plugin process group; supervisor reaps the plugin and the OS adopts orphaned descendants |
| Windows | Supervisor joins a non-breakaway Job Object before spawning the plugin; descendants inherit membership, and the last handle closes when the supervisor exits |

Unix observes plugin exit before reaping its group leader, keeping the group ID
reserved while terminating descendants. Linux uses `waitid` with `WNOWAIT` and
macOS uses a process-exit kqueue event. Real permission or ownership-setup errors
remain failures; there is no fallback to unowned launch. The Windows Job Object
uses [kill-on-close semantics](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects).

This is ownership for trusted runtime commands, not a sandbox. Unix descendants
must remain in the plugin's process group and retain signalable privileges.
Daemonization, a new session/process group (including a separately created PTY
session), or privilege elevation requires an additional explicit owner. On Unix,
simultaneously killing the host and its supervisor prevents that supervisor from
performing cleanup. Long-lived runtime services and container resources must have
their own resource lifecycle rather than depend on these command processes.

The default environment remains inherited, with optional `Env` overrides;
client-assigned handshake, certificate, and socket metadata retain precedence.
`Directory` sets the runtime working directory. The additional supervisor start
must be included in startup measurements before selecting session reuse.
Streaming stress and real-runtime trust/environment compatibility remain
separate gates before runtime cutover.
10 changes: 10 additions & 0 deletions cmd/devsy-runtime-supervisor/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// devsy-runtime-supervisor owns one plugin process tree for one host lease.
package main

import (
"os"

"github.com/devsy-org/devsy-runtime-sdk/supervisor"
)

func main() { supervisor.Main(os.Args[1:]) }
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.0
require (
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.8.0
golang.org/x/sys v0.47.0
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
)
Expand All @@ -17,7 +18,6 @@ require (
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/oklog/run v1.1.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)
88 changes: 88 additions & 0 deletions internal/processfixture/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

import (
"context"
"fmt"
"os"
"os/exec"
"time"

sdkplugin "github.com/devsy-org/devsy-runtime-sdk/plugin"
"github.com/devsy-org/devsy-runtime-sdk/supervisor"
"github.com/hashicorp/go-hclog"
hplugin "github.com/hashicorp/go-plugin"
)

func (f *fixture) childCommand(ctx context.Context) (*exec.Cmd, error) {
if f.uncancelable {
ctx = context.Background()
}
executable, err := os.Executable()
if err != nil {
return nil, err
}
// #nosec G204 -- Self-executable fixture, with arguments controlled by the test harness.
cmd := exec.CommandContext(
ctx,
executable,
"--mode",
childRole,
"--observer",
f.observer,
fmt.Sprintf(
"--ignore-interrupt=%t",
f.ignore,
),
fmt.Sprintf("--grandchild=%t", f.grandchild),
)
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }
// Windows cannot deliver os.Interrupt to a child; WaitDelay also bounds this fallback.
cmd.WaitDelay = 100 * time.Millisecond
cmd.Stderr = os.Stderr
return cmd, nil
}

func hostClient(observer string, behavior behavior) (*hplugin.Client, error) {
executable, err := os.Executable()
if err != nil {
return nil, err
}
// #nosec G204 -- Self-executable fixture, with arguments controlled by the test harness.
cmd := exec.Command(
executable,
"--mode",
pluginRole,
"--observer",
observer,
fmt.Sprintf(
"--ignore-interrupt=%t",
behavior.ignore,
),
fmt.Sprintf("--grandchild=%t", behavior.grandchild),
fmt.Sprintf("--uncancelable=%t", behavior.uncancelable),
)
config := &hplugin.ClientConfig{
HandshakeConfig: sdkplugin.Handshake(),
VersionedPlugins: map[int]hplugin.PluginSet{
sdkplugin.ProtocolVersion: sdkplugin.ClientPlugins(),
},
AllowedProtocols: []hplugin.Protocol{hplugin.ProtocolGRPC},
Cmd: cmd,
StartTimeout: 10 * time.Second,
Logger: hclog.NewNullLogger(),
Stderr: os.Stderr,
SyncStderr: os.Stderr,
}
if behavior.owned {
config.Cmd = nil
config.RunnerFunc = supervisor.Runner(
supervisor.Options{
SupervisorBinary: executable,
SupervisorArgs: []string{"--supervise", observer},
RuntimeBinary: executable,
Args: cmd.Args[1:],
},
)
}
return hplugin.NewClient(config), nil
}
73 changes: 73 additions & 0 deletions internal/processfixture/descendant.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package main

import (
"encoding/json"
"errors"
"net"
"os"
"os/exec"

"github.com/devsy-org/devsy-runtime-sdk/supervisor"
)

func superviseFixture(observer string, args []string) {
// #nosec G704 -- Address is supplied by the test-owned loopback observer, not external input.
conn, err := net.Dial("tcp", observer)
if err != nil {
panic(err)
}
events := &reporter{encoder: json.NewEncoder(conn), role: "supervisor", address: observer}
if err := events.Encode(
event{Role: "supervisor", Kind: "ready", PID: os.Getpid()},
); err != nil {
panic(err)
}
disconnected := make(chan struct{})
go events.respond(conn, disconnected)
supervisor.Main(args)
}

func launchGrandchild(events *reporter) error {
executable, err := os.Executable()
if err != nil {
return err
}
// #nosec G204 -- Fixed self-executable fixture, never a user command.
cmd := exec.Command(
executable,
"--mode",
"grandchild",
"--observer",
events.address,
"--ignore-interrupt=true",
)
output, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
_ = output.Close()
return err
}
var ready event
if err := json.NewDecoder(output).Decode(&ready); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return err
}
if ready.PID != cmd.Process.Pid {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return errors.New("grandchild readiness mismatch")
}
go func() { _ = cmd.Wait() }()
return nil
}

func maybeGrandchild(events *reporter, enabled bool) error {
if !enabled {
return nil
}
return launchGrandchild(events)
}
Loading
Loading