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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,21 @@ jobs:
probe_dir="$RUNNER_TEMP/runtime probe λ"
mkdir -p "$probe_dir/state"
go build -o "$probe_dir/runtime.exe" ./cmd/devsy-fake-runtime
go build -o "$probe_dir/supervisor.exe" ./cmd/devsy-runtime-supervisor
go build -o "$probe_dir/probe.exe" ./cmd/devsy-runtime-spawn-probe
"$probe_dir/probe.exe" --binary "$probe_dir/runtime.exe" \
--samples 100 --revision "$GITHUB_SHA" \
-- --state-dir "$probe_dir/state" > "$probe_dir/report.json"
"$probe_dir/probe.exe" --binary "$probe_dir/runtime.exe" \
--supervisor-binary "$probe_dir/supervisor.exe" \
--samples 100 --revision "$GITHUB_SHA" \
-- --state-dir "$probe_dir/state" > "$probe_dir/supervised-report.json"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: runtime-spawn-${{ matrix.os }}
path: ${{ runner.temp }}/runtime probe λ/report.json
path: |
${{ runner.temp }}/runtime probe λ/report.json
${{ runner.temp }}/runtime probe λ/supervised-report.json
if-no-files-found: error
release-please:
name: Release Please
Expand Down
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,16 +197,21 @@ this suite does not certify those policies or runtime-specific image semantics.
## Measuring plugin startup

The host adapter's startup spike uses the real plugin transport and includes
process shutdown and reaping. Build both executables without race instrumentation
process shutdown and reaping. Build all three executables without race instrumentation
(the race runtime's exit delay would distort process lifetime measurements):

```sh
mkdir -p bin/probe-state
go build -o bin/devsy-fake-runtime ./cmd/devsy-fake-runtime
go build -o bin/devsy-runtime-spawn-probe ./cmd/devsy-runtime-spawn-probe
go build -o bin/devsy-runtime-supervisor ./cmd/devsy-runtime-supervisor
"$PWD/bin/devsy-runtime-spawn-probe" \
--binary "$PWD/bin/devsy-fake-runtime" --samples 100 \
-- --state-dir "$PWD/bin/probe-state" > bin/spawn-report.json
"$PWD/bin/devsy-runtime-spawn-probe" \
--binary "$PWD/bin/devsy-fake-runtime" \
--supervisor-binary "$PWD/bin/devsy-runtime-supervisor" --samples 100 \
-- --state-dir "$PWD/bin/probe-state" > bin/supervised-spawn-report.json
```

The probe requires an explicitly trusted absolute executable path and an unused
Expand All @@ -225,7 +230,12 @@ initialization call. Find measures ordinary absence independently of Info.
Reports retain raw stage timings in nanoseconds, total p50/p95/p99 using
nearest-rank percentiles, process IDs and reaping confirmation, OS/architecture,
Go version, CPU count, source revision, and the measured binary's SHA-256.
Arguments, full environment values, and executable paths are omitted.
The additive `launch_mode` field identifies `direct` or `supervised` launches;
`supervisor_sha256` identifies the helper in supervised reports. In direct mode,
`pid` identifies the plugin; in supervised mode it identifies the owning helper.
Startup and total durations in supervised mode include launching both processes,
and reaping includes waiting for supervisor cleanup. Arguments, full environment
values, and executable paths are omitted.

The binary hash identifies the measured artifact; it is not an integrity check
against a trusted expected checksum. Hashing also warms file caches. First
Expand All @@ -237,9 +247,13 @@ plugin lifetime; this probe makes no ownership decision or latency threshold.

CI runs the probe on Linux, macOS, and Windows with executable paths containing
spaces and non-ASCII characters, and publishes `runtime-spawn-*` JSON artifacts.
These jobs gate release automation alongside the existing quality checks.
Cross-platform cancellation and descendant-process cleanup are the next host
hardening gates; successful startup measurements do not certify those behaviors.
Each artifact retains the direct `report.json` and `supervised-report.json` from
the same runner, runtime binary, operation, and sample count. Compare Info and
Find separately. These are sequential warm-cache experiments (direct runs first),
not randomized trials, cold-cache results, or latency acceptance tests. Repeat in
both orders on a representative host before drawing a performance conclusion.
These jobs gate release automation alongside the existing quality checks;
successful startup measurements alone do not certify descendant-process cleanup.

## Process ownership experiments

Expand Down
6 changes: 6 additions & 0 deletions cmd/devsy-runtime-spawn-probe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ func main() {
"",
"absolute path to an explicitly trusted plugin executable",
)
flag.StringVar(
&config.SupervisorBinary,
"supervisor-binary",
"",
"absolute path to a trusted supervisor helper; empty measures direct launch",
)
flag.IntVar(&config.Samples, "samples", 100, "warm repetitions per operation")
flag.DurationVar(
&config.Timeout,
Expand Down
134 changes: 96 additions & 38 deletions internal/spawnprobe/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,26 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"time"

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

// Config supplies an explicitly trusted executable and bounded measurement count.
type Config struct {
Binary string
Args []string
Samples int
Timeout time.Duration
WorkspaceID string
Revision string
Diagnostics io.Writer
SupervisorBinary string
Binary string
Args []string
Samples int
Timeout time.Duration
WorkspaceID string
Revision string
Diagnostics io.Writer
}

// Run measures one first launch and Samples warm launches for Info and Find.
Expand All @@ -54,24 +57,56 @@ func Run(ctx context.Context, config Config) (*Report, error) {
return &Report{
SchemaVersion: 1, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH,
GoVersion: runtime.Version(), CPUs: runtime.NumCPU(), Revision: config.Revision,
BinarySHA256: checksum, RecordedAt: time.Now().UTC(), Info: info, Find: find,
LaunchMode: launchMode(config), BinarySHA256: checksum.runtime,
SupervisorSHA256: checksum.supervisor, RecordedAt: time.Now().UTC(), Info: info, Find: find,
}, nil
}

func validate(config Config) (string, error) {
type binaryHashes struct {
runtime string
supervisor string
}

func launchMode(config Config) string {
if config.SupervisorBinary != "" {
return "supervised"
}
return "direct"
}

func validate(config Config) (binaryHashes, error) {
if !filepath.IsAbs(config.Binary) {
return "", errors.New("plugin binary must be an absolute path")
return binaryHashes{}, errors.New("plugin binary must be an absolute path")
}
if config.Samples < 1 || config.Samples > 10000 {
return "", errors.New("samples must be between 1 and 10000")
return binaryHashes{}, errors.New("samples must be between 1 and 10000")
}
if config.Timeout <= 0 {
return "", errors.New("operation timeout must be positive")
return binaryHashes{}, errors.New("operation timeout must be positive")
}
if config.WorkspaceID == "" {
return "", errors.New("workspace ID is required for Find")
return binaryHashes{}, errors.New("workspace ID is required for Find")
}
return checksum(config.Binary)
return artifactHashes(config)
}

func artifactHashes(config Config) (binaryHashes, error) {
hashes := binaryHashes{}
var err error
hashes.runtime, err = checksum(config.Binary)
if err != nil {
return hashes, err
}
if config.SupervisorBinary != "" {
if !filepath.IsAbs(config.SupervisorBinary) {
return hashes, errors.New("supervisor binary must be an absolute path")
}
hashes.supervisor, err = checksum(config.SupervisorBinary)
if err != nil {
return hashes, fmt.Errorf("supervisor: %w", err)
}
}
return hashes, nil
}

func checksum(binary string) (string, error) {
Expand Down Expand Up @@ -133,34 +168,15 @@ func measureOne(
}
ctx, cancel := context.WithTimeout(parent, config.Timeout)
defer cancel()
// #nosec G204 -- The caller supplies an explicitly trusted absolute executable.
command := exec.CommandContext(ctx, config.Binary, config.Args...)
client := hplugin.NewClient(&hplugin.ClientConfig{
HandshakeConfig: sdkplugin.Handshake(),
VersionedPlugins: map[int]hplugin.PluginSet{
sdkplugin.ProtocolVersion: sdkplugin.ClientPlugins(),
},
Cmd: command,
AllowedProtocols: []hplugin.Protocol{hplugin.ProtocolGRPC},
StartTimeout: config.Timeout,
Logger: hclog.NewNullLogger(),
Stderr: config.Diagnostics,
SyncStderr: config.Diagnostics,
})
client := newClient(ctx, config)
started := time.Now()
defer func() {
reapStarted := time.Now()
client.Kill()
sample.Reaped = client.Exited()
if command.Process != nil {
sample.PID = command.Process.Pid
}
sample.ReapNS = time.Since(reapStarted).Nanoseconds()
sample.TotalNS = time.Since(started).Nanoseconds()
if resultErr == nil && !sample.Reaped {
resultErr = errors.New("plugin was not reaped")
cleanupErr := reap(client, &sample, started)
if resultErr == nil {
resultErr = cleanupErr
}
}()

rpc, err := client.Client()
sample.StartupNS = time.Since(started).Nanoseconds()
if err != nil {
Expand Down Expand Up @@ -212,3 +228,45 @@ func call(
}
return nil
}

func newClient(ctx context.Context, config Config) *hplugin.Client {
clientConfig := &hplugin.ClientConfig{
HandshakeConfig: sdkplugin.Handshake(),
VersionedPlugins: map[int]hplugin.PluginSet{
sdkplugin.ProtocolVersion: sdkplugin.ClientPlugins(),
},
AllowedProtocols: []hplugin.Protocol{hplugin.ProtocolGRPC},
StartTimeout: config.Timeout,
Logger: hclog.NewNullLogger(),
Stderr: config.Diagnostics,
SyncStderr: config.Diagnostics,
}
if config.SupervisorBinary == "" {
// #nosec G204 -- Explicitly trusted absolute executable, validated before measurement.
clientConfig.Cmd = exec.CommandContext(ctx, config.Binary, config.Args...)
} else {
clientConfig.RunnerFunc = supervisor.Runner(supervisor.Options{
SupervisorBinary: config.SupervisorBinary,
RuntimeBinary: config.Binary,
Args: config.Args,
})
}
return hplugin.NewClient(clientConfig)
}

func reap(client *hplugin.Client, sample *Sample, started time.Time) error {
reapStarted := time.Now()
pid, pidErr := strconv.Atoi(client.ID())
sample.PID = pid
client.Kill()
sample.Reaped = client.Exited()
sample.ReapNS = time.Since(reapStarted).Nanoseconds()
sample.TotalNS = time.Since(started).Nanoseconds()
if pidErr != nil || sample.PID <= 0 {
return errors.New("runner did not report a process ID")
}
if !sample.Reaped {
return errors.New("runner was not reaped")
}
return nil
}
Loading
Loading