This RFD replaces the port-assignment portion of RFD #4. It proposes a cross-platform startup handshake in which a plugin selects its own loopback port and reports readiness to Hipcheck over standard output. It preserves the existing gRPC query protocol.
Today, Hipcheck selects a TCP port, passes it as --port <PORT>, and then
tries to connect. The core cannot reserve that port while the child is starting:
another process can claim it after Hipcheck checks it and before the plugin
binds it. In addition, the configured port range is not a meaningful contract
when the OS selects an ephemeral port.
Under the proposed protocol, Hipcheck starts a compatible plugin with
--hipcheck-control=stdio and a per-launch nonce. The plugin binds
127.0.0.1:0, starts serving gRPC, and writes one machine-readable readiness
record to stdout. Hipcheck validates the record, connects to the reported port,
and proves that it reached the launched plugin by completing a nonce-bound
startup RPC. Only then is the plugin considered started.
SDK users will not need to implement the handshake themselves. A non-SDK plugin needs to add one small CLI behavior and one RPC implementation.
For handshake-capable plugins, Hipcheck starts the manifest entrypoint with:
--hipcheck-control=stdio
--hipcheck-launch-nonce=<base64url-encoded-32-random-bytes>
Hipcheck pipes stdout, pipes stderr, and does not pass --port. It continues
to forward stderr as plugin logs. Stdout is reserved exclusively for control
records for the life of this mode; plugins must not write diagnostics, logs, or
application output there. Hipcheck should treat any non-control stdout before
the ready record as a protocol error, include a bounded escaped excerpt in its
diagnostic, then terminate the child.
The nonce is generated afresh for every spawn attempt, is not logged, and is provided only in the child command line. Command-line visibility means it is not a secret from same-user processes; it binds Hipcheck's readiness check to the child it intended to launch and detects stale or unrelated listeners.
After binding and starting its gRPC server, a plugin writes exactly one UTF-8
JSON object followed by \n to stdout, flushes it, and keeps the stream open:
{"hipcheck_control":1,"event":"ready","port":51342}
The record is limited to 4 KiB. Version 1 requires these fields:
| Field | Type | Requirement |
|---|---|---|
hipcheck_control | integer | Must equal 1. |
event | string | Must equal "ready". |
port | integer | Must be in 1..=65535. |
Unknown fields are ignored to permit additive changes. A plugin must bind the
reported port to IPv4 loopback (127.0.0.1), not 0.0.0.0 or a public
interface. The SDKs must check both bind success and the actual local address
before writing readiness. Hipcheck connects only to 127.0.0.1:<port>.
A future control-protocol version may add records after readiness. Version 1 has no such records; Hipcheck closes its stdout read handle after accepting the ready record. This prevents unrelated stdout data from being interpreted as control traffic.
Transport connection alone is not proof that the launched plugin owns the reported port. Before accepting readiness, Hipcheck invokes this new plugin service method:
message StartupHandshakeRequest {
// Exact value passed in --hipcheck-launch-nonce.
string launch_nonce = 1;
}
message StartupHandshakeResponse {
// Protocol revision understood by this plugin; initially 1.
uint32 control_protocol_version = 1;
// Canonical plugin identifier from the plugin manifest, e.g. "mitre/git".
string plugin_id = 2;
}
service PluginService {
rpc StartupHandshake(StartupHandshakeRequest)
returns (StartupHandshakeResponse);
}
The plugin stores the nonce received at launch. StartupHandshake succeeds
only when the request nonce matches it, and returns its compiled or manifest
plugin identity. Hipcheck requires version 1 and compares plugin_id with
the resolved manifest identity. A mismatch is a startup failure. This is also
the first real gRPC request, avoiding a false-ready result from a lazy client
connection.
The server may reject repeated handshakes after the first successful one. The nonce must never be exposed by other RPCs or included in errors or logs.
spawn child -> wait for one ready record -> StartupHandshake RPC -> ready
| |
+-- timeout/exit/error ----+--> kill, reap, retry
Hipcheck uses one configurable startup deadline per spawn attempt. It races three events: child exit, receipt of a complete stdout line, and deadline expiry. It rejects malformed JSON, oversized lines, missing or invalid required fields, invalid ports, early EOF, child exit, and failed/mismatched handshakes. For every rejected attempt Hipcheck kills and reaps the child before retrying; it must not leave a failed process or stderr-forwarding task behind.
The current max-spawn-attempts remains the number of complete attempts. The
current connection backoff settings are retired for handshake-capable plugins:
the readiness record is emitted only after the server is listening. A separate
startup-timeout configuration value replaces their timing role. The initial
default should be conservative and platform-independent (for example, 30
seconds); exact default and configuration syntax are implementation details to
settle with the implementation PR.
Inherited standard streams are available to normal child processes on all supported host platforms and are supported by essentially every language. They require no path naming, handle-passing API, permissions model, or platform-specific dependency from plugin authors. Reserving stdout and using stderr for logs creates an unambiguous, testable boundary.
The alternative of passing a pre-bound TCP listener is stronger only if the listener can be transferred without closing it. That requires different handle inheritance rules and APIs on Unix and Windows and is awkward for plugins written without an SDK. The proposed design removes port selection races while keeping the only required plugin operations—bind, write one JSON line, and serve one RPC—portable.
plugin.kdl; see
Compatibility and manifest changes.StartupHandshake to the common
protobuf crate and all current protocol consumers.PluginExecutor::get_available_port() and --port injection for
handshake-capable plugins with stdout control parsing and nonce generation.StartupHandshake before constructing PluginContext; record the
reported port only as diagnostic/runtime metadata.StartupControl parsed from the two new CLI arguments.PluginServer::listen_local behavior for legacy
mode. In control mode, expose a new SDK-owned run/listen_for_hipcheck
path that binds loopback port 0, starts serving, emits and flushes the
readiness record, and implements StartupHandshake.get_parser_for and preserve --port for
legacy mode.PluginServer.listen (or introduce a clearly named wrapper) to bind
127.0.0.1:0, obtain the assigned port, start gRPC, write and flush the
readiness record, and retain the nonce.StartupHandshake with the same nonce, identity, and
version checks as the Rust SDK.A third-party plugin can support the new protocol in any language by doing the following:
--hipcheck-control=stdio and --hipcheck-launch-nonce=....127.0.0.1:0; retrieve the assigned port.StartupHandshake to compare the supplied nonce exactly, return
control protocol version 1, and return the plugin's canonical ID.The published plugin-author guide will include language-agnostic pseudocode, wire-format examples, and a small conformance-test executable. The test will launch the plugin, validate the stdout record, call the RPC, and exercise common failure diagnostics without requiring an SDK.
The manifest must declare startup protocol support, rather than relying on failed invocation to discover it. This RFD proposes an additive field:
startup protocol="stdio-v1"
The absence of startup means legacy port-v0. Hipcheck selects stdio-v1
when declared and otherwise uses the current --port behavior. A future
manifest revision may express a supported-protocol list if negotiation becomes
necessary; one explicit value is sufficient for this migration.
Hipcheck releases the feature in these phases:
startup protocol="stdio-v1" and release compatible artifacts. Exercise
both Windows and Unix integration coverage before changing defaults.stdio-v1 artifacts and update manifests.
Hipcheck continues supporting port-v0 for at least two Hipcheck minor
releases after the first stable SDK releases.port-v0, port-range configuration, and --port injection. Hipcheck gives
a clear error identifying legacy artifacts and the last compatible core
release. The project should announce the target major release no later than
the deprecation phase.This field changes the plugin launch interface but not policy-file syntax or plugin discovery. Plugin authors must publish a new artifact and manifest version because an older artifact cannot obey the new launch contract.
Retries make collisions less likely but cannot eliminate check-then-bind races. They also obscure readiness failures and do not make a selected port an owned resource. Rejected.
This fixes port assignment but Hipcheck could connect to an unrelated process if the plugin exits after emitting its port and another listener reuses it. The explicit nonce-bound RPC is small and closes this correctness gap.
These mechanisms are excellent platform-native IPC, but paths, permissions, and APIs differ materially across Windows and Unix. Requiring them would make non-SDK plugins harder to author. They remain possible future transports, but are not the portable baseline.
This can eliminate the remaining plugin-side bind race, but requires reliable descriptor/handle inheritance across Unix and Windows, plus language-specific adoption. It is unsuitable as the universal plugin contract and would not improve the normal user experience enough to justify that burden.
This reverses the conventional stdout-data/stderr-diagnostics split and still requires a cross-platform control transport. Reserving stdout is simpler and matches the current expectation that plugin logs use stderr. Rejected.
The protocol does not make untrusted plugin execution safe. The manifest identity comparison and nonce prevent accidental wiring to the wrong local service, while loopback binding prevents exposing an unauthenticated plugin API to the network. Hipcheck must cap control-record size, redact nonce values from diagnostics, and always reap failed children. The control parser must not block stderr draining, since a verbose plugin can otherwise deadlock on a full stderr pipe before it becomes ready.
StartupHandshake become a general GetPluginInfo method used after
startup, or remain narrowly scoped to launch verification?startup-timeout default best accommodates container-backed
plugins without making ordinary failures slow?hc, published as a separate
binary, or both?