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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Ferret Wire is a versioned gRPC boundary for hosting an implementation of the [Unified Ferret API](https://github.com/MontFerret/api) in another process. It lets a host expose compilation, execution, and source-level debugging without moving runtime construction, configuration, policy, or listener security into this library.

This module targets Go 1.25 and Unified API `v1.0.0-alpha.19`. The v1 protobuf package is `ferret.wire.v1`; its sources live in `proto/ferret/wire/v1`, and the checked-in Go bindings live in `gen/ferret/wire/v1`.
This module targets Go 1.25 and Unified API `v1.0.0-alpha.20`. The v1 protobuf package is `ferret.wire.v1`; its sources live in `proto/ferret/wire/v1`, and the checked-in Go bindings live in `gen/ferret/wire/v1`.

## Ownership and architecture

Expand Down Expand Up @@ -42,7 +42,16 @@ Unary execution and debug resume calls publish work before returning. Once publi

`DefaultLimits` bounds client-controlled state to 64 logical connections; 128 plans, 128 normal sessions, and 128 executions per connection; 32 debug sessions per connection; 8 watchers per execution or debug session; 256 breakpoints per debug session; and 4 MiB for both inbound and outbound gRPC messages. Pending, active, and closing resources all count. Hosts may replace the complete positive limit set with `WithLimits`.

The one-shot Connect handshake publishes the connection ID, Wire protocol name and version, and optional host identity supplied through `WithRuntimeIdentity`. It does not publish fabricated capabilities, a Ferret version, or module-build metadata.
The one-shot Connect handshake publishes four distinct values: `connection_id`
identifies the Wire lifecycle scope; `protocol` identifies Wire; `runtime_version`
is the exact opaque value from the hosted `api.Runtime.Version(ctx)`; and optional
`runtime_identity` is host-supplied application or instance identity configured
through `WithRuntimeIdentity`. `runtime_version` and `runtime_identity.version`
have different owners and semantics, even when their strings happen to match.
Runtime version bytes are preserved exactly, including invalid UTF-8. An empty
runtime version is valid when present; alpha.20 clients reject handshakes
without the field. Wire exposes no capability negotiation, Ferret-specific build
metadata, Git metadata, or module inventories.

The Go client converts values supplied through `api.WithParam` and `api.WithParams` without reflection. It accepts `nil`, booleans, signed integer types, unsigned integers that fit in `int64`, finite `float32`/`float64`, strings, `[]byte`, `[]any`, and `map[string]any`. Duration, datetime, regexp, and other Go types are rejected locally.

Expand Down Expand Up @@ -149,13 +158,17 @@ only the hosted plan after admitted constructors settle; existing children
survive. Close every returned child. Transport release and lost-allocation
recovery still cascade. Output presence and available output accompanying an
error are preserved. Filesystem roots and output content types preserve explicit
empty settings; the hosted runtime owns their validation.
empty settings; the hosted runtime owns their validation. `Runtime.Version(ctx)`
reads the Connect snapshot and `Plan.Params(ctx)` returns a defensive copy of the
parameter snapshot captured during compile. Both validate non-nil caller contexts,
preserve cancellation/deadline errors, and remain readable after cleanup without
another RPC.

Debugger methods take caller contexts. `RunCommand` streams preserve Start's
execution lifetime and each resume's request context; cancellation does not
release the debugger. Atomic breakpoint replacement works while running, and
breakpoint enumeration remains available after explicit Close. See the complete
[alpha.19 method and setter audit](docs/uapi-audit.md).
breakpoint enumeration remains available after explicit Close. See the
[alpha.20 interface coverage](test/integration/README.md#interface-coverage).

The public client exports only `New`, `Error`, `ErrClosed`, and
`ErrExecutionCancelled`. Existing users of `NewRuntime` should call `New`;
Expand Down
5 changes: 4 additions & 1 deletion client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"google.golang.org/grpc"

"github.com/MontFerret/api"
wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1"
"github.com/MontFerret/wire/pkg/failure"
)
Expand All @@ -22,6 +23,7 @@ type (
debugClient wirev1.DebugServiceClient

connectionID string
runtimeVersion api.Version
stream wirev1.RuntimeService_ConnectClient
streamCancel context.CancelFunc
streamDone chan struct{}
Expand Down Expand Up @@ -85,7 +87,7 @@ func newConnection(ctx context.Context, connection grpc.ClientConnInterface) (*c
}

if response.GetConnectionId().GetValue() == "" || response.GetProtocol() == nil ||
response.GetProtocol().GetName() == "" || response.GetProtocol().GetVersion() == "" {
response.GetProtocol().GetName() == "" || response.GetProtocol().GetVersion() == "" || response.RuntimeVersion == nil {
streamCancel()

return nil, errors.New("Wire server returned an invalid Connect handshake")
Expand All @@ -99,6 +101,7 @@ func newConnection(ctx context.Context, connection grpc.ClientConnInterface) (*c
executionClient: wirev1.NewExecutionServiceClient(connection),
debugClient: wirev1.NewDebugServiceClient(connection),
connectionID: response.GetConnectionId().GetValue(),
runtimeVersion: api.Version(response.GetRuntimeVersion()),
stream: stream,
streamCancel: streamCancel,
streamDone: make(chan struct{}),
Expand Down
5 changes: 3 additions & 2 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ func (s *clientTestServer) Connect(_ *wirev1.ConnectRequest, stream wirev1.Runti
response := s.handshake
if response == nil {
response = &wirev1.ConnectResponse{
ConnectionId: &wirev1.ConnectionId{Value: "connection"},
Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"},
ConnectionId: &wirev1.ConnectionId{Value: "connection"},
Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"},
RuntimeVersion: []byte{},
}
}

Expand Down
13 changes: 9 additions & 4 deletions client/constructor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ func TestNewFailureReturnsNilRuntimeAndClosesHandshake(t *testing.T) {
}{
{name: "RPC failure", err: status.Error(codes.Unavailable, "handshake unavailable")},
{name: "empty handshake", handshake: &wirev1.ConnectResponse{}},
{name: "missing ID", handshake: &wirev1.ConnectResponse{Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"}}},
{name: "missing protocol", handshake: &wirev1.ConnectResponse{ConnectionId: &wirev1.ConnectionId{Value: "connection"}}},
{name: "missing name", handshake: &wirev1.ConnectResponse{ConnectionId: &wirev1.ConnectionId{Value: "connection"}, Protocol: &wirev1.ProtocolInfo{Version: "v1"}}},
{name: "missing version", handshake: &wirev1.ConnectResponse{ConnectionId: &wirev1.ConnectionId{Value: "connection"}, Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire"}}},
{name: "missing ID", handshake: &wirev1.ConnectResponse{RuntimeVersion: []byte{}, Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"}}},
{name: "missing protocol", handshake: &wirev1.ConnectResponse{RuntimeVersion: []byte{}, ConnectionId: &wirev1.ConnectionId{Value: "connection"}}},
{name: "missing name", handshake: &wirev1.ConnectResponse{RuntimeVersion: []byte{}, ConnectionId: &wirev1.ConnectionId{Value: "connection"}, Protocol: &wirev1.ProtocolInfo{Version: "v1"}}},
{name: "missing protocol version", handshake: &wirev1.ConnectResponse{RuntimeVersion: []byte{}, ConnectionId: &wirev1.ConnectionId{Value: "connection"}, Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire"}}},
{name: "missing runtime version", handshake: &wirev1.ConnectResponse{ConnectionId: &wirev1.ConnectionId{Value: "connection"}, Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"}, RuntimeIdentity: &wirev1.RuntimeIdentity{Name: "host", Version: "host-7.1"}}},
} {
t.Run(test.name, func(t *testing.T) {
ended := make(chan struct{})
Expand All @@ -36,6 +37,10 @@ func TestNewFailureReturnsNilRuntimeAndClosesHandshake(t *testing.T) {
t.Fatalf("New returned %v, %v; want nil runtime and an error", remote, err)
}

if test.err == nil && err.Error() != "Wire server returned an invalid Connect handshake" {
t.Fatalf("malformed handshake used a different failure path: %v", err)
}

if test.err != nil {
var decoded *Error
if !errors.As(err, &decoded) || status.Code(err) != codes.Unavailable {
Expand Down
5 changes: 3 additions & 2 deletions client/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ type lifecycleServer struct {

func (s *lifecycleServer) Connect(_ *wirev1.ConnectRequest, stream wirev1.RuntimeService_ConnectServer) error {
err := stream.Send(&wirev1.ConnectResponse{
ConnectionId: &wirev1.ConnectionId{Value: "connection"},
Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"},
ConnectionId: &wirev1.ConnectionId{Value: "connection"},
Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"},
RuntimeVersion: []byte{},
})
if err != nil || s.disconnect {
return err
Expand Down
5 changes: 3 additions & 2 deletions client/ownership_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ func (s *handleServer) Connect(_ *wirev1.ConnectRequest, stream wirev1.RuntimeSe
s.mu.Unlock()

if err := stream.Send(&wirev1.ConnectResponse{
ConnectionId: &wirev1.ConnectionId{Value: id},
Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"},
ConnectionId: &wirev1.ConnectionId{Value: id},
Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"},
RuntimeVersion: []byte{},
}); err != nil {
return err
}
Expand Down
8 changes: 7 additions & 1 deletion client/remote_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ type remotePlan struct {

var _ api.Plan = (*remotePlan)(nil)

func (p *remotePlan) Params() ([]string, error) {
// Params returns a caller-owned copy of the compile snapshot, including after
// plan or transport cleanup. Context validation requires no lifecycle admission.
func (p *remotePlan) Params(ctx context.Context) ([]string, error) {
if p == nil || p.plan == nil {
return nil, ErrClosed
}

if err := runtimeContextError(ctx); err != nil {
return nil, err
}

return p.plan.Parameters(), nil
}

Expand Down
21 changes: 18 additions & 3 deletions client/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ type remoteRuntime struct {
var _ api.Runtime = (*remoteRuntime)(nil)

// New opens a logical Wire connection and exposes it through the
// canonical api.Runtime interface. The context bounds the handshake; cancelling
// it after construction does not close the runtime. Close releases the logical
// connection and its resources with bounded detached cleanup, leaving the
// canonical api.Runtime interface. Connect must include the hosted runtime version,
// including presence for an empty value. The context bounds the handshake;
// cancelling it after construction does not close the runtime. Close releases the
// logical connection and its resources with bounded detached cleanup, leaving the
// caller-owned transport open. On failure, New returns a nil interface.
func New(ctx context.Context, connection grpc.ClientConnInterface) (api.Runtime, error) {
wireClient, err := newConnection(ctx, connection)
Expand All @@ -36,6 +37,20 @@ func New(ctx context.Context, connection grpc.ClientConnInterface) (api.Runtime,
return &remoteRuntime{client: wireClient}, nil
}

// Version returns the immutable hosted version captured during Connect, including
// after Close or transport loss. Reading it does not retain the connection.
func (r *remoteRuntime) Version(ctx context.Context) (api.Version, error) {
if r == nil || r.client == nil {
return "", ErrClosed
}

if err := runtimeContextError(ctx); err != nil {
return "", err
}

return r.client.runtimeVersion, nil
}

// Run invokes the hosted api.Runtime.Run operation once and releases the temporary
// Wire execution used to preserve cancellation, output, and failure semantics.
func (r *remoteRuntime) Run(ctx context.Context, src api.Source, options ...api.SessionOption) (output *api.Output, resultErr error) {
Expand Down
5 changes: 5 additions & 0 deletions client/runtime_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import (
// on failure, without requiring a Wire resource type in consumer code.
var _ func(context.Context, grpc.ClientConnInterface) (api.Runtime, error) = client.New

var (
_ func(api.Runtime, context.Context) (api.Version, error) = api.Runtime.Version
_ func(api.Plan, context.Context) ([]string, error) = api.Plan.Params
)

func TestNewRejectsMissingTransport(t *testing.T) {
remote, err := client.New(t.Context(), nil)
if err == nil || remote != nil {
Expand Down
31 changes: 19 additions & 12 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ callback. There is no dependency-carrying operation context.

Services convert protobuf sources and options to canonical API types before
calling core. Source/diagnostic, option/value, output, execution, debugger, and
failure conversions are grouped at the transport boundary. Handshake metadata
belongs to transport configuration, not resource management. Domain errors
supply shared Wire categories; gRPC owns status mapping and uses the same
failure conversions are grouped at the transport boundary. Protocol and optional
host identity come from transport configuration; runtime version comes exclusively
from the borrowed `api.Runtime.Version(ctx)`. These values do not belong to resource
management. Domain errors supply shared Wire categories; gRPC owns status mapping and uses the same
category serialization as terminal failures. Canonical diagnostic extraction
is shared within server error handling.

Expand Down Expand Up @@ -103,10 +104,15 @@ listener, or reconstruct the application's modules, functions, policies,
resources, or configuration. Importing Wire has no side effects, and
`NewServer` does not listen, bind, dial, or inspect the environment.

The Connect handshake identifies the Wire protocol and may include a
host-supplied runtime identity. It does not claim a Ferret version, runtime
capability set, module inventory, or runtime implementation metadata that the
Unified API cannot provide portably. The API's portable
The Connect handshake contains the logical connection ID, Wire protocol identity,
the exact portable runtime version, and optional host-supplied identity. Runtime
version is independent of `RuntimeIdentity.version`: the latter describes the
host application or instance. Version retrieval uses the stream context joined
with logical connection cancellation, inside the external implementation panic
boundary. Failure reclaims the connection and follows existing sanitized error
and context-status conventions. Presence distinguishes an empty successful
version from an unsupported older handshake. Wire does not expose Ferret-specific
build/Git metadata, runtime capabilities, or module inventories. The API's portable
`diagnostics.Diagnostics` collection is preserved, but it has no severity or
general structured runtime-error taxonomy. Wire keeps only categories needed
to operate the remote lifecycle and sanitizes implementation failures.
Expand Down Expand Up @@ -403,11 +409,12 @@ architectural reason and an explicit contract.

## Universal API contract

Wire targets `v1.0.0-alpha.19`; the complete retained method/setter audit is in
[Universal API projection](uapi-audit.md). Output is optional and independent of
execution or cleanup failure. `(nil, nil)` is an invalid hosted execution result.
Compile retrieves fallible parameter metadata and copies it before publication;
metadata error or panic closes the unpublished plan once and joins cleanup errors.
Wire targets `v1.0.0-alpha.20`; the retained methods and owning tests are listed in
[Universal API integration coverage](../test/integration/README.md#interface-coverage).
Output is optional and independent of execution or cleanup failure. `(nil, nil)`
is an invalid hosted execution result.
Compile calls `api.Plan.Params(ctx)` with its allocation context and copies the
fallible parameter metadata before publication; metadata error or panic closes the unpublished plan once and joins cleanup errors.
Anonymous sources and explicitly empty filesystem roots/content types pass through
unchanged. Wire owns structural protocol checks; hosted implementations own source
validity, filesystem interpretation, and codec validation.
22 changes: 16 additions & 6 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ their canonical Universal API types directly. `client` exports only `New`,

`Runtime.Run` invokes the hosted `api.Runtime.Run` directly, once per call.
`Compile` and `CompileDebug` create reusable plans through the corresponding
hosted methods. `Plan.Params() ([]string, error)` returns a defensive copy of metadata captured
before the compiled plan was published. A hosted metadata error or panic closes
hosted methods. `Plan.Params(ctx) ([]string, error)` returns a defensive copy of
metadata captured before the compiled plan was published. A hosted metadata error or panic closes
the unpublished plan once and preserves its cleanup error. Each `NewSession`
creates one durable hosted session with the supplied semantic options;
sequential `Session.Run` calls reuse it. A concurrent run on that session is
Expand All @@ -33,8 +33,17 @@ Each runtime/session invocation privately acquires, watches, and releases an
execution. Output is `*api.Output`: its content type and encoded bytes are
copied without interpretation. Nil output differs from present empty output,
and available output survives execution or cleanup errors. A hosted `(nil, nil)`
result is invalid. No IDs, RPC handles, execution snapshots, or
connection metadata are exposed by the returned API interfaces.
result is invalid. No IDs, RPC handles, Wire execution snapshots, or protocol/host
identity metadata are exposed by the returned API interfaces. `Runtime.Version(ctx)` exposes only
the portable UAPI runtime version captured during Connect.

The Connect snapshot requires a present `runtime_version`, including when empty.
Its bytes are converted to `api.Version` unchanged, including invalid UTF-8.
Absent version metadata follows the invalid-Connect handshake path; host identity
is never substituted. `Runtime.Version(ctx)` and `Plan.Params(ctx)` reject nil
contexts and preserve `context.Canceled` and `context.DeadlineExceeded`. Both read
immutable snapshots without another RPC or lifecycle admission and remain available
after resource or transport cleanup. Parameter slices are caller-owned.

## Options and parameters

Expand Down Expand Up @@ -224,5 +233,6 @@ compatibility shims.

Use canonical runtime/plan/session operations, cancellation contexts, and
debugger events. The versioned protobuf services and shared domain packages
are extended additively for alpha.19; callers implementing protocol tooling may
still use the generated bindings directly. See the [contract audit](uapi-audit.md).
are extended additively for alpha.19 and alpha.20; callers implementing protocol
tooling may still use the generated bindings directly. See the
[alpha.20 protocol contract](protocol.md#alpha20-metadata-adoption).
Loading
Loading