styx

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 8, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

README

Styx

Styx

CI Go Reference Release

Styx is a Go plugin framework for local, same-machine, process-isolated plugin communication. It replaces gRPC-over-Unix-domain-socket with a shared-memory data plane (descriptor rings, payload arena, eventfd wakeups), targeting unary RPC round-trips in the low single-digit microseconds. Plugins are separate executables with their own runtime and crash boundary; users define services in standard protobuf service blocks and get gRPC-style generated clients and servers, seeing no shared-memory details.

Why "Styx"?

In the old maps of the underworld, the Styx is the river between two worlds — the boundary itself. That's a plugin framework: your host lives in one process, your plugin in another world entirely, with its own runtime and its own crash domain. When it dies, it dies over there. The boundary is the point — but a boundary you can't cross efficiently is just a wall, so everything depends on the ferry.

Styx is the ferry, and the fare is nearly nothing. Both banks touch the same water: a sealed shared-memory region — descriptor rings, a slab arena, eventfd wakeups — carries a unary round trip in ~2.4 µs where gRPC-over-UDS takes ~16. One fixed-size memfd, resident memory pay-as-you-touch. No daemons, no sidecars — one river, two banks.

The mythology holds up under load. The gods swore unbreakable oaths on the Styx — ours are the frozen shm-abi.md and stream-protocol.md. Achilles was dipped in it and came out nearly invulnerable — this transport was dipped in a chaos suite, a differential oracle, and a leak soak. And with state-preserving hot reload, Styx is the rare river you can cross back over: a plugin goes down, its state ferries home, its successor picks up where it left off.

Installation

go get github.com/arloliu/styx

Quickstart

Define a service in protobuf:

syntax = "proto3";
package echo;

service Echo {
  rpc Say(SayRequest) returns (SayResponse);
  rpc Blob(BlobRequest) returns (BlobResponse);
}

message SayRequest { string message = 1; }
message SayResponse { string message = 1; }

message BlobRequest { bytes payload = 1; }
message BlobResponse { bytes payload = 1; }

Generate client and server stubs:

protoc --go_out=. --go-styx_out=. echo.proto

Implement and serve the plugin (examples/echo/plugin/main.go):

package main

import (
	"context"
	"os"

	"github.com/arloliu/styx"
	"github.com/arloliu/styx/examples/echo/echopb"
)

type echoServer struct{}

func (echoServer) Say(ctx context.Context, req *echopb.SayRequest) (*echopb.SayResponse, error) {
	return &echopb.SayResponse{Message: req.GetMessage()}, nil
}

// Blob echoes the payload back unchanged. Unlike Say's string field, bytes
// avoids the extra []byte<->string conversion on both sides of the call, so
// this is the representative shape for bulk binary payloads.
func (echoServer) Blob(ctx context.Context, req *echopb.BlobRequest) (*echopb.BlobResponse, error) {
	return &echopb.BlobResponse{Payload: req.GetPayload()}, nil
}

func main() {
	srv := styx.NewPluginServer(styx.PluginServerConfig{})
	echopb.RegisterEchoServer(srv, echoServer{})
	if err := srv.Serve(); err != nil {
		os.Exit(1)
	}
}

Call it from the host (examples/echo/host/main.go):

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/arloliu/styx"
	"github.com/arloliu/styx/examples/echo/echopb"
)

func main() {
	if len(os.Args) != 2 {
		fmt.Fprintln(os.Stderr, "usage: echo-host <plugin-path>")
		os.Exit(2)
	}

	host := styx.NewHost(styx.HostConfig{
		Plugins: []styx.PluginSpec{{
			Name:     "echo",
			Path:     os.Args[1],
			Services: []styx.ServiceRequirement{echopb.EchoRequirement()},
		}},
	})

	// Supervisor events are a subscription, never a callback invoked on an
	// internal goroutine holding a lock — a real host observes them like
	// this instead of polling.
	go func() {
		for ev := range host.Events() {
			fmt.Fprintf(os.Stderr, "event: plugin=%s kind=%d err=%v\n", ev.Plugin, ev.Kind, ev.Err)
		}
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := host.Start(ctx); err != nil {
		fmt.Fprintln(os.Stderr, "start:", err)
		os.Exit(1)
	}
	defer func() { _ = host.Stop(ctx) }()

	client := echopb.NewEchoClient(host.Plugin("echo"))

	resp, err := client.Say(ctx, &echopb.SayRequest{Message: "hello"})
	if err != nil {
		fmt.Fprintln(os.Stderr, "say:", err)
		os.Exit(1)
	}

	fmt.Println(resp.GetMessage())
}

More runnable examples are under examples/: host-side streaming of all three shapes (examples/streaming/), a state-preserving hot-reload (examples/hot-reload/), a handler slow enough to backpressure its caller, with the counters that name it (examples/slow-handler/), and a real consumer's device-plugin lifecycle contract defined as an ordinary Styx service (examples/device-gateway/; see docs/device-gateway-integration.md for the full contract). Coming from hashicorp/go-plugin? See docs/migration-from-go-plugin.md.

For what graceful shutdown, crash/restart, and hot-reload actually do to a running plugin — and what it means when a reload fails — see docs/plugin-lifecycle.md.

Configuration

The Quickstart above leaves every PluginSpec and PluginServerConfig field at its default. Real deployments usually set at least one of: Transport (which data-plane transport to negotiate — shared memory, Unix domain sockets, or let the host pick), MaxPayload (the single-field way to size a plugin for payloads and streamed messages larger than the stock default, deriving the shared-memory geometry and the burst/chunking ceilings underneath it), Geometry (hand-authoring that shape directly, for a deployment MaxPayload's stock derivation doesn't fit), Restart (the crash-restart policy), or Services (the version range a host requires from a plugin).

See docs/configuration.md for the full field-by-field guide, including a plain-language walkthrough of shared-memory geometry (ring capacity, lifecycle reserve, size classes) for readers who don't already know those terms.

Observability

host.Events() (shown in the Quickstart above) is a subscription to every plugin's lifecycle transitions — spawned, ready, unhealthy, crashed, restarting, or given up for good. host.Stop closes it once it has torn the host down — after a brief wait for you to take what the shutdown published — so the range loop above ends with the host rather than outliving it. Give Stop a context with its own budget (not an already-canceled one, such as the context you started under): an expired budget still tears every plugin down and reaps it, but Stop returns without waiting for that to finish, so the loop ends slightly after the call rather than before it. A Host whose teardown has begun is done — Start on it fails with ErrHostStopped, so build a new one to reconnect. HostConfig.Logger and HostConfig.Metrics cover structured diagnostics and counters for the same transitions, so a real host usually configures all three rather than reimplementing logging inside an Events() consumer. See docs/supervisor-events.md for what each event means and what's worth reacting to versus just logging.

Status

Styx is feature-complete on both data-plane transports — shared memory and Unix domain sockets, unary and streaming RPC, supervised plugin lifecycle with hot-reload — and validated by a differential test suite against the UDS oracle, a fault-injection (chaos) suite, and a long-running leak soak.

Current release: v0.5.0 (see CHANGELOG.md). Pre-1.0: the public Go API may still move between minor versions; the wire contracts (shm-abi.md, stream-protocol.md) are frozen and change only by explicit, versioned amendment.

At a 64-byte payload with one in-flight call, a unary round trip measures a p50 of 2.4 µs over shared memory, against 7.7 µs over Unix domain sockets and 15.9 µs over gRPC-over-UDS. Against hashicorp/go-plugin, Styx over shared memory is faster in all 24 cells of the comparison matrix — 1.65× to 4.72× on throughput, across payloads from 64 B to 1 MiB and concurrency from 1 to 64.

See docs/benchmark.md for what each suite measures, how to reproduce every number, and which of them CI actually gates; docs/performance-headroom.md records why the transport is not faster still and which optimization levers remain open.

For the full design, see docs/specs/2026-07-16-styx-design.md.

Documentation

Overview

Package styx is the single public import for both host and plugin authors. It provides the complete framework for local, same-machine, process-isolated plugin communication: the Host and PluginServer types for lifecycle management, generated client and server stubs for RPC calls, and a comprehensive error taxonomy distinguishable with errors.As/Is.

Styx preserves the isolation model of hashicorp/go-plugin — plugins are separate executables with their own runtime and crash boundary — but replaces gRPC-over-Unix-domain-socket with a shared-memory data plane (memfd rings + slab arena + eventfd wakeups). Unary RPC calls target the low single-digit microseconds; users define services in standard protobuf syntax and never see shared memory, ring indices, or eventfds.

See the design spec (docs/specs/2026-07-16-styx-design.md) for the full architecture, and examples/echo for a runnable quickstart.

Error taxonomy and the IsRetryable classifier.

Streaming RPC public seam: OpenStream / RegisterStreamHandler, the reader-loop routing for the six STREAM_* kinds and stream-teardown CANCEL, rejection-frame emission, and the status-code -> styx-sentinel translation. The connection-level streaming engine lives in internal/rpcruntime; this file wires it into the public styx package, where the transport-specific imports it needs are legal.

Index

Constants

View Source
const ConsumeFaultEscalationDisabled = shmtransport.ConsumeFaultEscalationDisabled

ConsumeFaultEscalationDisabled switches off the consume-fault teardown for the side it is assigned to -- PluginSpec.ConsumeFaultRunThreshold for this Host, PluginServerConfig.ConsumeFaultRunThreshold for the plugin. Frames that cannot be consumed are still discarded and still fail their own calls; no run of them makes that side tear the region down.

Reach for it when a region is being restarted for a consumer that is slow rather than broken. The cost arrives only once BOTH sides are set, and it is that a peer publishing bytes neither side can use will keep a region alive indefinitely, failing every call on it. Set on one side alone this buys less than it appears to -- see below.

It disables one side, and a region has two

Each side runs the guard over its own inbound stream, the threshold is never negotiated or carried to the peer, and tearing the region down takes only one side: the teardown stops both. So setting this here stops THIS side from tearing the region down and leaves the other side's guard armed at its default, still able to do it.

Standing the behavior down for a region means setting it on both sides. A Host running a plugin binary it does not build cannot set the plugin's half, and for that deployment the teardown cannot be fully switched off -- raising both this threshold and, where possible, the plugin's is the available remedy.

View Source
const PluginHeartbeatInterval = supervisor.DefaultHeartbeatInterval

PluginHeartbeatInterval is the fixed cadence at which a plugin sends heartbeats to its Host. It is not negotiated and neither side can configure it: both derive it from this one value.

It is the floor for PluginSpec.HeartbeatTimeout, and the reason a floor exists at all -- a Host learns a plugin is alive only when a heartbeat arrives, so a wait shorter than the cadence those heartbeats are sent at expires on a healthy plugin. It is also the granularity of PluginSpec.WedgeWindow, which is measured in heartbeat sends rather than in host time.

Variables

View Source
var (
	// ErrPluginUnavailable reports that a call was not admitted because no live
	// instance is routed for the named plugin — it isn't running, its prior
	// instance is still stopping, or a crash was detected before the request
	// was ever published. It is retryable.
	ErrPluginUnavailable = errors.New("styx: plugin unavailable")
	// ErrDrained reports that a call was refused at a hot-reload's admission
	// cutoff, before it was ever submitted. It is retryable.
	ErrDrained = errors.New("styx: plugin draining")
	// ErrRequestDeclined reports that the plugin took the request off the wire
	// and could not turn it into anything it was able to dispatch, so it answered
	// with a refusal rather than leaving the call unanswered (shm-abi.md §9). No
	// handler ran and the request had no effect, so it is retryable.
	ErrRequestDeclined = errors.New("styx: request declined")
	// ErrOutcomeUnknown reports that a call's request may or may not have
	// reached the plugin before a crash or teardown, so its side effects (if
	// any) are unknown. It is not retryable — reissuing the call could repeat
	// an effect it already had.
	ErrOutcomeUnknown = errors.New("styx: call outcome unknown")
	// ErrIncompatible reports that a plugin could not be started: a binary
	// identity mismatch against PluginSpec.BinarySHA256, or a handshake
	// negotiation failure. errors.Is(err, ErrIncompatible) matches any
	// *IncompatibleError; errors.As recovers the structured detail,
	// including which of the two occurred (see IncompatibleError,
	// IncompatibleKind).
	ErrIncompatible = errors.New("styx: incompatible handshake")
	// ErrInvalidConfig reports a configuration value that cannot be honored,
	// refused by Start before any process is spawned. errors.Is(err,
	// ErrInvalidConfig) matches any *ConfigError; errors.As recovers the
	// structured detail (see ConfigError).
	ErrInvalidConfig = errors.New("styx: invalid configuration")
	// ErrDeadlineExceeded reports that the call's context deadline elapsed
	// before a terminal outcome arrived.
	ErrDeadlineExceeded = errors.New("styx: deadline exceeded")
	// ErrCanceled reports that the caller's context was canceled before a
	// terminal outcome arrived.
	ErrCanceled = errors.New("styx: call canceled")
	// ErrBackpressure reports that a shared-memory send was refused because its
	// ring or payload arena was full. It is retryable once capacity frees up.
	ErrBackpressure = errors.New("styx: backpressure")
	// ErrPayloadTooLarge reports that a call, stream open, or stream send was
	// refused because its encoded payload exceeded the transport's per-frame
	// limit (uds's framing constant, or shm's geometry-derived
	// per-direction max_payload).
	// The rejection happens before any byte is published, so the outcome is
	// known and this is never ErrOutcomeUnknown. It is not retryable: an
	// identical retry at the same size fails the identical way.
	ErrPayloadTooLarge = errors.New("styx: payload too large")
	// ErrPoisoned reports that a conformance violation desynchronized a
	// shared-memory region, tearing the connection down. It is not retryable on
	// the same instance; the supervisor's restart policy runs.
	ErrPoisoned = errors.New("styx: region poisoned")
	// ErrServiceNotFound reports that the called service has no handler
	// registered on the plugin.
	ErrServiceNotFound = errors.New("styx: service not found")
	// ErrMethodNotFound reports that the called method has no handler
	// registered within its service on the plugin.
	ErrMethodNotFound = errors.New("styx: method not found")
	// ErrPluginAlreadyStarted reports that Start named a plugin this Host has
	// already started. A Host holds one supervisor per name, and its per-name state
	// is built on that: one routing entry that dispatches the name's calls, one
	// teardown gate that Stop clears. A second supervisor under the same name would
	// overwrite the routing its predecessor still owns and share that one gate, so
	// whichever finished tearing down first would clear it for both.
	//
	// Started, not still running, is the condition: a Host keeps a terminal
	// instance's supervisor and event relay registered under the name until Stop,
	// so the name is taken by an instance that has given up for good exactly as it
	// is by one that is serving. Replacing a serving instance with a fresh process
	// is what Reload does; recovering from EventGaveUp means building a new Host,
	// which is the only respawn path this API has. A plugin whose start FAILED
	// leaves no supervisor behind and can be started again.
	//
	// It is a lifecycle/framework error, not a per-call one, so IsRetryable does
	// not classify it.
	ErrPluginAlreadyStarted = errors.New("styx: plugin already started")
	// ErrPluginStopping reports that a Start or Reload named a plugin whose
	// previous instance is still shutting down, which happens two ways: a Stop
	// deadline expired before that instance's supervisor joined, or a Start was
	// abandoned when a Stop began and handed its supervisor on rather than
	// waiting out a spawn while holding the host's lock. Either way the name is
	// retained in a stopping state until the join completes. Starting a second
	// instance under the same name while the first is still stopping would let
	// two supervisors race for one name, so both Start and Reload reject it
	// with this error until the prior instance finishes tearing down
	// (automatically once its Run exits, or on a retried Stop). Retrying is
	// what recovers a Reload; a Start of a name that a Host's own Stop is
	// tearing down never recovers, because that Stop closed the Host to new
	// plugins — see ErrHostStopped, which every other name reports in that
	// state. It is a lifecycle/framework error, not a per-call one, so
	// IsRetryable does not classify it.
	ErrPluginStopping = errors.New("styx: plugin still stopping")
	// ErrHostStopped reports that Start was called on a Host whose Stop has begun.
	// A Host is single-use: that teardown releases the background workers the Host
	// owns for its whole life — the observability dispatchers and the Events()
	// subscription — and none of them is rebuilt, so a plugin started afterward
	// would run with its lifecycle events going nowhere and no metrics or logs
	// reported. A plugin admitted while the teardown is still in flight is worse
	// still: the teardown already decided which runtimes it owns, so nothing would
	// ever stop that one. Start therefore reports this from the moment a Stop
	// begins, not only once one has finished, and a Start abandoned mid-spawn by an
	// arriving Stop reports it too. Build a new Host instead; a caller that
	// reconnects by rebuilding one is the case this protects. A name that Stop is
	// still tearing down reports ErrPluginStopping, which is the same verdict for
	// this Host with the specific reason attached. It is a lifecycle/framework
	// error, not a per-call one, so IsRetryable does not classify it.
	ErrHostStopped = errors.New("styx: host stopped")
	// ErrUnknownPlugin reports that a call named a plugin this Host's
	// HostConfig.Plugins never declared. It is distinct from
	// ErrPluginUnavailable, which means a declared plugin isn't currently
	// running; this means the name has no supervisor to ever have run it. It is
	// a lifecycle/framework error, not a per-call one, so IsRetryable does not
	// classify it.
	ErrUnknownPlugin = errors.New("styx: unknown plugin")
	// ErrStreamAlreadyClosed reports that there was nothing left to hand back or
	// hand over, because the stream — or the one direction being closed — is
	// already closed or is being closed by somebody else. Three paths return it,
	// and they support different conclusions, so each is named with what a caller
	// may actually infer. IsRetryable is false for all three.
	//
	// From OpenStream: the stream recorded a completed outcome at a point where its
	// STREAM_OPEN provably never reached the peer, so there is no usable stream and
	// no peer result to return. A completion the peer actually produced is not this
	// error: OpenStream returns that stream to the caller, who drains the delivered
	// payloads and then reads io.EOF. It is distinct from the peer-error and teardown
	// outcomes, which carry their own mapped errors; it names specifically a completed
	// outcome with no underlying error of its own, and it also guards the theoretical
	// case of a peer-error or crashed outcome whose recorded error is nil. Not
	// retryable: the guard case cannot rule out a peer that already processed the
	// stream, and reissuing blindly in that case could repeat a side effect.
	//
	// From Stream.CloseSend, when this side already half-closed its send direction:
	// the second CloseSend has nothing to close (stream-protocol.md §6.5). The
	// stream itself may be perfectly healthy — the receive direction can still be
	// delivering, and a bidi stream stays live until the peer closes too. The
	// half-close already succeeded, so there is nothing to retry and reissuing can
	// only fail the same way. This is also what a close on a NORMALLY COMPLETED
	// stream reports: a completion means both directions closed, so this side's own
	// half-close is what committed, and a completion records no error for the close
	// to report instead. A close on a stream that terminated any OTHER way reports
	// that stream's terminal outcome rather than this sentinel.
	//
	// From Stream.CloseSend, when another goroutine currently OWNS the half-close:
	// publication has a single in-progress owner, and the caller that loses that
	// claim is refused without sending. Here the direction is NOT known to be
	// closed. The owner may yet fail definitively before the transport accepts
	// anything — or return on its own context before it sends at all — and either
	// restores the direction to closable, so this answer says only that a close was
	// in flight at that instant. Nothing is safe to conclude about the direction's
	// final state from it. Retrying is not the remedy: concurrent CloseSend on one
	// stream is a caller programming error (the send direction has a single owner,
	// stream-protocol.md §3.1), and a caller that observes this has a race to fix
	// rather than a call to reissue.
	ErrStreamAlreadyClosed = errors.New("styx: stream already closed")
	// ErrHeartbeatsMissed reports that a plugin was declared unhealthy after
	// missing enough consecutive heartbeats. errors.Is(err,
	// ErrHeartbeatsMissed) matches any *MissedHeartbeatsError; errors.As
	// recovers the exact count (see MissedHeartbeatsError).
	ErrHeartbeatsMissed = errors.New("styx: missed consecutive heartbeats")
	// ErrWedged reports that a plugin was declared unhealthy because its
	// heartbeat classifier detected a stalled component. errors.Is(err,
	// ErrWedged) matches any *WedgedError; errors.As recovers which
	// component wedged (see WedgedError, WedgeKind).
	ErrWedged = errors.New("styx: heartbeat classifier detected a wedged plugin")
)
View Source
var ExpBackoff = supervisor.ExpBackoff

ExpBackoff is the supervisor's exponential-backoff implementation, aliased here so ExpBackoff and supervisor.ExpBackoff refer to the identical value.

View Source
var NoRestart = supervisor.NoRestart

NoRestart is the supervisor's no-restart policy, aliased here so NoRestart and supervisor.NoRestart refer to the identical value.

Functions

func IsRetryable

func IsRetryable(err error) bool

IsRetryable reports whether err represents a failure the caller may safely retry by issuing a new call. It returns false for ErrOutcomeUnknown and anything wrapping it; for a *PluginCrashError it returns the value of Dispatched negated; true for ErrPluginUnavailable, ErrDrained, and ErrBackpressure (transient: the caller can wait, or the supervisor will restart); true for ErrRequestDeclined, which is not transient but is safe — no handler ran, so a fresh attempt repeats no effect, though a decline with a deterministic cause answers the same way again and a caller that reissues it immediately will spin; false for everything else, including a nil error, an application *Status, PluginPanicError, ErrIncompatible, ErrDeadlineExceeded, ErrCanceled, ErrPoisoned, ErrServiceNotFound, ErrMethodNotFound, and ErrPayloadTooLarge.

func RegisterIdentityName added in v0.3.0

func RegisterIdentityName(id uint64, name string)

RegisterIdentityName records that id is the FNV-1a-64 hash of name (the same algorithm fnv64a uses, and the one InvokeID/OpenStreamID's generated callers hash a service or method name with), so a later *PluginPanicError raised through the corresponding precomputed-ID call reports name instead of id rendered as hex.

Generated code is the intended caller: protoc-gen-go-styx emits one call per service and per method from each generated file's init function, covering the host side (which never calls Register<Service>Server, so had no other place to learn the name) and the plugin side alike, regardless of which one a given binary links. Hand-calling it is safe — useful only for a hand-written client that bypasses codegen and still wants readable panic identities.

Registering the same (id, name) pair more than once is a no-op, expected whenever two generated packages describe the same service. Registering an id already claimed by a DIFFERENT name is a genuine FNV-1a-64 collision: RegisterIdentityName never panics or returns an error for it — an init-time mapping conflict must not take a process down — it instead stops trusting that id from then on, so a PluginPanicError for it falls back to the id rendered as hex rather than risk reporting whichever name happened to register first.

func StreamError

func StreamError(err error) error

StreamError translates an internal streaming error — a Stream's Outcome().Err, or an error returned by SendMsg/RecvMsg/CloseSend — into the styx error taxonomy, so an application observing a stream failure gets the same sentinels unary Invoke returns. Generated streaming code calls it at the boundary where it surfaces a stream error to user code.

It maps the engine's local terminal sentinels (a local cancel or an elapsed budget), a second half-close of an already-closed send direction (ErrStreamAlreadyClosed), a send that provably never reached the peer because it exceeded the transport's per-frame limit (ErrPayloadTooLarge), a send the transport's closure ended after admission (ErrOutcomeUnknown — a closed transport does not prove the frame unpublished, so the outcome is genuinely unknown), and the four framework stream status codes a peer STREAM_ERR may carry (stream-protocol.md §9.1): CANCELED -> ErrCanceled, DEADLINE -> ErrDeadlineExceeded, INCOMPATIBLE -> ErrIncompatible, BACKPRESSURE -> ErrBackpressure. A peer STREAM_ERR carrying an application status surfaces as a *styx.Status, exactly as a unary error response does. A nil error stays nil, and io.EOF (normal remote/stream end) is returned unchanged.

The CANCELED and closed-transport arms preserve a wrapped cause when the error carries one (a visible chunked-train failure, stream-protocol.md §13.8 shapes 3 and 4): the returned error still satisfies errors.Is(_, ErrCanceled) or errors.Is(_, ErrOutcomeUnknown), but the cause stays reachable through errors.Is/As on that same returned value, rather than being discarded into the bare sentinel. Calling StreamError again on its own previous return value is idempotent -- generated streaming code does exactly this (Send/Recv wrap an already-translated *Stream method return a second time), and gets the identical result back rather than a doubly-wrapped one.

func WithDedupKey

func WithDedupKey(ctx context.Context, key DedupKey) context.Context

WithDedupKey returns a copy of ctx carrying key. The key rides the context so host-side code can read the same DedupKey back on each attempt of one logical operation via DedupKeyFromContext; it is host-local and is not transported to the plugin handler (see DedupKey), and Styx itself never acts on it.

Types

type BackoffFunc

type BackoffFunc = supervisor.BackoffFunc

BackoffFunc is the supervisor's backoff-function type, aliased here so BackoffFunc and supervisor.BackoffFunc name the identical type.

type ClientConn

type ClientConn struct {
	// contains filtered or unexported fields
}

ClientConn is a host-side connection to a single running plugin instance. Generated service client constructors return it (a grpc.ClientConnInterface analog).

ClientConn is safe for concurrent use: Invoke, InvokeID, and the stream-open methods (OpenStream, OpenStreamID, OpenServerStreamID) may all be called concurrently. Each Stream returned by a stream-open carries its own narrower goroutine rule (see Stream).

func (*ClientConn) Invoke

func (c *ClientConn) Invoke(ctx context.Context, service, method string, req, resp proto.Message) error

Invoke calls the named method of the named service on the plugin this ClientConn is connected to, encoding req and decoding into resp via the negotiated Codec. Generated client stubs are the only intended caller of Invoke — hand-calling it is supported but bypasses no safety mechanism; it's a plain typed RPC call.

func (*ClientConn) InvokeID

func (c *ClientConn) InvokeID(ctx context.Context, serviceID, methodID uint64, req, resp proto.Message) error

InvokeID is Invoke with the FNV-1a-64 service/method routing hashes supplied directly, so a caller that already holds them skips the per-call hash. Generated unary client stubs call it, passing the service/method ID constants protoc-gen-go-styx precomputed at generation time (with the same algorithm as fnv64a) — so a generated unary call never rehashes a name on the hot path. Hand-written code uses the name-based Invoke; both land on the identical (service, method) routing, mirroring OpenStream/OpenStreamID.

func (*ClientConn) InvokeIDFactory

func (c *ClientConn) InvokeIDFactory(
	ctx context.Context, serviceID, methodID uint64, req proto.Message, newResp func() proto.Message,
) (proto.Message, error)

InvokeIDFactory is InvokeID for a caller that lets the runtime own the response message: instead of supplying one to decode into, it supplies newResp to construct one, and receives the decoded message back.

That inversion is what lets the runtime decode a response straight out of the transport's own memory, without copying the bytes out first — the decode happens on the receive goroutine while those bytes are still readable, and the message reaches this caller only afterwards, through the same channel every other call outcome travels on. Generated unary client stubs call it and type-assert the result, so the message this allocates replaces the one the stub used to allocate itself.

newResp is called at most once for the call, on the receive goroutine, and only if a response arrives while the call table still holds the call — so a cancelled call whose cancellation has landed costs no construction and no decode. One that terminates inside the delivery window may still cost a single construction and decode, whose message is then dropped undelivered. It MUST be allocation-only: it holds up every later inbound frame while it runs, and it MUST return a message nothing else references, since a message this caller already held could be read here while the receive goroutine was still decoding into it. That last requirement is exactly why Invoke and InvokeID, which take a caller-supplied response message, keep decoding on the calling goroutine instead.

The returned message is the one newResp built, and it is this caller's alone and valid indefinitely: it borrows nothing from the transport, whatever memory it was decoded from, and no other goroutine holds a reference to it once this returns. Every error is the one the matching InvokeID call would return, plus one more: a response the codec cannot decode fails this call alone, as an unknown outcome, and leaves the connection serving.

It is safe for concurrent use, like every other call on a ClientConn.

func (*ClientConn) OpenServerStreamID

func (c *ClientConn) OpenServerStreamID(
	ctx context.Context, serviceID, methodID uint64, req proto.Message,
) (*Stream, error)

OpenServerStreamID opens a server-streaming stream by precomputed service/method hashes, marshaling req onto the STREAM_OPEN with the connection's negotiated codec (stream-protocol.md §6.3). Generated server-streaming client code calls it so the single request rides the OPEN encoded with the SAME codec every other message on the connection uses — never a hardcoded one — and the accepter, decoding with the negotiated codec, reads back exactly what was sent. The opener is half-closed-local at establishment: it sends no STREAM_MSG, so the peer's single STREAM_CLOSE completes the stream on its own. A peer that finishes that fast is a successful open — the returned stream drains the delivered payloads and then reports io.EOF, exactly as OpenStream documents.

func (*ClientConn) OpenStream

func (c *ClientConn) OpenStream(
	ctx context.Context, service, method string, opts ...StreamOption,
) (*Stream, error)

OpenStream opens a new stream call to the named method, mirroring Invoke's (service, method) shape. It computes the FNV-1a-64 service/method routing hashes, resolves the caller's deadline to a strictly-positive budget (materializing the connection default when none is set), proposes a credit N bounded by N_max, publishes the STREAM_OPEN, and returns the narrow *Stream. Generated code wraps the returned Stream with typed Send/Recv for the method's message types.

The returned stream's context is rooted in ctx, so canceling ctx cancels the stream — the gRPC-shaped semantics — and terminates it autonomously (its slot frees promptly) even if the caller performs no further operation; a cancel while this call is still publishing the OPEN returns the cancel outcome and no live stream.

A peer fast enough to finish the whole exchange before this call returns is a SUCCESS, not a failure: the stream is handed back already complete, its context canceled, and the caller drains it exactly as it drains any other stream — RecvMsg returns every payload the peer delivered, then io.EOF. Nothing the peer sent is lost, so a server-streaming caller must not treat a completed stream as an error case. A failed open, in contrast, always returns a nil stream with a non-nil error (a deadline, a cancel, a peer error, or the plugin going away).

Drain the returned stream with the ctx passed to this call, not the stream's own Context(): on a completed stream that context is already canceled, and RecvMsg selects across it alongside the terminal and remote-close signals, so passing it back in makes the read return ErrCanceled instead of io.EOF at random. Generated client code drains with the caller's ctx for exactly this reason.

Optimistic sends are permitted: the stream is live on the opener's side the moment the transport accepts the STREAM_OPEN (stream-protocol.md §7.4/§4.5), so the caller MAY Send and CloseSend immediately without waiting for any inbound frame. A STREAM_OPEN the accepter refuses (over-large credit, non-positive budget, or S_max reached) arrives back as a STREAM_ERR that terminates the stream through the ordinary inbound path (§4.7/§9.1).

func (*ClientConn) OpenStreamID

func (c *ClientConn) OpenStreamID(
	ctx context.Context, serviceID, methodID uint64, opts ...StreamOption,
) (*Stream, error)

OpenStreamID is OpenStream with the FNV-1a-64 service/method routing hashes supplied directly, so a caller that already holds them skips the per-call hash. Generated streaming client code calls it, passing the service/method ID constants the generator precomputed at generation time (protoc-gen-go-styx hashes once, at build time, with the same algorithm as fnv64a) — so a stream open never rehashes a name on the hot path. Hand-written code uses the name-based OpenStream; both land on the identical (service, method) routing.

type Code

type Code uint32

Code enumerates application-level status codes in a Status. Styx defines its own small enum (no gRPC dependency in this package).

const (
	CodeUnknown Code = iota
	CodeOK
	CodeInvalidArgument
	CodeNotFound
	CodeAlreadyExists
	CodeFailedPrecondition
	CodeAborted
	CodeUnavailable
	CodeInternal
	CodeUnimplemented
	CodeResourceExhausted
)

type ConfigError

type ConfigError struct {
	// Field names the offending configuration field, as written on the public
	// struct (e.g. "PluginSpec.HeartbeatTimeout").
	Field string
	// Reason states why the value cannot be honored, in terms of the constraint
	// it violates rather than the internals enforcing it.
	Reason string
}

ConfigError reports a configuration field whose value cannot be honored. It is detected before anything is spawned, so a plugin that fails this way never had a process, a handshake, or a lifecycle event. errors.Is(err, ErrInvalidConfig) matches any *ConfigError; errors.As(err, &configErr) recovers which field and why it was refused.

The value is refused rather than silently clamped: a clamp would leave the Host supervising on numbers the caller never chose and never sees.

func (*ConfigError) Error

func (e *ConfigError) Error() string

func (*ConfigError) Is

func (e *ConfigError) Is(target error) bool

Is reports whether target is ErrInvalidConfig.

type DedupKey

type DedupKey string

DedupKey is an application-chosen idempotency key: a value the calling code attaches to identify "this is attempt N of the same logical operation."

It is host-local: it rides the call context (attach it with WithDedupKey) and is readable host-side with DedupKeyFromContext, so host-side code can tag the calls of one logical operation and recognize the same key back on any of them. Reusing the same context — or setting the same key on a re-issued call — carries the key across those calls, but Styx mints no retries or attempts of its own around it.

It is NOT transported to the plugin — the data-plane frame carries no per-call metadata field, so a plugin handler cannot observe it. Delivering it to the plugin end to end would need a wire carrier the frame does not have; it is a possible future extension, not a current capability.

Styx does NOT deduplicate. It never inspects, compares, or acts on a DedupKey. Deduplication — deciding that two attempts are the same operation and suppressing the duplicate effect — is the application's responsibility, because only the application knows what a duplicate effect means for its domain. Pretending the framework could dedupe generically is how equipment gets double-actuated: the framework cannot know that "open valve 42" issued twice is the same physical action, so it must never promise to collapse them.

func DedupKeyFromContext

func DedupKeyFromContext(ctx context.Context) (DedupKey, bool)

DedupKeyFromContext returns the DedupKey carried by ctx and whether one was set. It reports false (and an empty key) for a context that carries no key.

type Event

type Event struct {
	Plugin string
	Kind   EventKind
	Time   time.Time
	Err    error
	// Revision is this event's position in its plugin's transition history, or
	// 0 if it did not advance that history.
	//
	// A Host retains one health record per plugin and applies each transition
	// to it exactly once, discarding any that arrives out of order — this
	// stream does reorder, because a critical Crashed is delivered ahead of an
	// informational Starting published before it. Revision is that record's own
	// position: an event that advanced the record carries the position it
	// advanced to, and one the record discarded as superseded carries 0. Both
	// are delivered here; only the first should update a view you maintain.
	//
	// Fold this stream by comparing Revision against what you have already
	// applied — the HealthSnapshot.Revision you seeded from, or the last
	// Revision you accepted — and ignoring anything not strictly greater. That
	// one comparison subsumes every reordering this stream can produce,
	// including a stale Starting after a terminal GaveUp, so it replaces both a
	// per-kind terminal latch and a re-check after seeding.
	//
	// Revision counts one plugin's transitions, not the stream's: it is 1 for a
	// plugin's first applied transition and counts up by one per transition
	// after it, for the Host's whole life — across crashes, restarts, hot
	// reloads, and retried Starts alike. Two plugins' Revisions are unrelated
	// numbers; compare only within one Event.Plugin.
	//
	// Revisions are dense where they are assigned and not where they are
	// delivered. The record numbers each transition as it applies it, but this
	// stream then hands critical events out ahead of informational ones queued
	// before them, so a revision the record assigned can arrive after a higher
	// one. Events() is bounded and drops under backpressure besides, so
	// "exactly once" is not on offer either.
	//
	// A gap between one accepted Revision and the next is therefore not a
	// count of what was lost. It means at least one of two things: transitions
	// this stream dropped, or a lower-numbered transition still in flight
	// behind the one that overtook it, which arrives later and the fold above
	// then ignores. Nothing tells the two apart, and no counter reports how
	// many events Events() dropped.
	//
	// What a gap does not cost you is the state: the highest Revision your
	// fold has accepted is the newest transition the Host applied and handed
	// over, whatever the stream reordered or dropped around it. Call Health to
	// resynchronize a view that needs more than that — it re-reads the
	// retained record whole.
	//
	// HealthSnapshot.MissedHeartbeats is outside this numbering. It is written
	// by the heartbeat path, which records no transition, advances no Revision,
	// and publishes no event of its own.
	Revision uint64
}

Event is one supervisor lifecycle notification. Err is populated for EventUnhealthy, EventCrashed, and EventGaveUp.

type EventKind

type EventKind int

EventKind enumerates the supervisor lifecycle event stream.

const (
	// EventStarting reports that the supervisor is spawning a plugin instance —
	// a first start or a restart — before its handshake has completed.
	EventStarting EventKind = iota
	// EventReady reports that an instance completed its handshake and data-plane
	// attach and is now serving calls.
	EventReady
	// EventUnhealthy reports that the heartbeat classifier judged a still-running
	// instance wedged — a stalled ring consumer with queued work, or a dispatch
	// owing a response with no running handler — so it is making no progress even
	// though it has not exited. Err carries which wedge was detected.
	EventUnhealthy
	// EventCrashed reports that an instance attempt failed. It covers a running
	// instance that exited unexpectedly or lost its connection; a spawned instance
	// that failed before it reached ready — a handshake or attach failure, where the
	// process did exist and EventStarting already reported it; and a spawn that
	// failed before any process existed at all. Err carries the failure detail. The
	// supervisor's restart policy then decides whether to try again.
	EventCrashed
	// EventRestarting reports that a crash will be retried: the restart policy
	// has scheduled another attempt and is backing off before it spawns the
	// replacement. The spawn itself is reported by the EventStarting that follows.
	EventRestarting
	// EventGaveUp reports the terminal outcome that no further restart will
	// happen. This is either the restart policy's budget being exhausted, or a
	// deterministic handshake incompatibility that retrying could never recover —
	// which gives up immediately, without consuming any restart budget. Err
	// carries the last failure detail.
	EventGaveUp
)

type HandshakeOffer

type HandshakeOffer struct {
	ProtocolMin, ProtocolMax uint32
	Transports               []Transport
	Codecs                   []string
	Features                 []string // names only; required/optional detail is in Reason

	// Services carries per-service version data for this side.
	// On HostOffer, it holds the host's declared requirements (PluginSpec.Services).
	// On PluginOffer, it holds the plugin's advertised versions as an
	// exact-version "requirement" (MinVersion == MaxVersion == advertised version).
	// nil when no per-service data is available.
	Services []ServiceRequirement
}

HandshakeOffer is the public summary of one side's negotiation offer, attached to IncompatibleError for inspection.

type HealthSnapshot added in v0.2.0

type HealthSnapshot struct {
	// Plugin is the name Health was asked about.
	Plugin string
	// State is the kind of this plugin's most recent lifecycle transition —
	// the identical EventKind Events() reports (EventStarting/EventReady/
	// EventUnhealthy/EventCrashed/EventRestarting/EventGaveUp), not a separate
	// health enum: a snapshot's state IS exactly the last transition observed,
	// so a second enum would only drift from this one. Before this plugin's
	// first transition is recorded, State reads as EventStarting's zero value
	// and LastTransition is the zero time; check LastTransition.IsZero to
	// tell a real Starting transition from one that has not happened yet.
	State EventKind
	// LastTransition is when State was recorded.
	LastTransition time.Time
	// LastError is the same translated error the corresponding Events() event
	// carried, or nil for a kind that carries none (e.g. EventReady).
	LastError error
	// Revision is the position in this plugin's transition history that this
	// snapshot reflects: 0 before its first transition is recorded, then the
	// Revision of the most recent one. It is what makes a snapshot comparable
	// to the event stream — an Event whose Revision is not strictly greater
	// than this one is a transition already reflected here — so seeding a view
	// from a snapshot and then folding Events() needs no other ordering rule.
	// See Event.Revision for how to fold, and for what a gap between two
	// accepted Revisions does and does not tell you.
	//
	// Revision 0 and a zero LastTransition mean the same thing and always
	// agree; prefer Revision == 0, which needs no time comparison.
	//
	// MissedHeartbeats is outside this numbering: it is maintained by the
	// heartbeat path, which records no transition and does not advance
	// Revision. A snapshot whose Revision is unchanged from the last one you
	// took can still report a different MissedHeartbeats.
	Revision uint64
	// MissedHeartbeats is the CURRENT run of consecutive missed heartbeats for
	// the instance State describes, not a lifetime total, and never another
	// instance's run. It is maintained entirely by the heartbeat path, never by
	// a lifecycle transition: it resets to zero once at a fresh instance's own
	// monitor loop entry (before that instance's first Recv), again on any
	// later received heartbeat, and again on a serviced reload's own reset.
	// A successor is reported Ready before its monitor loop entry can reset
	// anything, so a snapshot taken in that window reads zero rather than the
	// predecessor's leftover run: a plugin whose State already belongs to the
	// successor never carries a predecessor's silence in this count.
	MissedHeartbeats int
}

HealthSnapshot is a point-in-time copy of one plugin's retained health state: the kind of its most recent lifecycle transition, when that transition happened, the error it carried (if any), and its current run of missed heartbeats.

It is the pull-based, level-triggered counterpart to Events(): Events() reports each transition once, as it happens, while a HealthSnapshot answers "what is true right now" without needing a goroutine to have consumed every event leading up to it. Use Events() to react to a change as it occurs; use Health for a synchronous, Ping()-style liveness probe that asks on its own schedule.

type Host

type Host struct {
	// contains filtered or unexported fields
}

Host manages plugins declared in a HostConfig: spawning, handshake, supervision, and teardown. Generated client stubs reach a plugin through the *ClientConn Plugin returns.

All exported Host methods (Plugin, Start, Stop, Reload, Events, Health) are safe for concurrent use by multiple goroutines.

func NewHost

func NewHost(cfg HostConfig) *Host

NewHost creates a Host from the given configuration but does not start it.

func (*Host) Events

func (h *Host) Events() <-chan Event

Events returns a channel of supervisor lifecycle events for every plugin this Host manages (EventStarting/EventReady/EventUnhealthy/EventCrashed/ EventRestarting/EventGaveUp). Delivery never blocks, even under a sustained burst with no reader: Starting/Ready/Restarting drop the oldest once the reader falls behind, while Unhealthy/Crashed/GaveUp instead fill a bounded backlog holding CriticalBufferCapacity's worth of critical events (currently 3: an Unhealthy verdict, the Crashed that follows it, and a terminal GaveUp) for EACH plugin this Host was configured with. So long as no more than that many plugins have an undrained failure incident sitting in the backlog at once, every one of those incidents' critical events arrives whole and in order — a verdict and the outcome that followed it never split apart or arrive out of order. If MORE incidents than that stack up undrained at once — a flapping plugin racking up several of its own before you drain, or enough distinct plugins failing together — an older, still-undelivered incident's critical events can be evicted to make room, and that older incident may belong to a different plugin than the one whose newer incident evicted it. Reading Events() promptly — from its own goroutine, from before Start until Stop returns — keeps you well inside that bound.

The channel is the same one for the Host's whole life and is CLOSED once Stop has completed the host's teardown, so a `for ev := range host.Events()` loop ends with the Host instead of blocking forever. Before closing it, Stop waits a bounded time for a reader to take what the shutdown itself published, so a failure incident reported moments before it still arrives whole at a reader that is still draining — the reason to keep reading until Stop returns. Closing is the end of the stream: an event nobody took by then is not delivered, and a receive after the close yields the zero Event with ok=false, never another event.

One case leaves the channel open: a Stop whose context expired before some plugin's supervisor joined. That plugin's teardown finishes on its own, and the channel carries its remaining events until it does, then closes — no second Stop is needed to get there. A Stop handed a context that was already canceled or expired is that same case with no wait at all: it stops every plugin and reports each one's context error, and the channel closes once those teardowns finish. Giving Stop a context with its own budget rather than the one the work ran under is still what makes the close synchronous with the call. Once the teardown has completed, the Host is done: Start rejects with ErrHostStopped. See docs/supervisor-events.md's delivery-semantics section for the full accounting.

func (*Host) Health added in v0.2.0

func (h *Host) Health(name string) (HealthSnapshot, error)

Health returns a point-in-time copy of name's retained health state. It is safe for concurrent use: it reads a retained record, never the plugin's supervisor, a channel, or anything Start/Stop may be holding, so it does not block on plugin operations — but it may briefly wait on the named plugin's own internal record lock, which a concurrent recordHealthEvent or heartbeat hook can hold only for the few stores that build one snapshot.

It returns ErrHostStopped once Stop has completed the host's teardown, checked before the name lookup, so a name that was never declared in this Host's HostConfig.Plugins still reports ErrHostStopped rather than ErrUnknownPlugin once the Host itself is done; matches Start's own ErrHostStopped contract (the retained records end with the Host). Otherwise it returns ErrUnknownPlugin for an undeclared name. A name whose instance is currently stopping, restarting, or mid-reload still returns its most recently retained transition: records live for the Host's single-use lifetime, not for one instance's.

Health for a name whose last event you just received off Events() can still return ErrHostStopped if Stop's teardown completes in between: the two calls race once shutdown has started, and completing the unbuffered receive off Events() proves only that the event was handed to you, not that a subsequent Health call for it is still guaranteed to succeed. A consumer that needs a plugin's exact last state across shutdown should read it off the event it just received (Event carries the identical translated Kind/Err this record would have held) rather than making a second, separate call back into Health for it; call Health before initiating Stop if what you need is this Host's belief about a plugin's state before shutdown began.

func (*Host) Plugin

func (h *Host) Plugin(name string) *ClientConn

Plugin returns the named plugin's client connection, or a ClientConn that fails every call with ErrPluginUnavailable if the plugin isn't running. A plugin whose prior instance is still stopping also reports unavailable: its client mapping was removed when Stop began, and no new instance may take the name until that teardown completes.

It takes the host's lock, which a concurrent Start holds while each plugin it admitted waits for that plugin's first outcome, so a call made during a Start can block for the length of a spawn. It takes no context and cannot be cut short. Health reports a plugin's state without that lock and is the one to reach for on a path that must not block.

func (*Host) Reload

func (h *Host) Reload(ctx context.Context, name string) error

Reload hot-reloads the named plugin in place: it stops admitting new calls, freezes and quiesces the running instance's registered mutators, snapshots its sealed, verified state, restores a freshly spawned instance from that snapshot, and atomically swaps routing to that successor — all without restarting supervision. It blocks until the transaction reaches a terminal outcome, and on success until the old instance's teardown-with-reap has completed.

A call the instance accepted before the admission cutoff runs to completion and its real outcome reaches the caller. The drain certifies both halves of that: every registered Mutator is frozen before the snapshot is taken, and every call the instance accepted has finished and had its response written to the transport before the drain is acknowledged. The host then reads those responses out before it reaps the predecessor, so a call that completed is answered rather than reported as an unknown outcome. A new call refused at the cutoff instead fails with ErrDrained and is retryable.

A call can still end in ErrOutcomeUnknown, but only as a genuine anomaly rather than the ordinary course of a reload: the host bounds how long it waits to read the answers it is owed, so a reader that stops making progress cannot stall a reload indefinitely. Calls left unanswered when that bound expires are reported as outcome-unknown and counted on styx.reload.dropped.count.

On success it returns nil and the successor is the instance the named plugin now routes to. On any pre-promote failure the reload has already rolled back — the running instance was resumed and admission reopened — and Reload returns the reason it aborted with that same instance still serving. It returns ErrPluginUnavailable if no plugin of that name is running, and ctx's error (as a styx error) if ctx is done.

Before any of that, Reload takes the host's lock, which a concurrent Start holds while each plugin it admitted waits for that plugin's first outcome. That acquisition does not observe ctx, so a Reload racing a Start can wait for the length of a spawn, and a ctx expiring during the wait is reported only once the lock is held rather than cutting it short.

Cancelling ctx aborts a reload that has not yet promoted. Past that point the reload is committed and cancellation no longer shortens it: the successor is already routing, so there is nothing left to abandon, and cutting the wait for the predecessor's outstanding answers short would discard outcomes this host is already holding. A caller whose ctx expires after the promote still gets nil and a completed reload.

The five-phase transaction lives in internal/lifecycle and the atomic routing swap is (*ClientConn).promote; the heartbeat loop that owns the live control connection runs the transaction inline on its own goroutine, which is what lets a reload drive that connection without racing the loop.

func (*Host) Start

func (h *Host) Start(ctx context.Context) error

Start spawns every configured plugin, completes its handshake, and begins supervisor heartbeat monitoring for each. Start blocks per plugin only until that plugin's first attempt reaches Ready or gives up; ongoing monitoring and restarts continue in the background and are reported via Events. A single plugin's failure does not abort the others; Start's returned error is the combined (errors.Join) set of any that failed.

Start is how a caller retries the plugins it failed to start, not how it restarts the ones it did. A name this Host already started stays that instance's for as long as the Host owns it — after the instance has given up for good as much as while it is serving — and a second Start of it is refused with ErrPluginAlreadyStarted, having spawned nothing and left the earlier instance untouched. A name whose prior instance is still tearing down — from an expired Stop, or from an attempt this Host abandoned mid-spawn — is refused with ErrPluginStopping until that teardown completes. So a Start retried after a partial failure starts what failed and reports ErrPluginAlreadyStarted for what did not: replacing a serving instance with a fresh process is Reload's job, and there is no per-plugin respawn for one that gave up — build a new Host.

A Host is single-use. Once a Stop has BEGUN — not merely completed — Start rejects with ErrHostStopped and spawns nothing: that teardown owns whichever plugins it snapshotted and ends the Events() subscription and the observability workers when they are done, so a plugin admitted behind it would report its lifecycle nowhere and no teardown would own it. A name that Stop is still tearing down reports the more specific ErrPluginStopping instead, which says the same thing about this Host. Build a new Host.

A Start already inside a plugin spawn when a Stop begins abandons that attempt and reports ErrHostStopped for it: Start holds the host's lock across a spawn, and making the teardown wait that out would spend a budget the Stop caller sized for waiting on children. Abandoning stops that attempt's supervisor at once but never waits for it under that lock — a supervisor inside a spawn cannot be interrupted out of one — so an attempt whose supervisor has not joined by then is handed to the teardown as a stopping runtime rather than joined here. Either way no process, supervisor, or subscription is left unowned; see abandonAttempt.

func (*Host) Stop

func (h *Host) Stop(ctx context.Context) error

Stop drains and shuts down every plugin and blocks until children that join within ctx have been reaped. Every plugin being torn down is held in a stopping state that rejects concurrent Start or Reload of the same name.

ctx bounds how long Stop WAITS, never whether the teardown happens. A Stop handed an already-expired ctx buys no wait at all, but still stops every plugin: each supervisor ends its instance through the same sequence any other Stop runs — a graceful Shutdown message, SIGKILL of the whole process group if the child does not exit inside that window, then a waitpid reap — so no plugin process outlives the Host. What the expired budget costs is the join, and with it the wait for that reap: Stop reports the context error for every plugin that had not already joined and returns while their teardowns finish detached, exactly as for a budget that ran out mid-teardown. Stop is therefore always worth calling, whatever ctx is left.

A plugin whose Supervisor.Run does not join before ctx expires cannot be torn down safely: closing its relay now could drop a terminal event the still-running Run publishes later. Stop returns that plugin's deadline error but retains the runtime — its relay stays subscribed and its client mapping stays absent (Plugin reports it unavailable). The retained runtime's teardown completes automatically once its Run finally exits, via a detached watcher or a retried Stop, whichever happens first. The host's own background workers — the observability dispatchers and the Events() forwarder — are released only after the last such runtime is gone, so Events() stays open while one is still stopping; see maybeReleaseWorkers for what ending it means for a reader. That release's own waits are bounded by ctx as well, so a caller sizing a shutdown budget never has to add Styx's internal bounds to it.

Concurrent Stops are serialized rather than run side by side: one owns the teardown and the others wait for it, so every caller returns only once a teardown it actually observed has finished, or its own ctx expires. A waiting caller with budget left then runs its own pass, which is what lets it complete a join an earlier caller ran out of time for; a waiting caller with no budget left takes its ctx's error and leaves the owner to finish — the owner still stops and reaps every plugin, so no child outlives that call either. Ownership also decides the teardown tail: the bounded waits below belong to the caller that owns the teardown, so a Stop with a spent budget can never cut short a drain a caller with budget is still paying for.

A Start already inside a plugin spawn when Stop arrives is abandoned rather than waited out: it holds h.mu across that spawn, and Stop's ctx is a budget for waiting on children, not on another caller's admission. That Start reports ErrHostStopped for the abandoned plugin and releases h.mu without waiting for the supervisor it stopped, handing it to this Stop as a stopping runtime — so the spawn is waited on here, as any other child is, and only for as long as ctx allows. That is what keeps this whole call inside ctx: were the abandoned attempt joined under h.mu instead, Stop would wait the spawn out before it could even take its snapshot.

type HostConfig

type HostConfig struct {
	Plugins []PluginSpec

	// Metrics optionally sends built-in instrumentation to the sink
	// (RPC latency, bytes moved, timeouts, cancellations, restarts, heartbeat
	// misses, backpressure). One sink covers all plugins; each signal carries
	// a "plugin" label. nil disables host-side metrics.
	Metrics observe.MetricsSink

	// Logger optionally sends structured internal diagnostics (plugin lifecycle
	// transitions and faults). Delivery goes through the same bounded,
	// panic-isolated worker as metrics, so a slow or panicking Logger neither
	// stalls the relay nor crashes the process. nil disables logging.
	Logger observe.Logger

	// MetricsInterval sets the periodic reporter cadence for transport-sourced
	// signals (bytes moved and shared-memory gauges).
	// Zero uses the default (one second).
	// Ignored when Metrics is nil.
	MetricsInterval time.Duration
}

HostConfig configures a Host before Start.

type IncompatibleError

type IncompatibleError struct {
	HostOffer   HandshakeOffer
	PluginOffer HandshakeOffer
	Reason      string

	// Kind discriminates which of the two IncompatibleError causes applies:
	// IncompatibleHandshake covers any handshake or negotiation
	// incompatibility, including a mismatch found after a successful
	// recompute; IncompatibleBinaryIdentity covers a binary pin mismatch
	// caught before the plugin process is spawned. See IncompatibleKind.
	// The zero value, IncompatibleHandshake, is correct for any construction
	// that predates this field, so existing callers need no change.
	Kind IncompatibleKind
}

IncompatibleError reports that a plugin could not be started: either its binary identity does not match PluginSpec.BinarySHA256, checked before any process is spawned or handshake runs (HostOffer and PluginOffer then contain no offer data), or its handshake offer had no resolution against the host's, checked during negotiation (both offers are then populated). Kind tells the two apart. errors.Is(err, ErrIncompatible) matches any *IncompatibleError. errors.As(err, &incompatibleErr) recovers the structured detail.

func (*IncompatibleError) Error

func (e *IncompatibleError) Error() string

func (*IncompatibleError) Is

func (e *IncompatibleError) Is(target error) bool

Is reports whether target is ErrIncompatible.

type IncompatibleKind added in v0.2.0

type IncompatibleKind int

IncompatibleKind discriminates the two reasons Start can fail with *IncompatibleError, so a host can tell them apart with errors.As and a field check instead of parsing Reason.

const (
	// IncompatibleHandshake reports an ordinary handshake negotiation failure:
	// the host and plugin's protocol/transport/codec/feature/service offers
	// have no compatible resolution — typically a plugin built against an
	// older or newer contract than the host expects. This is the zero value,
	// so every IncompatibleError construction that predates IncompatibleKind
	// defaults to it correctly.
	IncompatibleHandshake IncompatibleKind = iota
	// IncompatibleBinaryIdentity reports that the plugin binary on disk does
	// not match PluginSpec.BinarySHA256 — a wrong or tampered binary, not a
	// version mismatch between two genuine builds.
	IncompatibleBinaryIdentity
)

type MethodDesc

type MethodDesc struct {
	MethodName string
	MethodID   uint64
	NewRequest func() proto.Message
	Handler    func(srv any, ctx context.Context, req proto.Message) (proto.Message, error)
}

MethodDesc is one method within a ServiceDesc. Its two functions split request construction from request handling, because the runtime runs them in different places.

NewRequest allocates the message this method's request decodes into. It runs on the receive goroutine, while the inbound payload is still readable, and it holds up every later inbound frame for its duration — so it MUST do nothing but allocate, and MUST return a message nothing else references. Generated code emits `func() proto.Message { return &Req{} }`.

Handler receives that message already decoded, invokes the user's implementation, and returns the response message or an application error. It runs on the serving goroutine, after the receive path has released the frame, so the request it is handed owns every byte it holds and stays valid for as long as the handler wants it.

The two are a pair: the runtime hands Handler exactly what NewRequest built for the same MethodID, so a Handler may assert the concrete type of its request directly.

type MissedHeartbeatsError added in v0.3.0

type MissedHeartbeatsError struct {
	// Missed is the consecutive miss count that reached the configured
	// threshold.
	Missed int
}

MissedHeartbeatsError reports that a plugin instance went silent for PluginSpec.MissedHeartbeatThreshold consecutive heartbeat waits, so the supervisor declared it unhealthy and ended it. It appears in Host.Events() event errors and, while that verdict is still the plugin's most recent recorded transition, as HealthSnapshot.LastError. errors.Is(err, ErrHeartbeatsMissed) matches any *MissedHeartbeatsError; errors.As(err, &missedErr) recovers the exact count.

func (*MissedHeartbeatsError) Error added in v0.3.0

func (e *MissedHeartbeatsError) Error() string

func (*MissedHeartbeatsError) Is added in v0.3.0

func (e *MissedHeartbeatsError) Is(target error) bool

Is reports whether target is ErrHeartbeatsMissed.

type Mutator

type Mutator interface {
	// Freeze stops the component from mutating its own state and returns only
	// once it has settled. The plugin waits for Freeze to return on every
	// registered Mutator before it reports the drain complete, so a Freeze
	// that blocks holds up the whole reload until the host's drain deadline
	// (carried on the drain request) elapses.
	//
	// Returning an error aborts the reload before the drain is acknowledged:
	// the plugin never reports the drain complete. Unlike the other reload
	// hooks, this is fatal to the current instance — it stops serving and
	// exits without acknowledging the drain, and the host's supervisor treats
	// the exit as a crash, subject to its restart policy. A partially frozen
	// set is never treated as drained — freezing stops at the first error.
	Freeze(ctx context.Context) error
	// Resume restarts the component after a reload was abandoned and the
	// current instance is kept in service. It is never called on a reload that
	// succeeds — a retired instance is torn down without being resumed — so
	// Resume runs only when a frozen instance is being returned to service.
	//
	// Returning an error aborts the rollback: the plugin does not acknowledge
	// the resume, and the instance is left in a partially resumed state.
	Resume(ctx context.Context) error
}

Mutator is a background component that must hold its state still during hot reload. Register one with RegisterMutator. Anything that mutates state on its own schedule — a background flusher, a lease renewer, a reconnect loop, a cache evictor — is a Mutator.

During reload the host asks the plugin to drain. The plugin freezes every registered Mutator in registration order, then reports drain complete. If reload is abandoned and the plugin keeps serving, each Mutator is resumed in that same order. On successful reload, the frozen instance is retired without being resumed (Resume runs only on rollback). Mutators should be registered in dependency order (a cache drawing on a connection pool registers before that pool).

type PluginCrashError

type PluginCrashError struct {
	Plugin          string
	ExitStatus      int
	ExitStatusKnown bool
	Reason          string

	// StderrTail holds the plugin's last captured stderr lines, oldest first,
	// the same lines Reason's "; stderr: ..." suffix already embeds as one
	// pipe-joined string -- this is that content, structured, for a caller
	// that wants to log or inspect it without re-parsing Reason.
	// Bounded by the supervisor's rolling tail window, so it holds only the
	// most recent lines, not the plugin's whole stderr history.
	// Nil when no stderr was captured (e.g. a crash before the process
	// spawned).
	StderrTail []string

	// Dispatched is always false in PluginCrashError by design. A single crash
	// event covers the whole plugin—potentially many in-flight calls with
	// mixed dispatch states—which one bool cannot represent. The authoritative
	// per-call dispatch truth is each call's terminal error: a never-published
	// call fails with retryable ErrPluginUnavailable, a published call with
	// non-retryable ErrOutcomeUnknown. IsRetryable reads those errors, not this
	// field. This field remains for future per-call attribution.
	Dispatched bool
}

PluginCrashError reports that a plugin process exited unexpectedly. It appears in Host.Events() event errors and as a failed Start's returned error, never as a per-call error. Per-call errors are ErrPluginUnavailable (crash detected before request publish, retryable) or ErrOutcomeUnknown (crash detected after publish, outcome unknown, not retryable).

ExitStatus follows Python's subprocess and many supervisors: a non-negative value is the process's exit code; negative -N means terminated by signal N (e.g. -9 for SIGKILL). ExitStatusKnown is false when the exit status could not be determined (e.g., crash before the process was spawned).

func (*PluginCrashError) Error

func (e *PluginCrashError) Error() string

type PluginPanicError

type PluginPanicError struct {
	Plugin  string
	Service string
	Method  string
	Value   string // fmt.Sprint(recover())
}

PluginPanicError reports that a handler panicked. By default the process is tainted and terminated for restart by the supervisor. The panicking call returns *PluginPanicError directly (the runtime knows definitively that its handler panicked). Other outstanding calls on the same connection get ErrOutcomeUnknown wrapping a PluginCrashError, like any crash.

Plugin, Service, and Method identify which handler panicked, filled in from the host's own call context — they never cross the wire, since only the recovered value does (see statusFromRPC). Service and Method carry the real name for a call made through the name-based API (Invoke, OpenStream). A call made through the precomputed-ID API (InvokeID, InvokeIDFactory, OpenStreamID, OpenServerStreamID — every generated client stub) never had a name to give, but generated code also registers its service/method names against their IDs at package init (RegisterIdentityName), so Service/Method still carry the real name whenever that registration ran. Only an ID with no registration — a hand-called precomputed-ID API, or a plugin built with an older generator — falls back to the routing hash rendered as hex, e.g. "0xedc54b402664edff".

func (*PluginPanicError) Error

func (e *PluginPanicError) Error() string

type PluginServer

type PluginServer struct {
	// contains filtered or unexported fields
}

PluginServer is the plugin-side counterpart to Host: it owns the control connection, data-plane Transport, and internal/rpcruntime.Dispatcher that services register against. Serve drives the handshake, attaches the transport, runs the serving loop, and exits when the host disconnects or sends Shutdown.

Register services, stream handlers, and reload hooks BEFORE calling Serve. Each Register* method is individually safe for concurrent use, but Serve snapshots the registered services, handlers, and hooks once at startup, so a post-Serve registration does not affect the running session. Serve is called once.

func NewPluginServer

func NewPluginServer(cfg PluginServerConfig) *PluginServer

NewPluginServer creates a PluginServer from cfg. Call RegisterService for each generated service (and any stream handlers and reload hooks), then Serve. The zero-value PluginServerConfig{} configures no metrics, the default panic policy, and both transports. It panics if cfg.Transports names an unknown transport (see PluginServerConfig.Transports).

func (*PluginServer) RegisterMutator

func (s *PluginServer) RegisterMutator(m Mutator)

RegisterMutator registers a background component that must be frozen before drain-ack and resumed on rollback during hot-reload. Mutators are frozen and resumed in registration order, so a plugin with dependent mutators (e.g., a cache drawing on a connection pool) registers them in dependency order.

func (*PluginServer) RegisterService

func (s *PluginServer) RegisterService(desc *ServiceDesc, impl any)

RegisterService installs desc against impl (the user's service implementation, e.g., &ImageProcessor{}), to be called from the dispatch loop once Serve starts. Registering two services with colliding ServiceIDs panics immediately (a startup-time configuration error, not a runtime condition to recover from).

func (*PluginServer) RegisterStateRestorer

func (s *PluginServer) RegisterStateRestorer(restorer StateRestorer)

RegisterStateRestorer registers this plugin's hot-reload snapshot consumer, invoked on a freshly spawned instance before it acks readiness. Registering a second restorer replaces the first — only one is ever consulted.

func (*PluginServer) RegisterStateSaver

func (s *PluginServer) RegisterStateSaver(saver StateSaver)

RegisterStateSaver registers this plugin's hot-reload snapshot producer. A plugin that declares no state simply never calls this; hot-reload then proceeds without a snapshot phase. Registering a second saver replaces the first — only one is ever consulted.

func (*PluginServer) RegisterStreamHandler

func (s *PluginServer) RegisterStreamHandler(
	service, method string, shape StreamShape, handler func(*Stream) error,
)

RegisterStreamHandler installs handler for the named streaming method with the method's declared shape, keyed by the same FNV-1a-64 service/method hashes the opener stamps on STREAM_OPEN. The accepter derives the stream's shape from this registration, never from the wire (stream-protocol.md §7.4): a server-streaming registration establishes the stream half-closed-remote and delivers the STREAM_OPEN request (even when zero bytes) to the handler. Generated code calls it once per streaming method at startup; the plugin's serve loop invokes handler on its own goroutine when a matching STREAM_OPEN is accepted, passing the server half of the stream.

func (*PluginServer) RegisterStreamHandlerID

func (s *PluginServer) RegisterStreamHandlerID(
	serviceID, methodID uint64, shape StreamShape, handler func(*Stream) error,
)

RegisterStreamHandlerID is RegisterStreamHandler with the FNV-1a-64 service/method hashes supplied directly, so a caller that already holds them skips the per-registration hash. Generated streaming server code calls it, passing the service/method ID constants the generator precomputed at generation time — the accepter's keys are thus the exact literals the opener's STREAM_OPEN carries, with no name hashed at startup. Hand-written code uses the name-based RegisterStreamHandler; both key the identical (service, method).

func (*PluginServer) Serve

func (s *PluginServer) Serve() error

Serve reads the inherited control fd, completes the handshake, receives the data-plane transport fd, and runs the serving loop until the host disconnects or Shutdown is received. It blocks until the plugin process should exit; callers do os.Exit(1) if it returns a non-nil error. InstallDeathSignal is its literal first statement so an already-orphaned process exits immediately.

type PluginServerConfig

type PluginServerConfig struct {
	// Metrics optionally sends the plugin's built-in metrics to the sink.
	// nil disables plugin-side metrics (no dispatcher goroutine, no hot-path allocation).
	Metrics observe.MetricsSink

	// MetricsInterval sets the periodic reporter cadence.
	// Zero uses the default (one second).
	// Ignored when Metrics is nil.
	MetricsInterval time.Duration

	// ContinueAfterPanic selects the handler-panic policy.
	// false (the default) is the enterprise profile: a panicking handler is
	// recovered at the dispatch boundary and its call returns the panic outcome,
	// then the process taints and terminates so the supervisor restarts it.
	// true keeps the process serving after a panic — an explicit opt-in, safe only
	// if every handler guarantees its own isolation, since process state after a
	// panic is whatever the handler left behind.
	// A panic in the Styx runtime itself (outside handler frames) is never
	// recovered under either setting, with two exceptions, both of them a codec
	// panicking on a message it was handed. A codec that panics while encoding a
	// message directly into the shared-memory transport's send buffer is
	// recovered by that transport, because a caller's bug must not take the
	// transport down. On the plugin that message is the response: the call
	// terminates with an internal-error status and the plugin keeps serving,
	// without tainting the session. The host has the same exception for the
	// request it encodes the same way: the call fails instead of crashing the
	// host process. A codec that panics while DECODING a request is recovered the
	// same way and for the same reason, with the same outcome — an internal-error
	// status for that one call, no taint — because that decode runs on the receive
	// goroutine over bytes the peer chose, and a peer must not be able to end this
	// process by publishing a payload the codec chokes on.
	ContinueAfterPanic bool

	// Transports is the data-plane transport allowlist advertised during handshake.
	// nil or empty advertises both TransportSHM and TransportUDS, letting the host
	// choose per its own preference.
	// Set to []Transport{TransportUDS} for a uds-only plugin or
	// []Transport{TransportSHM} for a shared-memory-only plugin.
	// NewPluginServer panics on an unknown transport name.
	Transports []Transport

	// ConsumeFaultRunThreshold is how many inbound frames this plugin may fail to
	// consume back to back, with no frame delivered successfully between them,
	// before it tears the shared-memory region down. Ignored for the uds transport.
	//
	// It is the plugin-side twin of PluginSpec.ConsumeFaultRunThreshold and carries
	// the same meaning; see that field for what the run measures, why a single
	// success resets it, and why the threshold buys less stall time the faster the
	// link runs. Each side adjudicates only its own receive path, so the two are
	// set independently and need not agree.
	//
	// Because they need not agree, neither side's setting governs the region on its
	// own. Tearing the region down takes only one side and stops both, so a plugin
	// that raises or disables its threshold still gets torn down by a host whose own
	// guard fires.
	//
	// Zero selects the default. ConsumeFaultEscalationDisabled switches this
	// plugin's half of the teardown off; see that constant for what one-sided
	// disabling does and does not achieve. Do not set a small value: at 1 a single
	// unconsumable frame tears the region down.
	ConsumeFaultRunThreshold int
}

PluginServerConfig configures a PluginServer before Serve. The zero value is valid: no metrics, default reporter cadence, default panic policy, and both data-plane transports advertised. Pass it to NewPluginServer (fields are read-only once set).

The region geometry and transport preference are host-authored via PluginSpec. What this side does configure is what it does with its own receive path: Transports selects which transports it advertises, and ConsumeFaultRunThreshold bounds how long it keeps a region it cannot consume from.

type PluginSpec

type PluginSpec struct {
	Name    string
	Path    string
	Args    []string
	Env     []string // additional vars merged onto the sanitized base env
	Restart RestartPolicy

	// Stdio optionally observes this plugin's live stdout/stderr.
	// nil (the default) disables it: no line leaves the plugin process
	// except through the crash tail a PluginCrashError already carries.
	Stdio StdioSink

	// BinarySHA256 optionally pins the plugin binary's identity. When non-nil,
	// Start verifies it before creating any supervisor and fails the plugin
	// with *IncompatibleError on a mismatch, with Kind set to
	// IncompatibleBinaryIdentity so a host can tell a tampered or wrong
	// binary apart from an ordinary handshake version mismatch.
	BinarySHA256 []byte

	// Services optionally declares the version range each service must satisfy.
	// Typically populated from a generated `<Service>Requirement()` value.
	// Start sends this on the Hello offer; the plugin's negotiation enforces it
	// against its advertised versions, and a violation surfaces as
	// *IncompatibleError naming the offending service.
	Services []ServiceRequirement

	// RequireStreaming declares that this Host calls streaming methods on this
	// plugin, so streaming is marked required in the handshake offer.
	// A plugin that cannot stream fails the handshake with *IncompatibleError
	// rather than surfacing the incompatibility only at the first OpenStream call.
	// Generated streaming client code sets it; the default (false) offers
	// streaming as optional.
	RequireStreaming bool

	// Transport selects this plugin's data-plane transport.
	// TransportSHM pins the shared-memory transport (plugin that cannot speak it
	// fails handshake). TransportUDS pins Unix domain sockets.
	// TransportAuto (zero-value default) offers both, preferring shared memory.
	Transport Transport

	// Geometry is the host-authored shape of the shared-memory region
	// (capacity and payload size classes) used when the shared-memory transport
	// is negotiated. Ignored for the uds transport.
	// The zero value selects the default profile (GeometryDefault).
	Geometry ShmGeometry

	// BurstMaxPayload is the burst-path ceiling: the largest payload the burst
	// socket will carry. It is enforced on the burst path by both sides --
	// send-side before any byte leaves, receive-side before any allocation.
	// Burst is opt-in per plugin: this Host offers the burst feature only when
	// this is non-zero, and setting it is a deliberate memory grant this Host
	// should size on purpose, not raise defensively. One value governs both
	// directions; there is no separate host-to-plugin and plugin-to-host
	// ceiling.
	//
	// Zero (the default) leaves the burst path off -- today's behavior,
	// unchanged. A non-zero value must exceed the largest slab class configured
	// in BOTH directions of Geometry (Geometry.HostToPlugin and
	// Geometry.PluginToHost may differ); Start refuses anything else with a
	// *ConfigError naming this field, before any plugin process is spawned.
	BurstMaxPayload uint32

	// MaxPayload is a capacity GUARANTEE, not an enforced cap: "styx will carry
	// any marshaled frame handed to it up to this many bytes, on the covered
	// surfaces" — unary request/response bodies (shm or burst) and logical
	// STREAM_MSG messages (shm or stream-chunking). It is not a new rejection
	// bound of its own, and not a ceiling on what actually gets through: a
	// value at or below what the transport already carries adds no
	// enforcement, and the derived burst/chunk ceilings this field resolves
	// to are themselves frequently larger than MaxPayload (raised to clear
	// the stock ladder's top class), so a send larger than MaxPayload can
	// still succeed. What DOES fail with the existing definitive
	// ErrPayloadTooLarge is a send beyond the derived transport ceilings
	// themselves. Add your own envelope before choosing a value — this
	// field states a floor, not a wire framing limit.
	//
	// Two surfaces are excepted. STREAM_OPEN's single server-streaming request
	// and STREAM_CLOSE's single client-streaming response are not chunked and
	// stay bounded by the sending direction's stock inline limit (about 1 MiB),
	// regardless of MaxPayload; oversize there fails the same
	// ErrPayloadTooLarge. And the guarantee is only as good as the transport
	// that carries it (below).
	//
	// Setting MaxPayload derives everything else: the stock shared-memory
	// geometry (GeometryDefault, both directions), the burst-path ceiling, and
	// the stream-chunking ceiling, all internal from here on. It is mutually
	// exclusive with a non-zero Geometry or a non-zero BurstMaxPayload on the
	// same spec — Start refuses the combination with a *ConfigError naming
	// this field before any plugin process is spawned, since a hand-authored
	// geometry and a derived one cannot both govern the same spec. A value at
	// or below the stock ladder's certain-fit bound (the top class minus the
	// worst-case checksum trailer) derives the stock geometry alone, with
	// burst and chunking both left off: the guarantee is already met,
	// so nothing new switches on, and MaxPayload never shrinks the geometry
	// it derives. Zero (the default) leaves MaxPayload
	// entirely out of it — today's expert path, unchanged; a
	// memory-constrained deployment that needs a custom ladder keeps using
	// Geometry (GeometryLean or a hand-built table) and, if it also needs an
	// oversize-payload path, BurstMaxPayload directly.
	//
	// The guarantee is validated against the transport that will actually
	// carry it, and refused loudly where unmeetable. The ordinary uds
	// transport has a fixed frame cap (about 1 MiB) and gets no burst or
	// chunking to widen it: Transport pinned to TransportUDS with MaxPayload
	// above that cap is a *ConfigError at Start, before spawn. Once a
	// shared-memory attach has negotiated — when the checksum choice and
	// therefore the connection's EXACT per-direction inline limits are known —
	// the same requirement is checked again against those exact limits, and
	// again against uds if negotiation resolved there (e.g. TransportAuto
	// falling back). An unmet requirement at that point — an old peer that
	// left burst or chunking unresolved, or an auto-negotiated uds connection
	// — fails the attach with a typed *IncompatibleError naming MaxPayload
	// and the missing capability; the operator's two remedies, upgrade the
	// plugin or lower MaxPayload, are stated in the error text.
	MaxPayload uint32

	// MaxDataInflight is the peak number of concurrent data calls.
	// Carried to the plugin so both sides admit identically.
	// Ignored for the uds transport.
	// Zero falls back to RingCapacity minus LifecycleReserve.
	MaxDataInflight int

	// StrictCapacity opts into ABI optional STRICT certification:
	// the transport additionally requires MaxDataInflight not to exceed any
	// reachable size class's usable slab count. A geometry that fails this check
	// is refused at spawn with a typed error. Ignored for the uds transport.
	// Off by default; a non-strict geometry experiences typed backpressure
	// under load instead.
	StrictCapacity bool

	// ConsumeFaultRunThreshold is how many inbound frames this Host may fail to
	// consume back to back, with no frame delivered successfully between them,
	// before it tears the shared-memory region down and restarts the plugin.
	// Ignored for the uds transport.
	//
	// The run exists to bound the damage a receive path can do when it cannot tell
	// a peer publishing unusable bytes from its own inability to take them: a
	// consumer that is merely busy still succeeds between its failures and never
	// accumulates a run, while a region producing nothing usable accumulates one
	// without bound. Any single successful delivery resets it.
	//
	// It counts frames, not time, so what it buys depends on how fast the link
	// runs: the same threshold is a few milliseconds of total stall on a
	// high-throughput link and several seconds on a latency-bound one. Raise it if
	// this Host's traffic is fast enough that an ordinary pause (a garbage
	// collection assist, a descheduled goroutine) could span the default; the only
	// cost of raising it is a proportionally longer detection delay.
	//
	// Zero selects the default, which is high enough that no consumer making
	// progress reaches it. ConsumeFaultEscalationDisabled switches this Host's half
	// of the teardown off -- read that constant first, because it does not on its
	// own keep the region alive. Do not set a small value: at 1 every single
	// unconsumable frame tears the region down, and the threshold needs to stay
	// well clear of the inbound queue depths it is meant to outlast.
	ConsumeFaultRunThreshold int

	// HeartbeatTimeout is how long this Host waits for the plugin's next heartbeat
	// before counting a miss. MissedHeartbeatThreshold consecutive misses declare the
	// instance unhealthy and end it, so the two together decide how fast a plugin
	// that has gone silent -- deadlocked, starved, stopped -- is detected: at the
	// defaults, three waits of one second each.
	//
	// It is this Host's WAIT, not the plugin's send cadence. A plugin sends a
	// heartbeat every PluginHeartbeatInterval, fixed and not negotiated, so a wait
	// shorter than that cadence expires on a perfectly healthy plugin nearly every
	// interval and the missed count reaches its threshold with nothing wrong. Start
	// refuses such a value with a *ConfigError rather than clamping it.
	//
	// Zero selects the default, which equals the send cadence. Lengthen it for a
	// plugin whose thread of control legitimately pauses -- a slow serial link, a
	// blocking device read -- and pay for that in detection latency. Detection
	// cannot be pushed below one send cadence by shortening this: the evidence
	// arrives no faster than the plugin sends it. Lower MissedHeartbeatThreshold
	// instead.
	HeartbeatTimeout time.Duration

	// MissedHeartbeatThreshold is how many consecutive missed heartbeats declare this
	// plugin's instance unhealthy. Any received heartbeat resets the running count,
	// so it bounds a RUN of silence, not a lifetime total.
	//
	// Zero selects the default (three). This is the knob to move to detect a dead
	// plugin faster, since HeartbeatTimeout cannot go below the send cadence. What it
	// spends is the margin that absorbs an ordinary late beat: at 1, one heartbeat
	// delayed past HeartbeatTimeout ends the instance, so tighten it only when the
	// plugin's cadence is not routinely jittered by the machine it runs on.
	MissedHeartbeatThreshold int

	// WedgeWindow is how long a plugin must keep reporting a stalled data plane -- a
	// ring consumer making no progress with work queued, or a response owed with no
	// handler running -- before the instance is declared unhealthy for it. This is
	// the progress-based half of liveness, separate from the missed-heartbeat half: a
	// wedged plugin keeps heartbeating, so no missed-heartbeat budget ever catches it.
	//
	// The window is not measured in host time. It is converted to a count of the
	// plugin's own heartbeat sequence increments, and the stall must span that many
	// consecutive beats. The divisor is the closest spacing the plugin's sender will
	// admit between two beats -- seven eighths of PluginHeartbeatInterval, 875ms --
	// so the count is ceil(WedgeWindow / 875ms) and every configured window rounds UP
	// to a whole beat. The default five seconds is six beats, about six seconds of
	// real stall at the one-second send cadence; anything from 876ms through 1.75s
	// costs two beats, not one. Zero selects the default (five seconds).
	WedgeWindow time.Duration
	// contains filtered or unexported fields
}

PluginSpec declares one plugin the Host spawns and supervises.

type RestartPolicy

type RestartPolicy = supervisor.RestartPolicy

RestartPolicy is the supervisor's restart-policy type, aliased here so RestartPolicy and supervisor.RestartPolicy name the identical type.

type ServiceDesc

type ServiceDesc struct {
	ServiceName string
	ServiceID   uint64
	Version     uint32
	Methods     []MethodDesc
}

ServiceDesc mirrors grpc.ServiceDesc: generated Register<Service>Server calls RegisterService with a table of method descriptors, plus the service's FNV-64 ID and generated-metadata version (used in handshake negotiation as a ServiceVersion).

type ServiceRequirement

type ServiceRequirement struct {
	Service                string
	MinVersion, MaxVersion uint32
}

ServiceRequirement is the host's declared acceptable version range for one service it intends to call on a plugin. A generated `<Service>Requirement()` returns the exact-version form (MinVersion == MaxVersion); a wider range is a hand-authored option.

type ShmGeometry

type ShmGeometry struct {
	// RingCapacity is the descriptor-slot bound shared by both rings:
	// a power of two in [64, 1<<20].
	RingCapacity uint32

	// LifecycleReserve is the ring slots kept unavailable to data frames
	// so the lifecycle lane always has room: 0 < R < C.
	LifecycleReserve uint32

	// HostToPlugin and PluginToHost are the per-direction size-class tables.
	// Each has ascending, cache-line-multiple slab sizes.
	// An empty direction copies the other; both empty selects the default profile.
	HostToPlugin []ShmSizeClass
	PluginToHost []ShmSizeClass
}

ShmGeometry describes a shared-memory region's geometry: ring capacity, lifecycle reserve, and per-direction size-class tables. The host authors it (via PluginSpec); the plugin reads it from the region header at attach. A zero ShmGeometry selects the default profile.

func GeometryDefault

func GeometryDefault() ShmGeometry

GeometryDefault returns the ABI's recommended default profile: ring capacity 4096, lifecycle reserve 256, per-direction size classes {256 B ×4096, 1088 B ×2048, 4160 B ×1024, 16448 B ×256, 65600 B ×128, 131136 B ×32, 1048640 B ×8} (roughly 63 MiB of region in total). The ladder is graded from a few hundred bytes to a megabyte so a payload is served from a class close to its own size, and every class above the smallest carries slabHeadroom so a power-of-two payload still fits after marshaling. Suits a general workload; memory-constrained deployments should prefer GeometryLean or a custom geometry sized to peak concurrency.

func GeometryLean

func GeometryLean() ShmGeometry

GeometryLean returns a profile for small control traffic only: ring capacity 512, lifecycle reserve 32, per-direction size classes {512 B ×64, 4160 B ×64} (roughly 0.6 MiB, sized to a 32-concurrent-call peak). The largest class is a hard ceiling, not a backpressure point: a message whose marshaled length exceeds 4160 bytes is rejected outright as too large, so this profile suits control, status, and acknowledgement frames and nothing else. A workload with kilobyte-scale reports, recipe or file transfers, or state snapshots needs GeometryDefault or a custom geometry sized to its own message distribution. Scale the class counts and RingCapacity minus LifecycleReserve to your workload's peak concurrent in-flight calls plus headroom.

func (ShmGeometry) RegionBytes added in v0.3.0

func (g ShmGeometry) RegionBytes() (uint64, error)

RegionBytes returns the exact number of bytes the shared-memory region this geometry describes would occupy — the same region_size CreateRegion derives and mmaps, not an estimate, so it cannot drift from what actually gets created. This matters because the region's pages are lazily faulted but never reclaimed for the plugin instance's life (shm-abi.md §1: the mapping is MAP_SHARED without MAP_POPULATE, and nothing walks the arena at startup), and tmpfs/memfd pages are charged to the host process's memory cgroup once touched. A capacity plan for a memory-constrained deployment must budget this number per plugin — the region's fully-touched size, not whatever a smoke test happens to observe as resident — because Styx's own backpressure is denominated in slabs, not bytes, and cannot protect a container's byte-denominated memory limit.

It reports the zero value's cost when g is the zero ShmGeometry (selects GeometryDefault) and applies the same empty-direction-copies-the-other rule toLayout does, so the number always matches what PluginSpec.Geometry set to g would actually produce.

It returns an error wrapping ErrInvalidConfig, as a *ConfigError with Field "ShmGeometry", if g's ring capacity, lifecycle reserve, or either direction's size-class table is structurally invalid, or if the derived region would exceed the region ceiling — never a silently wrong number.

type ShmSizeClass

type ShmSizeClass struct {
	// SlabSize is the slab's byte size — the largest payload (plus per-frame
	// overhead) it can hold.
	SlabSize uint32
	// SlabCount is how many slabs of this size the arena provides. Class 0's first
	// slab is reserved (payload_offset 0 means "no slab"), so class 0 has SlabCount
	// minus one usable slabs (shm-abi.md §6).
	SlabCount uint32
}

ShmSizeClass is one entry in a shared-memory arena's size-class table: a slab size in bytes and the number of slabs of that size. Sizes across a direction's table ascend, and each size is a 64-byte cache-line multiple (shm-abi.md §2).

type StateRestorer

type StateRestorer interface {
	// RestoreState applies data — the verified snapshot payload the
	// predecessor produced, built under formatVersion — to this freshly
	// spawned instance. It runs before the instance reports itself ready, so a
	// successful return is what lets the successor begin serving with the
	// predecessor's state in place. formatVersion is the version the
	// predecessor stamped on the payload, for a plugin that evolves its
	// snapshot format across releases.
	//
	// Returning an error refuses the snapshot: the successor reports itself not
	// ready with the error's text as the reason, and the host abandons the
	// reload and keeps the predecessor serving. The half-spawned successor is
	// discarded, never promoted.
	RestoreState(ctx context.Context, formatVersion uint32, data []byte) error
}

StateRestorer applies a predecessor's snapshot to a freshly spawned successor instance before that successor begins serving. A plugin that registers a StateSaver registers a matching StateRestorer with RegisterStateRestorer so its state survives reload; a stateless plugin registers neither. A successor with no registered StateRestorer accepts only an empty snapshot. If a predecessor sends real state and the successor has nothing registered to apply it, the successor refuses the snapshot and the host abandons the reload.

type StateSaver

type StateSaver interface {
	// SaveState returns the bytes to seal into the snapshot handed to the
	// successor instance. It runs after the drain is acknowledged, once the
	// plugin's own state is frozen, so it observes a quiescent snapshot.
	//
	// Returning an error means no snapshot reaches the host. The plugin cannot
	// report a save failure over the wire, so instead of exiting it waits for
	// the host to abandon the reload — which the host does once its snapshot
	// deadline expires — and keeps serving. A failed SaveState therefore turns
	// into an abandoned reload, not a lost instance.
	SaveState(ctx context.Context) ([]byte, error)
}

StateSaver produces the snapshot payload a plugin hands to its successor across hot reload. A plugin with state to carry forward registers one with RegisterStateSaver; a stateless plugin registers none and the reload then carries an empty snapshot. Styx handles versioning, checksumming, and sealing around the returned bytes (the payload is opaque to Styx).

type Status

type Status struct {
	Code    Code
	Message string
	Details []*anypb.Any
}

Status is an application-level error returned by a remote handler. It marshals through the same Codec as ordinary messages (Details are opaque proto.Message values the runtime does not interpret). "Status" (not "StatusError") matches the gRPC convention.

func (*Status) Error

func (s *Status) Error() string

type StdioSink added in v0.2.0

type StdioSink interface {
	// WriteLine delivers one captured line. See StdioSink's own doc for the
	// full delivery, ownership, and failure-isolation contract.
	WriteLine(stream string, line []byte)
}

StdioSink observes a plugin's live stdout/stderr, line by line, as the plugin process writes it. This is separate from and in addition to PluginCrashError.StderrTail, which only appears after the plugin has already crashed: a Stdio sink is the only way to see stdio output that never leads to a crash -- a third-party library writing to stderr directly, a Go runtime fault printed before a logger initializes, or ordinary diagnostic output the plugin's author intended to be watched live.

WriteLine delivers one line from stream, which is always "stdout" or "stderr". An implementation must be safe for fully concurrent WriteLine calls, including two concurrent calls for the same stream: a restart or hot reload runs the outgoing plugin process's final stdio deliveries alongside the incoming one's first deliveries, each on its own goroutines, so nothing serializes calls even within one stream name.

line is owned exclusively by this call: it is a freshly allocated copy that nothing else reads or mutates afterward, so an implementation may retain it past WriteLine's return without copying.

WriteLine must never block or run long. A sink that falls behind drops lines (counted, not surfaced here) rather than queuing them unbounded, so a slow or stalled sink can never back up into, or slow down, the plugin's own stdio pipes. A panicking WriteLine is recovered and counted, never propagated -- it loses only that one line and never crashes the Host or stops later lines on the same stream from being delivered.

type Stream

type Stream struct {
	// contains filtered or unexported fields
}

Stream is the public handle to a gRPC-shaped stream returned by OpenStream, OpenStreamID, OpenServerStreamID, or received by a RegisterStreamHandler. It wraps the internal stream state machine and exposes only what generated code needs: SendMsg/RecvMsg/CloseSend, codec (Marshal/Unmarshal), Context, and Err. Engine lifecycle controls are deliberately not promoted.

Stream follows gRPC ClientStream's goroutine rules: it is safe for one sending goroutine and one receiving goroutine to use concurrently (a single goroutine may call SendMsg and CloseSend while another calls RecvMsg). It is a caller programming error to call SendMsg or CloseSend concurrently with each other or with itself (the send direction has a single owner). Context, Err, Marshal, and Unmarshal are safe to call from any goroutine.

func (*Stream) CloseSend

func (s *Stream) CloseSend(ctx context.Context, payload []byte) error

CloseSend half-closes this side's send direction, optionally carrying a final payload. It returns nil on success and an error already in the styx taxonomy on failure (the same sentinels Err() reports). Once the stream has terminated it returns that terminal error.

func (*Stream) Context

func (s *Stream) Context() context.Context

Context returns the stream's own context, carrying its deadline and canceled when the stream terminates (stream-protocol.md §7.1). On the opener side it is rooted in the caller's OpenStream context, so it inherits the caller's values and is canceled when the caller cancels — the intended gRPC-shaped semantics. Generated stream interfaces expose it so a handler or caller can observe the stream's deadline and cancellation.

func (*Stream) Err

func (s *Stream) Err() error

Err reports the stream's terminal error once it has terminated — translated to the styx taxonomy — or nil while it is still live or completed normally. It gives seam users the terminal outcome without exposing the engine's internal outcome type.

A nil answer therefore means live-or-completed and nothing else: the terminal error is recorded by the terminal transition itself, so it is readable from that instant, without waiting on the teardown the transition's winner still has to run and without any other operation on the stream having to publish it first.

func (*Stream) Marshal

func (s *Stream) Marshal(m proto.Message) ([]byte, error)

Marshal encodes m with the connection's negotiated codec — the same codec unary calls and every other stream message use, so a second codec cannot silently fork the wire encoding (stream-protocol.md §2.4). On the opener side an encode failure the opener cannot send drives the stream terminal, freeing its slot rather than leaving it live to the deadline.

func (*Stream) RecvMsg

func (s *Stream) RecvMsg(ctx context.Context) ([]byte, error)

RecvMsg returns the next delivered STREAM_MSG payload, io.EOF at clean stream end, or an error already in the styx taxonomy (the same sentinels Err() reports). Once the stream has terminated it returns that terminal error.

Buffered payloads come first: a stream that has already terminated still delivers every message the peer sent before it reports the end of stream, so a stream that completed before the caller could read it drains in full.

func (*Stream) SendMsg

func (s *Stream) SendMsg(ctx context.Context, payload []byte) error

SendMsg sends payload as a STREAM_MSG under ctx. It returns nil on success and an error already in the styx taxonomy on failure (the same sentinels Err() reports). Once the stream has terminated it returns that terminal error.

func (*Stream) Unmarshal

func (s *Stream) Unmarshal(payload []byte, m proto.Message) error

Unmarshal decodes payload into m with the connection's negotiated codec. On the opener side a decode failure drives the stream terminal, freeing its slot.

type StreamOption

type StreamOption func(*streamConfig)

StreamOption customizes a stream's configuration before OpenStream publishes the STREAM_OPEN. Generated streaming client code composes these; a hand caller may pass them directly. The only way to construct one is the With* helpers below.

func WithBidiStream

func WithBidiStream() StreamOption

WithBidiStream declares the stream bidirectional. Its establishment matches client-streaming (no implicit half-close); the explicit shape lets generated code express the method's direction set. A client-streaming opener needs no option (it is the default shape).

func WithServerStreamRequest

func WithServerStreamRequest(request []byte) StreamOption

WithServerStreamRequest declares the stream server-streaming and attaches its single request, which rides the STREAM_OPEN frame (stream-protocol.md §6.3). The opener is half-closed-local at establishment: it sends no STREAM_MSG and emits no separate client STREAM_CLOSE. The request MAY be empty — a zero-byte server-streaming request is legal and still establishes the shape, because the shape is carried explicitly here, not inferred from the payload's length. Generated server-streaming client code passes the marshaled request; a client-streaming or bidi opener omits this option and sends its first message as a STREAM_MSG.

func WithStreamCredits

func WithStreamCredits(n uint32) StreamOption

WithStreamCredits proposes a per-direction credit N for the stream. It is clamped into (0, N_max] by OpenStream — a value of 0 or one above N_max becomes N_max — so an opener never proposes a credit its accepter must reject (stream-protocol.md §4.7).

type StreamShape

type StreamShape uint8

StreamShape is a streaming method's gRPC-shaped direction set. Generated code declares it explicitly on both sides — the opener through OpenStream's options, the accepter through RegisterStreamHandler — so neither side infers the shape from a frame's payload length (a server-streaming request MAY be zero bytes, stream-protocol.md §2.3/§6.3).

const (
	// ClientStreamingShape is the client-streaming shape (the opener streams; the
	// response rides the server's STREAM_CLOSE). It is the zero value.
	ClientStreamingShape StreamShape = StreamShape(rpcruntime.ClientStreaming)
	// ServerStreamingShape is the server-streaming shape (the single request rides
	// STREAM_OPEN and the opener is half-closed-local at establishment).
	ServerStreamingShape StreamShape = StreamShape(rpcruntime.ServerStreaming)
	// BidiStreamingShape is the bidirectional shape (both sides stream).
	BidiStreamingShape StreamShape = StreamShape(rpcruntime.BidiStreaming)
)

type Transport

type Transport string

Transport names one of the two data-plane transports a plugin can speak: shared memory or Unix domain sockets. PluginSpec.Transport uses a single Transport to select which one a host negotiates; PluginServerConfig.Transports uses a slice of it to declare which ones a plugin advertises.

It is a defined string type rather than a plain string so a typo (e.g. "shmm") is caught by the same construction-time validation that already rejects an unknown name, and so IDE autocomplete surfaces the three valid values (TransportAuto, TransportSHM, TransportUDS) instead of requiring the caller to know the exact literal.

const (
	// TransportAuto offers both the shared-memory transport and Unix domain
	// sockets, with shared memory preferred: a plugin that does not offer shared
	// memory in its own advertised allowlist gets a uds connection instead, never
	// a spawn failure. It is also what the zero value ("", PluginSpec's default)
	// means.
	TransportAuto Transport = "auto"

	// TransportSHM pins the shared-memory transport: a plugin whose advertised
	// allowlist does not include it fails the handshake with *IncompatibleError,
	// never silently downgrading to Unix domain sockets.
	TransportSHM Transport = "shm"

	// TransportUDS pins Unix domain sockets.
	TransportUDS Transport = "uds"
)

type WedgeKind added in v0.3.0

type WedgeKind int

WedgeKind discriminates the two components a WedgedError can report as stalled.

const (
	// WedgeTransport reports a stalled ring consumer: the plugin's consume
	// counter is frozen while inbound work is still readable.
	WedgeTransport WedgeKind = iota
	// WedgeDispatch reports a stalled dispatch: a response is owed with no
	// live handler lease renewing it.
	WedgeDispatch
)

type WedgedError added in v0.3.0

type WedgedError struct {
	Kind WedgeKind
}

WedgedError reports that a plugin instance stopped making progress long enough for the heartbeat classifier to declare it unhealthy, so the supervisor ended it: either a stalled ring consumer with queued work, or a dispatch owing a response with no running handler. Kind tells the two apart. It appears in Host.Events() event errors and, while that verdict is still the plugin's most recent recorded transition, as HealthSnapshot.LastError. errors.Is(err, ErrWedged) matches any *WedgedError; errors.As(err, &wedgedErr) recovers which component wedged.

func (*WedgedError) Error added in v0.3.0

func (e *WedgedError) Error() string

func (*WedgedError) Is added in v0.3.0

func (e *WedgedError) Is(target error) bool

Is reports whether target is ErrWedged.

Directories

Path Synopsis
bench
internal/benchbaseline
Package benchbaseline holds baseline IPC implementations (direct function calls, raw UDS, net/rpc, and gRPC over TCP/UDS) for benchmarking against the shared-memory transport.
Package benchbaseline holds baseline IPC implementations (direct function calls, raw UDS, net/rpc, and gRPC over TCP/UDS) for benchmarking against the shared-memory transport.
rpc
Package rpc measures the framework RPC layer end to end: generated stub -> ClientConn -> negotiated codec -> transport -> plugin dispatch and back, across a real process boundary.
Package rpc measures the framework RPC layer end to end: generated stub -> ClientConn -> negotiated codec -> transport -> plugin dispatch and back, across a real process boundary.
spike/arena
Package arena implements the spike's single-writer slab allocator.
Package arena implements the spike's single-writer slab allocator.
spike/cmd/spikeplugin command
Command spikeplugin is the spike's child-process binary: it reads the inherited control fd (fd 3), receives the shared region and two eventfds via SCM_RIGHTS, maps the region, sends a ready byte, then serves an echo loop (dequeue a request, copy its payload into a response-arena slab, enqueue the response, signal).
Command spikeplugin is the spike's child-process binary: it reads the inherited control fd (fd 3), receives the shared region and two eventfds via SCM_RIGHTS, maps the region, sends a ready byte, then serves an echo loop (dequeue a request, copy its payload into a response-arena slab, enqueue the response, signal).
spike/event
Package event implements the spike's eventfd hybrid spin-then-park waiter and its arming protocol.
Package event implements the spike's eventfd hybrid spin-then-park waiter and its arming protocol.
spike/harness
Package harness spawns the spike plugin child process and passes the shared-memory region and eventfds over a control socketpair.
Package harness spawns the spike plugin child process and passes the shared-memory region and eventfds over a control socketpair.
spike/ring
Package ring implements the spike's SPSC descriptor ring.
Package ring implements the spike's SPSC descriptor ring.
spike/shmregion
Package shmregion implements the spike's memfd-backed shared-memory region: creation, sealing, mmap, and the fixed spike layout.
Package shmregion implements the spike's memfd-backed shared-memory region: creation, sealing, mmap, and the fixed spike layout.
stream
Package stream measures raw stream Send cost -- Stream.SendMsg over a real process boundary, through the shared-memory transport -- across a size ladder that spans both sides of the per-direction inline limit.
Package stream measures raw stream Send cost -- Stream.SendMsg over a real process boundary, through the shared-memory transport -- across a size ladder that spans both sides of the per-direction inline limit.
cmd
protoc-gen-go-styx command
Package main implements protoc-gen-go-styx, the code generator that turns an ordinary gRPC-compatible `service` definition into a Styx client/server pair: `New<Service>Client` for the host side and `Register<Service>Server` for the plugin side, wired to the shared-memory data plane through styx.ClientConn.Invoke / styx.PluginServer instead of gRPC.
Package main implements protoc-gen-go-styx, the code generator that turns an ordinary gRPC-compatible `service` definition into a Styx client/server pair: `New<Service>Client` for the host side and `Register<Service>Server` for the plugin side, wired to the shared-memory data plane through styx.ClientConn.Invoke / styx.PluginServer instead of gRPC.
Package codec defines the Codec and SizedMarshaler interfaces for marshaling RPC payloads and Status on the wire.
Package codec defines the Codec and SizedMarshaler interfaces for marshaling RPC payloads and Status on the wire.
examples
device-gateway/host command
Command device-gateway-host drives the DevicePlugin contract (examples/device-gateway/deviceplugin) end to end: every lifecycle method against the reference plugin, in the real contract's own order (Init, then a runtime-state guard check, then LoadRuntimeState before Start), a runtime-state round trip across a real process restart, and both of the faulty plugin's failure modes, printing each typed error's classification as Styx reports it.
Command device-gateway-host drives the DevicePlugin contract (examples/device-gateway/deviceplugin) end to end: every lifecycle method against the reference plugin, in the real contract's own order (Init, then a runtime-state guard check, then LoadRuntimeState before Start), a runtime-state round trip across a real process restart, and both of the faulty plugin's failure modes, printing each typed error's classification as Styx reports it.
device-gateway/plugin command
Command device-gateway-plugin is a reference implementation of the DevicePlugin lifecycle contract (examples/device-gateway/deviceplugin): a device with one piece of runtime state — a generation counter, incremented each time it is (re)configured — carried across a process restart through SaveRuntimeState and LoadRuntimeState.
Command device-gateway-plugin is a reference implementation of the DevicePlugin lifecycle contract (examples/device-gateway/deviceplugin): a device with one piece of runtime state — a generation counter, incremented each time it is (re)configured — carried across a process restart through SaveRuntimeState and LoadRuntimeState.
device-gateway/plugin/faulty command
Command device-gateway-faulty-plugin deliberately misbehaves to exercise how the DevicePlugin contract's two error classes propagate through Styx.
Command device-gateway-faulty-plugin deliberately misbehaves to exercise how the DevicePlugin contract's two error classes propagate through Styx.
echo/host command
Command echo-host spawns the echo plugin, verifies its service requirements, logs every lifecycle event, and calls Say once to demonstrate the message round-trip.
Command echo-host spawns the echo plugin, verifies its service requirements, logs every lifecycle event, and calls Say once to demonstrate the message round-trip.
echo/plugin command
Command echo-plugin registers handlers for the EchoServer (echoing each request back unchanged) and EchoStreamServer (with deterministic responses for all three streaming shapes), then serves until the host closes the connection.
Command echo-plugin registers handlers for the EchoServer (echoing each request back unchanged) and EchoStreamServer (with deterministic responses for all three streaming shapes), then serves until the host closes the connection.
echo/plugin/crashy command
Command echo-crashy-plugin deliberately misbehaves to test crash-timing, restart policies, hot-reload, wedge detection, and panic handling — all scenarios the normal example avoids by design.
Command echo-crashy-plugin deliberately misbehaves to test crash-timing, restart policies, hot-reload, wedge detection, and panic handling — all scenarios the normal example avoids by design.
hot-reload/host command
Command hot-reload-host spawns a stateful counter plugin, makes initial calls to build state, hot-reloads it in place, and makes more calls to verify the state survived.
Command hot-reload-host spawns a stateful counter plugin, makes initial calls to build state, hot-reloads it in place, and makes more calls to verify the state survived.
hot-reload/plugin command
Command hot-reload-plugin is a stateful Echo server that carries its state through hot-reload.
Command hot-reload-plugin is a stateful Echo server that carries its state through hot-reload.
slow-handler/host command
Command slow-handler-host drives examples/slow-handler/plugin hard enough that the plugin cannot keep up, and reports what an operator would see: the call latency distribution, and the shared-memory arena's own stall counters.
Command slow-handler-host drives examples/slow-handler/plugin hard enough that the plugin cannot keep up, and reports what an operator would see: the call latency distribution, and the shared-memory arena's own stall counters.
slow-handler/plugin command
Command slow-handler-plugin serves the echo services with a handler that is deliberately slow, so a host calling it faster than it can answer drives the shared-memory data plane into backpressure on purpose.
Command slow-handler-plugin serves the echo services with a handler that is deliberately slow, so a host calling it faster than it can answer drives the shared-memory data plane into backpressure on purpose.
streaming/host command
Command streaming-host spawns the echo plugin and exercises all three streaming shapes from the host side: server-streaming (Feed), client-streaming (Collect), and bidirectional (Chat).
Command streaming-host spawns the echo plugin and exercises all three streaming shapes from the host side: server-streaming (Feed), client-streaming (Collect), and bidirectional (Chat).
internal
arena
Package arena implements the per-direction slab allocator docs/specs/shm-abi.md §6 defines: a size-classed allocator over one direction's payload arena, laid directly on internal/shm's decoded geometry.
Package arena implements the per-direction slab allocator docs/specs/shm-abi.md §6 defines: a size-classed allocator over one direction's payload arena, laid directly on internal/shm's decoded geometry.
control
Package control implements the Styx control-plane protocol.
Package control implements the Styx control-plane protocol.
event
Package event implements the eventfd-backed hybrid spin-then-park waiter for cross-process wakeup notification between host and plugin.
Package event implements the eventfd-backed hybrid spin-then-park waiter for cross-process wakeup notification between host and plugin.
lifecycle
Package lifecycle implements the process-level primitives of host- and plugin-side lifecycle: spawning a plugin child with a sanitized environment and the inherited control fd (Spawn), the plugin's "never outlive the host" death-signal bootstrap (InstallDeathSignal), and the normative 6-step teardown state machine (Teardown) whose ordering — stop admission, fail in-flight, join goroutines, unmap, terminate-and-reap, close fds — is fixed and never reordered.
Package lifecycle implements the process-level primitives of host- and plugin-side lifecycle: spawning a plugin child with a sanitized environment and the inherited control fd (Spawn), the plugin's "never outlive the host" death-signal bootstrap (InstallDeathSignal), and the normative 6-step teardown state machine (Teardown) whose ordering — stop admission, fail in-flight, join goroutines, unmap, terminate-and-reap, close fds — is fixed and never reordered.
panics
Package panics holds the one operation every recover boundary in this repository needs and none of them can safely write inline: turning a recovered panic value into text.
Package panics holds the one operation every recover boundary in this repository needs and none of them can safely write inline: turning a recovered panic value into text.
ring
Package ring implements the single-producer/single-consumer descriptor ring docs/specs/shm-abi.md defines: the fixed 64-byte descriptor (§4, §5) and the lock-free ring of those descriptors over shared memory (§8 enqueue, §9 dequeue, §10 wraparound arithmetic).
Package ring implements the single-producer/single-consumer descriptor ring docs/specs/shm-abi.md defines: the fixed 64-byte descriptor (§4, §5) and the lock-free ring of those descriptors over shared memory (§8 enqueue, §9 dequeue, §10 wraparound arithmetic).
rpcruntime
Package rpcruntime implements the request and stream tables that manage the lifetime of unary RPC calls and gRPC-shaped streams on a connection.
Package rpcruntime implements the request and stream tables that manage the lifetime of unary RPC calls and gRPC-shaped streams on a connection.
shm
Package shm implements the memfd-backed shared-memory region for cross-process communication between host and plugin.
Package shm implements the memfd-backed shared-memory region for cross-process communication between host and plugin.
supervisor
Package supervisor implements process supervision: plugin spawn/heartbeat lifecycle, health classification, restart policy execution with backoff, and crash reason capture.
Package supervisor implements process supervision: plugin spawn/heartbeat lifecycle, health classification, restart policy execution with backoff, and crash reason capture.
testutil
Package testutil provides shared resource-accounting helpers for tests that assert a unit of work returns the process to its pre-test baseline: open file descriptors, live goroutines, and retained heap.
Package testutil provides shared resource-accounting helpers for tests that assert a unit of work returns the process to its pre-test baseline: open file descriptors, live goroutines, and retained heap.
transport
Package transport defines the message-oriented transport abstraction used by the Styx RPC runtime.
Package transport defines the message-oriented transport abstraction used by the Styx RPC runtime.
transport/difftest
Package difftest is the transport differential test harness.
Package difftest is the transport differential test harness.
transport/shm
Package shm builds the single writer goroutine and the two-lane intent queue that sit on top of one already-attached descriptor ring and payload arena (internal/ring, internal/arena).
Package shm builds the single writer goroutine and the two-lane intent queue that sit on top of one already-attached descriptor ring and payload arena (internal/ring, internal/arena).
transport/shm/chaos
Package chaos proves the shared-memory transport's crash-state and cleanup behavior across a REAL process boundary with REAL signals, at the correctness-defining windows in a frame's lifecycle.
Package chaos proves the shared-memory transport's crash-state and cleanup behavior across a REAL process boundary with REAL signals, at the correctness-defining windows in a frame's lifecycle.
transport/shm/shmtest
Package shmtest builds an in-process host/plugin shm.Transport pair for tests: one memfd region, two cross-wired eventfds, and both ends attached — the construction seam that keeps a package testing against the shm transport as a black box (through transport.Transport alone) from having to import internal/shm or internal/event itself.
Package shmtest builds an in-process host/plugin shm.Transport pair for tests: one memfd region, two cross-wired eventfds, and both ends attached — the construction seam that keeps a package testing against the shm transport as a black box (through transport.Transport alone) from having to import internal/shm or internal/event itself.
Package observe defines Styx's metrics, logging, and tracing hook interfaces and their no-op defaults, with no vendor dependencies.
Package observe defines Styx's metrics, logging, and tracing hook interfaces and their no-op defaults, with no vendor dependencies.
scripts
bench-compare command
Command bench-compare gates a shared-memory benchmark against a checked-in baseline.
Command bench-compare gates a shared-memory benchmark against a checked-in baseline.
Package supervisor provides the restart policy configuration surface for plugin process supervision.
Package supervisor provides the restart policy configuration surface for plugin process supervision.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL