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
- Variables
- func IsRetryable(err error) bool
- func RegisterIdentityName(id uint64, name string)
- func StreamError(err error) error
- func WithDedupKey(ctx context.Context, key DedupKey) context.Context
- type BackoffFunc
- type ClientConn
- func (c *ClientConn) Invoke(ctx context.Context, service, method string, req, resp proto.Message) error
- func (c *ClientConn) InvokeID(ctx context.Context, serviceID, methodID uint64, req, resp proto.Message) error
- func (c *ClientConn) InvokeIDFactory(ctx context.Context, serviceID, methodID uint64, req proto.Message, ...) (proto.Message, error)
- func (c *ClientConn) OpenServerStreamID(ctx context.Context, serviceID, methodID uint64, req proto.Message) (*Stream, error)
- func (c *ClientConn) OpenStream(ctx context.Context, service, method string, opts ...StreamOption) (*Stream, error)
- func (c *ClientConn) OpenStreamID(ctx context.Context, serviceID, methodID uint64, opts ...StreamOption) (*Stream, error)
- type Code
- type ConfigError
- type DedupKey
- type Event
- type EventKind
- type HandshakeOffer
- type HealthSnapshot
- type Host
- type HostConfig
- type IncompatibleError
- type IncompatibleKind
- type MethodDesc
- type MissedHeartbeatsError
- type Mutator
- type PluginCrashError
- type PluginPanicError
- type PluginServer
- func (s *PluginServer) RegisterMutator(m Mutator)
- func (s *PluginServer) RegisterService(desc *ServiceDesc, impl any)
- func (s *PluginServer) RegisterStateRestorer(restorer StateRestorer)
- func (s *PluginServer) RegisterStateSaver(saver StateSaver)
- func (s *PluginServer) RegisterStreamHandler(service, method string, shape StreamShape, handler func(*Stream) error)
- func (s *PluginServer) RegisterStreamHandlerID(serviceID, methodID uint64, shape StreamShape, handler func(*Stream) error)
- func (s *PluginServer) Serve() error
- type PluginServerConfig
- type PluginSpec
- type RestartPolicy
- type ServiceDesc
- type ServiceRequirement
- type ShmGeometry
- type ShmSizeClass
- type StateRestorer
- type StateSaver
- type Status
- type StdioSink
- type Stream
- func (s *Stream) CloseSend(ctx context.Context, payload []byte) error
- func (s *Stream) Context() context.Context
- func (s *Stream) Err() error
- func (s *Stream) Marshal(m proto.Message) ([]byte, error)
- func (s *Stream) RecvMsg(ctx context.Context) ([]byte, error)
- func (s *Stream) SendMsg(ctx context.Context, payload []byte) error
- func (s *Stream) Unmarshal(payload []byte, m proto.Message) error
- type StreamOption
- type StreamShape
- type Transport
- type WedgeKind
- type WedgedError
Constants ¶
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.
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 ¶
var ( // 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") )
var ExpBackoff = supervisor.ExpBackoff
ExpBackoff is the supervisor's exponential-backoff implementation, aliased here so ExpBackoff and supervisor.ExpBackoff refer to the identical value.
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 ¶
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
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 ¶
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 ¶
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).
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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.
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.
Source Files
¶
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. |