diff --git a/README.md b/README.md index f8f5cdb..b24ed79 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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`; diff --git a/client/client.go b/client/client.go index 643a5c3..7c7745b 100644 --- a/client/client.go +++ b/client/client.go @@ -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" ) @@ -22,6 +23,7 @@ type ( debugClient wirev1.DebugServiceClient connectionID string + runtimeVersion api.Version stream wirev1.RuntimeService_ConnectClient streamCancel context.CancelFunc streamDone chan struct{} @@ -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") @@ -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{}), diff --git a/client/client_test.go b/client/client_test.go index 2ac2d74..6725468 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -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{}, } } diff --git a/client/constructor_test.go b/client/constructor_test.go index 32824b1..aef5221 100644 --- a/client/constructor_test.go +++ b/client/constructor_test.go @@ -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{}) @@ -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 { diff --git a/client/lifecycle_test.go b/client/lifecycle_test.go index c331aa0..0fd1f05 100644 --- a/client/lifecycle_test.go +++ b/client/lifecycle_test.go @@ -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 diff --git a/client/ownership_test.go b/client/ownership_test.go index 6cc4891..c2e0b8b 100644 --- a/client/ownership_test.go +++ b/client/ownership_test.go @@ -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 } diff --git a/client/remote_plan.go b/client/remote_plan.go index 1257436..9fd1330 100644 --- a/client/remote_plan.go +++ b/client/remote_plan.go @@ -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 } diff --git a/client/runtime.go b/client/runtime.go index 870008e..dc0c458 100644 --- a/client/runtime.go +++ b/client/runtime.go @@ -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) @@ -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) { diff --git a/client/runtime_contract_test.go b/client/runtime_contract_test.go index 35a0760..664b1df 100644 --- a/client/runtime_contract_test.go +++ b/client/runtime_contract_test.go @@ -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 { diff --git a/docs/architecture.md b/docs/architecture.md index edcd889..4906709 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. @@ -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. @@ -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. diff --git a/docs/client.md b/docs/client.md index 3f36117..da57e3b 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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 @@ -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 @@ -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). diff --git a/docs/protocol.md b/docs/protocol.md index fd2ae2c..59bc346 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -143,13 +143,13 @@ and field. Rows that list several fields classify each listed field. | RPC `Run` | A/B/C | Creates one connection-owned Execution that invokes hosted `api.Runtime.Run`. | | `RunRequest` | A/B/C | `connection_id=1`, `source=2`, optional `parameters=3`, requested `output_content_type=4`, optional `fs_root=5`, `output_content_type_set=6`. | | `RunResponse` | B/C | Required running `execution=1`. | -| RPC `Connect` | B/C | `ConnectRequest` to streaming `ConnectResponse`; creates and signals one logical connection. | +| RPC `Connect` | A/B/C | `ConnectRequest` to streaming `ConnectResponse`; creates and signals one logical connection. | | RPC `CloseConnection` | B/C | Explicit connection teardown and empty acknowledgement. | | `ConnectionId` | B | `value=1`, opaque and non-empty. | | `ProtocolInfo` | C | `name=1`, `version=2`; identifies Wire, not the runtime. | | `RuntimeIdentity` | C | Host-supplied `name=1`, optional `version=2`, optional `instance_id=3`. | | `ConnectRequest` | C | Empty request. | -| `ConnectResponse` | B/C | `connection_id=3`, `protocol=4`, optional `runtime_identity=5`; sent once. | +| `ConnectResponse` | A/B/C | `connection_id=3` (B), `protocol=4` (C), optional `runtime_identity=5` (C), optional opaque bytes `runtime_version=6` (A/C); sent once. Runtime version presence is required by alpha.20 clients. | | `CloseConnectionRequest` | B/C | `connection_id=1`. | | `CloseConnectionResponse` | C | Empty acknowledgement. | | enum `ErrorCategory` | A/B/C | `UNSPECIFIED=0`; compilation `2`, execution `3`; Plan `4`, Execution `5`, DebugSession `6`, Connection `7`, Session `16` not found; invalid state `8`; internal runtime boundary `10`; watcher lag `11`; breakpoint not found `15`. | @@ -288,8 +288,8 @@ and field. Rows that list several fields classify each listed field. ### D: implementation leakage -- Ferret version, module-build metadata, runtime module inventories, native - runtime values, native diagnostic spans/source identities, panic data, and +- Ferret-specific build/Git metadata, module-build metadata, runtime module + inventories, native runtime values, native diagnostic spans/source identities, panic data, and arbitrary implementation errors are absent. - `Capability`, `RuntimeInfo`, `ConnectionOpened`, `ResourceKind`, and `DiagnosticSpan` messages/enums are absent. @@ -332,8 +332,8 @@ and field. Rows that list several fields classify each listed field. Wire does not invent diagnostic severity, a general structured runtime-error taxonomy, a Unified API declaration of accepted parameter values, runtime -introspection/versioning, or capability negotiation. Host-supplied -`RuntimeIdentity` is not presented as API introspection. +introspection beyond portable `api.Runtime.Version`, or capability negotiation. +Host-supplied `RuntimeIdentity` is not presented as API introspection. Native Ferret consumer migration, bytecode/node protocols, distributed execution, and advanced negotiated capabilities remain separate work. The public client @@ -391,3 +391,36 @@ RunCommand allows at most `MaxWatchersPerResource + 1` live streams per debugger Reservations remain charged until the stream handler exits. A debugger poisoned by an implementation panic is closed and never reused for final enumeration; the sanitized poisoning error is retained instead. + +## Alpha.20 metadata adoption + +Connect contains four independently owned concepts: + +| Field | Owner and meaning | +| --- | --- | +| `connection_id` | Wire logical resource/lifecycle identity | +| `protocol` | Wire protocol name/version | +| `runtime_version` | Exact opaque version of the hosted `api.Runtime` implementation | +| `runtime_identity` | Optional host-supplied application/instance identity | + +`runtime_version` is a UAPI projection (class A), encoded as optional bytes field +6. Bytes preserve the opaque UAPI value exactly without requiring valid UTF-8. +The server invokes hosted `Runtime.Version(ctx)` once per handshake and always +sets presence, including for an empty result. Failure or panic prevents a +successful handshake and reclaims the logical connection; existing context status +mapping and runtime-error sanitization apply. `runtime_version` is independent of +`runtime_identity.version`; neither derives from or overwrites the other. + +Alpha.20 clients reject absent runtime version as an invalid Connect handshake. +Older protobuf readers can ignore the additive field. The client retains the value +as `api.Version` and serves `Version(ctx)` locally, including after cleanup. +Field 6 is introduced by the alpha.20 adoption; its generated Go field and +getter use `[]byte`. ProtoJSON represents its value as base64. + +Normal and debug compilation call hosted `Plan.Params(ctx)` with the existing +allocation context. Ordered parameter names still cross through `Plan.parameters=2` +as an eager snapshot. Metadata failure closes the unpublished hosted plan and +joins cleanup errors; successful empty metadata is valid. Client `Params(ctx)` +returns a detached snapshot locally, including after cleanup. Both cached metadata +methods require non-nil contexts and preserve cancellation/deadline identities. +No Version or Params RPC, general introspection, or capability negotiation is added. diff --git a/docs/uapi-audit.md b/docs/uapi-audit.md deleted file mode 100644 index afeadd7..0000000 --- a/docs/uapi-audit.md +++ /dev/null @@ -1,65 +0,0 @@ -# Universal API alpha.19 projection audit - -The module pins `github.com/MontFerret/api v1.0.0-alpha.19`. The published tag and -matching API checkout resolve to `fa6c2057aef6be7f9980dcd3d8e498b66d14ecd6`. -This matches Ferret `v2.0.0-alpha.56`, pinned in the separate -[native compatibility module](../test/ferret/README.md). That suite validates -the native engine through Ferret's UAPI adapter, the public Wire server, real -gRPC, and the public Wire client. Wire's root module remains runtime independent. - -The six interfaces below are audited against that checkout. Production -compile-time assertions cover all six. The client returns `api.Runtime` and -borrows caller transport; the server borrows the hosted runtime. Public spy -integration tests use real gRPC and no native runtime dependency. - -The native suite complements this method-level audit with real execution, -parameter/content-type/filesystem options, reusable plans and durable sessions, -independent parent/child lifetimes and deferred connection release, portable -compiler diagnostics, and a breakpoint/stack/locals/evaluation debugger round -trip through completion. It also verifies hosted native usability after Wire -shutdown. Existing spies retain exhaustive failure and no-hosted-Close coverage. -The [compiler diagnostic coverage](../test/ferret/README.md#compiler-diagnostics) -includes invalid-token and EOF errors, preserving their compilation classification -and complete portable diagnostics. Bare `RETURN` retains Ferret's insertion point -at line 1, column 7, span `[6,6)` without Wire-side range normalization. - -| Interface | Method/setter | Projection and retained behavior | Coverage | -| --- | --- | --- | --- | -| `api.Runtime` | `Run(ctx, Source, ...SessionOption) (*Output, error)` | One hosted Run; optional output independent of execution/cleanup errors; temporary Execution released once. | `TestRuntimeAndSessionOutputRoundTrip`, `TestOutputPresenceAndCleanupErrors` | -| | `Compile`, `CompileDebug` | Corresponding hosted method, eager copied fallible metadata, unpublished plan cleanup. Anonymous sources preserved. | `TestCompileRoundTrip`, `TestMetadataFailureClosesUnpublishedPlan`, `TestOptionPresenceAndAnonymousSources` | -| | `Close` | Reject new root work; admitted work/descendants survive; last reference releases connection. Result stable; transport and host borrowed. | `TestRuntimeCloseBorrowsTransportAndHostedRuntime`, `TestDeferredRuntimeCloseRetainsItsResult`, `TestRuntimeClosePreservesAdmittedCompile` | -| `api.Plan` | `Params() ([]string, error)` | Defensive copy of captured metadata; no lazy hosted request. | `TestCompileRoundTrip`, metadata failure test above | -| | `NewSession`, `NewDebugSession` | Direct constructors; ordered options; admitted constructors settle before ordinary plan closure. | `TestReusablePlanAndDurableSessions`, `TestDebuggerRoundTrip`, core `TestPlanCloseSettlesAdmittedConstructorWithoutCancellingChildren` | -| | `Close` | New ClosePlan RPC closes only plan; published children survive; transport retained until last child. | `TestParentClosePreservesChildrenAndActiveWork`, core constructor test above | -| `api.Session` | `Run(ctx) (*Output, error)` | Same durable hosted session, sequential execution; optional output and joined cleanup error. | `TestOutputPresenceAndCleanupErrors`, `TestSessionRejectsOverlapAndReopensAfterRelease` | -| | `Close` | Settle active execution, release hosted session once, retain result. | `TestParentClosePreservesChildrenAndActiveWork`, `TestKnownResourceCloseFailurePreservesSiblings` | -| `debugger.Session` | `Start(ctx)` | RunCommand stream retains Start lifetime after initial result. | `TestDebuggerRoundTrip`, `TestStartContextSurvivesInitialStop` | -| | `Continue(ctx)`, `StepIn(ctx)`, `StepOver(ctx)`, `StepOut(ctx)` | Individual request contexts; independent optional event and command error. | `TestDebuggerRoundTrip`, `TestCancellationReachesHostedOperations`, `TestDebuggerCommandEventAndError` | -| | `Pause(ctx)` | Direct hosted pause; canceled requests never ask for a stop. | `TestDebuggerRoundTrip`, `TestDebuggerContextsAndDirectOperations` | -| | `SetBreakpoint(ctx, Location)` | Options absent; invokes default operation. | `TestDebuggerContextsAndDirectOperations` | -| | `SetBreakpointAt(ctx, Location, Options)` | Options present even at zero; portable metadata and signed function ID preserved. | `TestDebuggerRoundTrip` | -| | `ReplaceBreakpoints(ctx, source, []BreakpointRequest)` | One atomic call, including running; request order, default source, duplicates, unresolved entries, IDs, and metadata preserved; net cross-source quota. | `TestDebuggerReplacementAndRetainedEnumeration` | -| | `DeleteBreakpoint(ctx, ID)` | Hosted deletion with context; ID lookup scoped to debugger. | `TestDebuggerRoundTrip`, `TestErrorFamilies` | -| | `Breakpoints(ctx)` | Hosted enumeration while open; detached final data/error retained after explicit Close. | `TestDebuggerReplacementAndRetainedEnumeration`, `TestDebuggerEnumerationFailureAfterClose` | -| | `Frames(ctx)` | Order gives frame index; signed function ID includes -1. | `TestDebuggerRoundTrip`, replacement test above | -| | `Locals(ctx)`, `FrameLocals(ctx, index)` | Distinct default/indexed calls. | `TestDebuggerRoundTrip`, `TestDebuggerContextsAndDirectOperations` | -| | `Variables(ctx, reference)` | Stopped-state references; stale/invalid references remain errors. | `TestDebuggerRoundTrip` | -| | `Evaluate(ctx, expression)`, `EvaluateFrame(ctx, index, expression)` | Distinct direct hosted operations, forwarding cancellation/deadlines. | `TestDebuggerRoundTrip`, `TestCancellationReachesHostedOperations`, context test above | -| | `Close` | Cancel/settle commands, close hosted debugger once, capture final enumeration, release handle; retain close error. | `TestParentClosePreservesChildrenAndActiveWork`, `TestDebuggerEnumerationFailureAfterClose` | -| `api.PlanOptions` | `SetOptimizationLevel` | Explicit zero versus absence; valid enum check; ordered overrides. | `TestCompileRoundTrip`, `TestCompileOptionsApplyOnceBeforeDispatch` | -| `api.SessionOptions` | `SetParam`, `SetParams` | Ordered single override and map merge; snapshot portable values. | `TestSessionOptionsRoundTrip` | -| | `SetOutputContentType` | Preserve omitted/nonempty/explicit empty; host validates codec. | `TestOptionPresenceAndAnonymousSources`, protocol `TestProtocolResourceOperationsRemainAvailable` | -| | `SetFSRoot` | Preserve presence and verbatim value on runtime, temporary Execute, durable Session, and debugger creation. Host owns validation. | `TestOptionPresenceAndAnonymousSources`, protocol test above | - -All debugger context-taking methods reject nil and canceled contexts before -dispatch; `TestDebuggerContextsAndDirectOperations` covers the complete method -set. Server admission rechecks cancellation. Normal API closure does not bypass -transport release gates. Existing disconnect, allocation-loss escalation, quota, -panic containment, stale-ID, watcher, and release-error suites remain in place. - -Protocol additions are listed in [Wire Protocol](protocol.md#alpha19-additive-contract). -The [integration coverage table](../test/integration/README.md) maps the wider -lifecycle and security coverage. Runtime semantics, path interpretation, source -validity, debugger binding, atomic matching, and codec implementation stay hosted. - -Baseline and post-change measurements are recorded in [Migration benchmarks](uapi-benchmarks.md). diff --git a/docs/uapi-benchmarks.md b/docs/uapi-benchmarks.md deleted file mode 100644 index a3cf119..0000000 --- a/docs/uapi-benchmarks.md +++ /dev/null @@ -1,141 +0,0 @@ -# Alpha.19 migration benchmarks - -Measured on darwin/arm64, Apple M2 Max, Go 1.26.5. Each result is the median of -three runs. Table entries are **ns/op / B/op / allocs/op**. These are local -measurements, not a performance guarantee. - -The initial baseline was captured before editing. Because wall-time results -shifted during the session, the unchanged HEAD was also extracted with -`git archive HEAD` into a temporary directory and rerun immediately before the -final implementation. Benchmarks ran sequentially without concurrent test/lint -jobs. Both baselines are retained to expose the measurement variation. - -| Benchmark | Initial alpha.11 baseline | Rechecked alpha.11 baseline | Alpha.19 | -| --- | --- | --- | --- | -| `RuntimeAdapterDurableSession` | 81576 / 35470 / 586 | 121280 / 35338 / 582 | 110979 / 35504 / 586 | -| `ExecutionEventPublication/no_watchers` | 26.2 / 0 / 0 | 29.44 / 0 / 0 | 28.24 / 0 / 0 | -| `ExecutionEventPublication/one_watcher` | 68.2 / 0 / 0 | 72.89 / 0 / 0 | 71.95 / 0 / 0 | -| `CancelExecution` | 66.83 / 48 / 1 | 75.16 / 48 / 1 | 74.4 / 48 / 1 | -| `RunDurableSession` | 3976 / 1600 / 32 | 4534 / 1599 / 32 | 5022 / 1647 / 33 | -| `ReplaceBreakpoints` | — | — | 18603 / 32840 / 28 | - -Publication and cancellation retain their allocation counts. The core durable -session run adds one 48-byte allocation for the hosted pointer output; its -median time was about 11% above the rechecked baseline. The public gRPC durable -session benchmark showed no comparable slowdown against the rechecked baseline, -but wall-time variation precludes a speedup claim. Its allocations rose by four -against the recheck and matched the original baseline's 586 allocations. - -`ReplaceBreakpoints` measures 32 stable requests replaced atomically through the -core owner and a hosted API spy. It includes hosted enumeration, ID matching, -quota accounting, and detached result copies. It has no pre-migration equivalent. - -Commands: - -```sh -go test ./server ./server/internal/core -run '^$' -bench 'Benchmark(RuntimeAdapterDurableSession|RunDurableSession|ExecutionEventPublication|CancelExecution)$' -benchmem -count=3 -go test ./server ./server/internal/core -run '^$' -bench 'Benchmark(RuntimeAdapterDurableSession|RunDurableSession|ExecutionEventPublication|CancelExecution|ReplaceBreakpoints)$' -benchmem -count=3 -``` - -All runs used the same task-scoped writable GOCACHE. Raw successful measurements -follow; the second baseline is the unchanged alpha.11 source, not a dependency-only -downgrade of the implementation. - -## Initial baseline - -```text -goos: darwin -goarch: arm64 -pkg: github.com/MontFerret/wire/server -cpu: Apple M2 Max -BenchmarkRuntimeAdapterDurableSession-12 14534 82457 ns/op 35547 B/op 586 allocs/op -BenchmarkRuntimeAdapterDurableSession-12 14810 80968 ns/op 35470 B/op 586 allocs/op -BenchmarkRuntimeAdapterDurableSession-12 14703 81576 ns/op 35402 B/op 586 allocs/op -PASS -ok github.com/MontFerret/wire/server 3.936s -goos: darwin -goarch: arm64 -pkg: github.com/MontFerret/wire/server/internal/core -cpu: Apple M2 Max -BenchmarkExecutionEventPublication/no_watchers-12 46251240 26.20 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/no_watchers-12 45794534 26.21 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/no_watchers-12 46869201 25.57 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 17996523 67.17 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 17610703 68.27 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 17194922 68.20 ns/op 0 B/op 0 allocs/op -BenchmarkCancelExecution-12 17717167 66.45 ns/op 48 B/op 1 allocs/op -BenchmarkCancelExecution-12 18310522 66.83 ns/op 48 B/op 1 allocs/op -BenchmarkCancelExecution-12 17669979 67.08 ns/op 48 B/op 1 allocs/op -BenchmarkRunDurableSession-12 304342 3914 ns/op 1600 B/op 33 allocs/op -BenchmarkRunDurableSession-12 301864 3976 ns/op 1600 B/op 32 allocs/op -BenchmarkRunDurableSession-12 291579 4011 ns/op 1599 B/op 32 allocs/op -PASS -ok github.com/MontFerret/wire/server/internal/core 14.831s -``` - -## Unchanged baseline recheck - -```text -goos: darwin -goarch: arm64 -pkg: github.com/MontFerret/wire/server -cpu: Apple M2 Max -BenchmarkRuntimeAdapterDurableSession-12 9447 111961 ns/op 35461 B/op 583 allocs/op -BenchmarkRuntimeAdapterDurableSession-12 9964 123626 ns/op 35338 B/op 582 allocs/op -BenchmarkRuntimeAdapterDurableSession-12 9920 121280 ns/op 35329 B/op 582 allocs/op -PASS -ok github.com/MontFerret/wire/server 3.962s -goos: darwin -goarch: arm64 -pkg: github.com/MontFerret/wire/server/internal/core -cpu: Apple M2 Max -BenchmarkExecutionEventPublication/no_watchers-12 45160034 27.77 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/no_watchers-12 43835816 29.44 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/no_watchers-12 38323455 29.73 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 16820739 72.89 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 16872297 72.70 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 14167692 76.50 ns/op 0 B/op 0 allocs/op -BenchmarkCancelExecution-12 14922874 75.89 ns/op 48 B/op 1 allocs/op -BenchmarkCancelExecution-12 15572692 73.98 ns/op 48 B/op 1 allocs/op -BenchmarkCancelExecution-12 16766616 75.16 ns/op 48 B/op 1 allocs/op -BenchmarkRunDurableSession-12 279268 4534 ns/op 1600 B/op 32 allocs/op -BenchmarkRunDurableSession-12 290020 4504 ns/op 1599 B/op 32 allocs/op -BenchmarkRunDurableSession-12 258192 4642 ns/op 1599 B/op 32 allocs/op -PASS -ok github.com/MontFerret/wire/server/internal/core 15.276s -``` - -## Final implementation - -```text -goos: darwin -goarch: arm64 -pkg: github.com/MontFerret/wire/server -cpu: Apple M2 Max -BenchmarkRuntimeAdapterDurableSession-12 10657 110979 ns/op 35608 B/op 586 allocs/op -BenchmarkRuntimeAdapterDurableSession-12 9385 111587 ns/op 35504 B/op 586 allocs/op -BenchmarkRuntimeAdapterDurableSession-12 10000 104229 ns/op 35464 B/op 586 allocs/op -PASS -ok github.com/MontFerret/wire/server 3.747s -goos: darwin -goarch: arm64 -pkg: github.com/MontFerret/wire/server/internal/core -cpu: Apple M2 Max -BenchmarkReplaceBreakpoints-12 68432 18123 ns/op 32840 B/op 28 allocs/op -BenchmarkReplaceBreakpoints-12 62622 18603 ns/op 32840 B/op 28 allocs/op -BenchmarkReplaceBreakpoints-12 67670 18871 ns/op 32840 B/op 28 allocs/op -BenchmarkExecutionEventPublication/no_watchers-12 41942336 28.24 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/no_watchers-12 44673673 27.16 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/no_watchers-12 44341117 28.38 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 16569283 72.42 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 15037671 71.79 ns/op 0 B/op 0 allocs/op -BenchmarkExecutionEventPublication/one_watcher-12 16608086 71.95 ns/op 0 B/op 0 allocs/op -BenchmarkCancelExecution-12 15350977 76.89 ns/op 48 B/op 1 allocs/op -BenchmarkCancelExecution-12 16943258 71.88 ns/op 48 B/op 1 allocs/op -BenchmarkCancelExecution-12 16672628 74.40 ns/op 48 B/op 1 allocs/op -BenchmarkRunDurableSession-12 246132 4920 ns/op 1648 B/op 33 allocs/op -BenchmarkRunDurableSession-12 255172 5022 ns/op 1647 B/op 33 allocs/op -BenchmarkRunDurableSession-12 232797 5135 ns/op 1647 B/op 33 allocs/op -PASS -ok github.com/MontFerret/wire/server/internal/core 18.850s -``` diff --git a/gen/ferret/wire/v1/runtime.pb.go b/gen/ferret/wire/v1/runtime.pb.go index db1287d..7491d04 100644 --- a/gen/ferret/wire/v1/runtime.pb.go +++ b/gen/ferret/wire/v1/runtime.pb.go @@ -268,8 +268,12 @@ type ConnectResponse struct { ConnectionId *ConnectionId `protobuf:"bytes,3,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` Protocol *ProtocolInfo `protobuf:"bytes,4,opt,name=protocol,proto3" json:"protocol,omitempty"` RuntimeIdentity *RuntimeIdentity `protobuf:"bytes,5,opt,name=runtime_identity,json=runtimeIdentity,proto3" json:"runtime_identity,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // runtime_version projects api.Runtime.Version verbatim, independently of host identity. + // Bytes preserve opaque values without requiring valid UTF-8. + // Presence is required by alpha.20 peers; an empty but present value is valid. + RuntimeVersion []byte `protobuf:"bytes,6,opt,name=runtime_version,json=runtimeVersion,proto3,oneof" json:"runtime_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ConnectResponse) Reset() { @@ -323,6 +327,13 @@ func (x *ConnectResponse) GetRuntimeIdentity() *RuntimeIdentity { return nil } +func (x *ConnectResponse) GetRuntimeVersion() []byte { + if x != nil { + return x.RuntimeVersion + } + return nil +} + // ConnectRequest has no transport- or client-identity fields. type ConnectRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -814,11 +825,13 @@ const file_ferret_wire_v1_runtime_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aversion\x18\x02 \x01(\tR\aversion\x12\x1f\n" + "\vinstance_id\x18\x03 \x01(\tR\n" + - "instanceId\"\xf7\x01\n" + + "instanceId\"\xb9\x02\n" + "\x0fConnectResponse\x12A\n" + "\rconnection_id\x18\x03 \x01(\v2\x1c.ferret.wire.v1.ConnectionIdR\fconnectionId\x128\n" + "\bprotocol\x18\x04 \x01(\v2\x1c.ferret.wire.v1.ProtocolInfoR\bprotocol\x12J\n" + - "\x10runtime_identity\x18\x05 \x01(\v2\x1f.ferret.wire.v1.RuntimeIdentityR\x0fruntimeIdentityJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x06openedR\aclosing\"'\n" + + "\x10runtime_identity\x18\x05 \x01(\v2\x1f.ferret.wire.v1.RuntimeIdentityR\x0fruntimeIdentity\x12,\n" + + "\x0fruntime_version\x18\x06 \x01(\fH\x00R\x0eruntimeVersion\x88\x01\x01B\x12\n" + + "\x10_runtime_versionJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x06openedR\aclosing\"'\n" + "\x0eConnectRequestJ\x04\b\x01\x10\x02R\x0fclient_identity\"[\n" + "\x16CloseConnectionRequest\x12A\n" + "\rconnection_id\x18\x01 \x01(\v2\x1c.ferret.wire.v1.ConnectionIdR\fconnectionId\"\x19\n" + @@ -918,6 +931,7 @@ func file_ferret_wire_v1_runtime_proto_init() { return } file_ferret_wire_v1_source_proto_init() + file_ferret_wire_v1_runtime_proto_msgTypes[3].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/go.mod b/go.mod index 19020b8..34bfa42 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,15 @@ module github.com/MontFerret/wire go 1.25.0 require ( - github.com/MontFerret/api v1.0.0-alpha.19 + github.com/MontFerret/api v1.0.0-alpha.20 github.com/google/uuid v1.6.0 - google.golang.org/grpc v1.83.0 + google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 ) require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect ) diff --git a/go.sum b/go.sum index 5e02238..ed3d0ec 100644 --- a/go.sum +++ b/go.sum @@ -1,40 +1,22 @@ -github.com/MontFerret/api v1.0.0-alpha.19 h1:zRAb2rAMFxhKggmVTEEQfWxfaSz6+p5D6b+C5P/RXZw= -github.com/MontFerret/api v1.0.0-alpha.19/go.mod h1:wz5bfUIGYkED8Hqz9CdsW0o2jSgagotbVLOCbHHacSI= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/MontFerret/api v1.0.0-alpha.20 h1:s09YgY0ndHudo7WY/XWIJACKdUCBt0bTA3XP8bhhmBI= +github.com/MontFerret/api v1.0.0-alpha.20/go.mod h1:wz5bfUIGYkED8Hqz9CdsW0o2jSgagotbVLOCbHHacSI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/proto/ferret/wire/v1/runtime.proto b/proto/ferret/wire/v1/runtime.proto index c164116..8e8254f 100644 --- a/proto/ferret/wire/v1/runtime.proto +++ b/proto/ferret/wire/v1/runtime.proto @@ -38,6 +38,10 @@ message ConnectResponse { ConnectionId connection_id = 3; ProtocolInfo protocol = 4; RuntimeIdentity runtime_identity = 5; + // runtime_version projects api.Runtime.Version verbatim, independently of host identity. + // Bytes preserve opaque values without requiring valid UTF-8. + // Presence is required by alpha.20 peers; an empty but present value is valid. + optional bytes runtime_version = 6; } // ConnectRequest has no transport- or client-identity fields. diff --git a/server/integration_test.go b/server/integration_test.go index 4ca2b53..54c918c 100644 --- a/server/integration_test.go +++ b/server/integration_test.go @@ -82,7 +82,7 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { t.Fatal(err) } - params, err := compiled.Params() + params, err := compiled.Params(t.Context()) if err != nil { t.Fatal(err) } diff --git a/server/internal/core/api.go b/server/internal/core/api.go index 730349f..d51b523 100644 --- a/server/internal/core/api.go +++ b/server/internal/core/api.go @@ -2,6 +2,7 @@ package core import ( + "context" "errors" "fmt" "reflect" @@ -11,12 +12,12 @@ import ( "github.com/MontFerret/wire/server/internal/panicboundary" ) -func apiPlanParameters(plan api.Plan) ([]string, error) { +func apiPlanParameters(ctx context.Context, plan api.Plan) ([]string, error) { parameters, err := panicboundary.Call(func() ([]string, error) { - return plan.Params() + return plan.Params(ctx) }) if err != nil { - return nil, runtimePanicError("read runtime plan parameters", err) + return nil, errors.Join(ctx.Err(), runtimePanicError("read runtime plan parameters", err)) } return append([]string(nil), parameters...), nil diff --git a/server/internal/core/compile.go b/server/internal/core/compile.go index cdcc25e..de28142 100644 --- a/server/internal/core/compile.go +++ b/server/internal/core/compile.go @@ -56,7 +56,7 @@ func CompilePlan(ctx context.Context, runtime api.Runtime, store *ResourceStore, return nil, errors.Join(err, closeAPIPlan(compiled)) } - parameters, err := apiPlanParameters(compiled) + parameters, err := apiPlanParameters(ctx, compiled) if err != nil { return nil, errors.Join(err, closeAPIPlan(compiled)) } diff --git a/server/internal/core/compile_metadata_test.go b/server/internal/core/compile_metadata_test.go new file mode 100644 index 0000000..4a0f3fd --- /dev/null +++ b/server/internal/core/compile_metadata_test.go @@ -0,0 +1,85 @@ +package core + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/MontFerret/api" +) + +func TestCompileMetadataCancellationClosesUnpublishedPlan(t *testing.T) { + for _, debug := range []bool{false, true} { + for _, mode := range []string{"canceled", "deadline", "success after cancellation", "private error after cancellation"} { + name := map[bool]string{false: "normal/", true: "debug/"}[debug] + mode + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithCancel(testContext(t)) + defer cancel() + + if mode == "deadline" { + ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + } + + cleanupErr := errors.New("metadata cleanup") + hosted := &spyPlan{close: func() error { return cleanupErr }, paramsCall: func(received context.Context) ([]string, error) { + if received != ctx { + t.Fatal("Params did not receive the allocation context") + } + + if mode == "deadline" { + <-received.Done() + } else { + cancel() + } + + if mode == "success after cancellation" { + return []string{"partial"}, nil + } + + if mode == "private error after cancellation" { + return nil, errors.New("private canceled metadata") + } + + return []string{"partial"}, received.Err() + }} + limits := testLimits().resources() + limits.Plans = 1 + store := newResourceStore(testContext(t), limits) + runtime := &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return hosted, nil }} + plan, err := CompilePlan(ctx, runtime, store, api.Source{}, debug) + want := context.Canceled + + if mode == "deadline" { + want = context.DeadlineExceeded + } + + if plan != nil || !errors.Is(err, want) || !errors.Is(err, cleanupErr) { + t.Fatalf("metadata cancellation=%v, %v", plan, err) + } + + if _, _, count := hosted.snapshot(); count != 1 { + t.Fatalf("unpublished plan closed %d times", count) + } + + store.mu.Lock() + retained, pending := len(store.plans), store.pending[planResource] + store.mu.Unlock() + + if retained != 0 || pending != 0 { + t.Fatalf("failed metadata retained plans=%d pending=%d", retained, pending) + } + + replacement, err := CompilePlan(testContext(t), &spyRuntime{}, store, api.Source{}, debug) + if err != nil { + t.Fatalf("failed metadata retained quota: %v", err) + } + + if err := replacement.Release(testContext(t)); err != nil { + t.Fatal(err) + } + }) + } + } +} diff --git a/server/internal/core/component_boundary_test.go b/server/internal/core/component_boundary_test.go index 3906d37..1a8080e 100644 --- a/server/internal/core/component_boundary_test.go +++ b/server/internal/core/component_boundary_test.go @@ -284,7 +284,7 @@ func TestCompilePanicsAreSanitizedAndCloseReturnedPlansOnce(t *testing.T) { }) t.Run("metadata", func(t *testing.T) { - plan := &spyPlan{paramsCall: func() []string { panic("metadata secret") }} + plan := &spyPlan{paramsCall: func(context.Context) ([]string, error) { panic("metadata secret") }} connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) diff --git a/server/internal/core/plan_spy_test.go b/server/internal/core/plan_spy_test.go index 3e2bbb7..cc5babe 100644 --- a/server/internal/core/plan_spy_test.go +++ b/server/internal/core/plan_spy_test.go @@ -11,7 +11,7 @@ import ( type spyPlan struct { mu sync.Mutex params []string - paramsCall func() []string + paramsCall func(context.Context) ([]string, error) paramsError error newSession func(context.Context, sessionOptions) (api.Session, error) newDebugSession func(context.Context, sessionOptions) (debugger.Session, error) @@ -21,15 +21,20 @@ type spyPlan struct { closeCalls int } -func (p *spyPlan) Params() ([]string, error) { +func (p *spyPlan) Params(ctx context.Context) ([]string, error) { p.mu.Lock() - defer p.mu.Unlock() + call, params, err := p.paramsCall, p.params, p.paramsError + p.mu.Unlock() + + if call != nil { + return call(ctx) + } - if p.paramsCall != nil { - return p.paramsCall(), nil + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr } - return p.params, p.paramsError + return params, err } func (p *spyPlan) NewSession(ctx context.Context, options ...api.SessionOption) (api.Session, error) { diff --git a/server/internal/core/runtime_spy_test.go b/server/internal/core/runtime_spy_test.go index 3170db8..ec79d27 100644 --- a/server/internal/core/runtime_spy_test.go +++ b/server/internal/core/runtime_spy_test.go @@ -19,6 +19,10 @@ type spyRuntime struct { closeCalls int } +func (r *spyRuntime) Version(ctx context.Context) (api.Version, error) { + return api.Version("runtime-test"), ctx.Err() +} + func (r *spyRuntime) Run(ctx context.Context, src api.Source, options ...api.SessionOption) (*api.Output, error) { configured, err := applySessionOptions(options) if err != nil { diff --git a/server/internal/grpcserver/handshake.go b/server/internal/grpcserver/handshake.go index 27a7ac2..552f180 100644 --- a/server/internal/grpcserver/handshake.go +++ b/server/internal/grpcserver/handshake.go @@ -2,7 +2,8 @@ package grpcserver import wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" -// Handshake is immutable transport metadata supplied by the server composition root. +// Handshake is immutable protocol and host-identity metadata supplied by the +// server composition root. RuntimeVersion belongs to host identity, not UAPI Version. type Handshake struct { ProtocolName string ProtocolVersion string diff --git a/server/internal/grpcserver/plan_service_test.go b/server/internal/grpcserver/plan_service_test.go index afac98e..afb89ea 100644 --- a/server/internal/grpcserver/plan_service_test.go +++ b/server/internal/grpcserver/plan_service_test.go @@ -1,8 +1,13 @@ package grpcserver import ( + "context" "errors" "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" @@ -50,3 +55,113 @@ func TestOptimizationLevelRejectsUnknownValue(t *testing.T) { func compileOptions(level wirev1.OptimizationLevel) *wirev1.CompileOptions { return &wirev1.CompileOptions{OptimizationLevel: level} } + +type ( + metadataRuntime struct { + api.Runtime + plan api.Plan + } + + metadataPlan struct { + api.Plan + params func(context.Context) ([]string, error) + closed int + } +) + +func (r *metadataRuntime) Compile(context.Context, api.Source, ...api.PlanOption) (api.Plan, error) { + return r.plan, nil +} + +func (r *metadataRuntime) CompileDebug(context.Context, api.Source, ...api.PlanOption) (api.Plan, error) { + return r.plan, nil +} + +func (p *metadataPlan) Params(ctx context.Context) ([]string, error) { + return p.params(ctx) +} + +func (p *metadataPlan) Close() error { + p.closed++ + + return nil +} + +func TestCompileMetadataCancellationReclaimsHostedPlan(t *testing.T) { + for _, debug := range []bool{false, true} { + for _, deadline := range []bool{false, true} { + name := map[bool]string{false: "normal/", true: "debug/"}[debug] + map[bool]string{false: "canceled", true: "deadline"}[deadline] + t.Run(name, func(t *testing.T) { + lifetime, finish := context.WithTimeout(t.Context(), 10*time.Second) + defer finish() + ctx, cancel := context.WithCancel(lifetime) + defer cancel() + + if deadline { + ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + } + + connections := core.NewConnectionRegistry(1, core.ResourceLimits{Plans: 1}) + + connection, err := connections.Open() + if err != nil { + t.Fatal(err) + } + + hosted := &metadataPlan{params: func(ctx context.Context) ([]string, error) { + if deadline { + <-ctx.Done() + } else { + cancel() + } + + return []string{"partial"}, ctx.Err() + }} + runtime := &metadataRuntime{plan: hosted} + service := &PlanService{runtime: runtime, connections: connections} + id := &wirev1.ConnectionId{Value: string(connection.ID())} + + if debug { + response, compileErr := service.CompileDebug(ctx, &wirev1.CompileDebugRequest{ConnectionId: id, Source: &wirev1.Source{}}) + if response != nil { + t.Fatal("metadata cancellation published a debug plan") + } + + err = compileErr + } else { + response, compileErr := service.Compile(ctx, &wirev1.CompileRequest{ConnectionId: id, Source: &wirev1.Source{}}) + if response != nil { + t.Fatal("metadata cancellation published a normal plan") + } + + err = compileErr + } + + want := codes.Canceled + + if deadline { + want = codes.DeadlineExceeded + } + + if status.Code(err) != want || hosted.closed != 1 { + t.Fatalf("metadata cancellation error=%v, close count=%d", err, hosted.closed) + } + + runtime.plan = &metadataPlan{params: func(context.Context) ([]string, error) { return nil, nil }} + + if _, err := service.Compile(lifetime, &wirev1.CompileRequest{ConnectionId: id, Source: &wirev1.Source{}}); err != nil { + t.Fatalf("metadata failure retained plan quota: %v", err) + } + + if err := connections.CloseConnection(lifetime, connection.ID()); err != nil { + t.Fatal(err) + } + + if hosted.closed != 1 { + t.Fatal("failed metadata plan was registered and closed again") + } + }) + } + } +} diff --git a/server/internal/grpcserver/runtime_service.go b/server/internal/grpcserver/runtime_service.go index 196654d..057b5a3 100644 --- a/server/internal/grpcserver/runtime_service.go +++ b/server/internal/grpcserver/runtime_service.go @@ -2,11 +2,13 @@ package grpcserver import ( "context" + "errors" "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" wireexecution "github.com/MontFerret/wire/pkg/execution" "github.com/MontFerret/wire/server/internal/core" + "github.com/MontFerret/wire/server/internal/panicboundary" ) // RuntimeService adapts the runtime RPC contract to its core owners. @@ -30,10 +32,29 @@ func (s *RuntimeService) Connect(_ *wirev1.ConnectRequest, stream wirev1.Runtime _ = s.connections.CloseConnection(context.Background(), connection.ID()) }() + ctx, cancel := core.OperationContext(stream.Context(), connection.Context()) + defer cancel() + + if err := ctx.Err(); err != nil { + return rpcError(err) + } + + version, err := panicboundary.Call(func() (api.Version, error) { + return s.runtime.Version(ctx) + }) + if err != nil { + return rpcError(errors.Join(ctx.Err(), err)) + } + + if err := ctx.Err(); err != nil { + return rpcError(err) + } + response := &wirev1.ConnectResponse{ ConnectionId: &wirev1.ConnectionId{Value: string(connection.ID())}, Protocol: protocolInfo(s.info), RuntimeIdentity: runtimeIdentity(s.info), + RuntimeVersion: []byte(version), } if err := stream.Send(response); err != nil { diff --git a/server/internal/grpcserver/runtime_service_test.go b/server/internal/grpcserver/runtime_service_test.go new file mode 100644 index 0000000..0b42811 --- /dev/null +++ b/server/internal/grpcserver/runtime_service_test.go @@ -0,0 +1,210 @@ +package grpcserver + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/MontFerret/api" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "github.com/MontFerret/wire/server/internal/core" +) + +type ( + versionRuntime struct { + api.Runtime + version func(context.Context) (api.Version, error) + } + + connectStream struct { + grpc.ServerStream + ctx context.Context + response *wirev1.ConnectResponse + send func() error + } +) + +func (r *versionRuntime) Version(ctx context.Context) (api.Version, error) { + return r.version(ctx) +} + +func (s *connectStream) Context() context.Context { + return s.ctx +} + +func (s *connectStream) Send(response *wirev1.ConnectResponse) error { + s.response = response + + return s.send() +} + +func TestConnectProjectsOpaqueRuntimeVersionIndependentlyOfHostIdentity(t *testing.T) { + for _, version := range []api.Version{"v2.0.0-alpha.test", " runtime-2.0+opaque \n", "版本-α", "\xff", "runtime\x00\xff\xc3", ""} { + t.Run(string(version), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + calls := 0 + connections := core.NewConnectionRegistry(1, core.ResourceLimits{}) + service := &RuntimeService{ + connections: connections, + info: Handshake{ProtocolName: "ferret.wire", ProtocolVersion: "v1", RuntimeName: "host", RuntimeVersion: "host-7.1"}, + runtime: &versionRuntime{version: func(received context.Context) (api.Version, error) { + calls++ + deadline, ok := received.Deadline() + + want, _ := ctx.Deadline() + if !ok || !deadline.Equal(want) { + t.Fatal("Version lost the stream deadline") + } + + return version, nil + }}, + } + + stream := &connectStream{ctx: ctx, send: func() error { cancel(); return nil }} + if err := service.Connect(&wirev1.ConnectRequest{}, stream); err != nil { + t.Fatal(err) + } + + if calls != 1 || stream.response.RuntimeVersion == nil || string(stream.response.GetRuntimeVersion()) != string(version) || stream.response.GetRuntimeIdentity().GetVersion() != "host-7.1" { + t.Fatalf("Connect calls=%d response=%v", calls, stream.response) + } + + if _, err := connections.Get(core.ConnectionID(stream.response.GetConnectionId().GetValue())); err == nil { + t.Fatal("ended handshake retained its connection") + } + }) + } +} + +func TestConnectMetadataFailureReclaimsConnectionCapacity(t *testing.T) { + for _, mode := range []string{"error", "panic", "canceled", "deadline", "send failure", "canceled successful retrieval", "canceled private error"} { + t.Run(mode, func(t *testing.T) { + lifetime, finish := context.WithTimeout(t.Context(), 10*time.Second) + defer finish() + ctx, cancel := context.WithCancel(lifetime) + defer cancel() + connections := core.NewConnectionRegistry(1, core.ResourceLimits{}) + service := &RuntimeService{connections: connections, runtime: &versionRuntime{version: func(context.Context) (api.Version, error) { + switch mode { + case "panic": + panic("private version panic") + case "canceled": + return "", context.Canceled + case "deadline": + return "", context.DeadlineExceeded + case "send failure": + return "", nil + case "canceled private error": + cancel() + + return "", errors.New("private canceled version") + case "canceled successful retrieval": + cancel() + + return "", nil + default: + return "", errors.New("private version error") + } + }}} + stream := &connectStream{ctx: ctx, send: func() error { return status.Error(codes.Unavailable, "send failure") }} + err := service.Connect(&wirev1.ConnectRequest{}, stream) + want := codes.Internal + switch mode { + case "canceled", "canceled successful retrieval", "canceled private error": + want = codes.Canceled + case "deadline": + want = codes.DeadlineExceeded + case "send failure": + want = codes.Unavailable + } + + if status.Code(err) != want || strings.Contains(err.Error(), "private") || (mode != "send failure" && stream.response != nil) { + t.Fatalf("Connect response=%v error=%v", stream.response, err) + } + + connection, err := connections.Open() + if err != nil { + t.Fatalf("failed handshake retained capacity: %v", err) + } + + if err := connections.CloseConnection(lifetime, connection.ID()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestConnectCancellationReachesHostedVersion(t *testing.T) { + for _, mode := range []string{"caller", "deadline", "shutdown"} { + t.Run(mode, func(t *testing.T) { + lifetime, finish := context.WithTimeout(t.Context(), 10*time.Second) + defer finish() + ctx, cancel := context.WithCancel(lifetime) + defer cancel() + + if mode == "deadline" { + ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + } + + entered := make(chan struct{}) + connections := core.NewConnectionRegistry(1, core.ResourceLimits{}) + service := &RuntimeService{connections: connections, runtime: &versionRuntime{version: func(ctx context.Context) (api.Version, error) { + close(entered) + <-ctx.Done() + + return "", ctx.Err() + }}} + stream := &connectStream{ctx: ctx, send: func() error { return nil }} + finished := make(chan error, 1) + go func() { finished <- service.Connect(&wirev1.ConnectRequest{}, stream) }() + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("Version was not entered") + } + + switch mode { + case "caller": + cancel() + case "shutdown": + if err := connections.Close(lifetime); err != nil { + t.Fatal(err) + } + } + + select { + case err := <-finished: + want := codes.Canceled + + if mode == "deadline" { + want = codes.DeadlineExceeded + } + + if status.Code(err) != want || stream.response != nil { + t.Fatalf("Connect published after cancellation: %v, %v", stream.response, err) + } + case <-time.After(10 * time.Second): + t.Fatal("Connect did not settle") + } + + if mode != "shutdown" { + connection, err := connections.Open() + if err != nil { + t.Fatalf("cancellation retained capacity: %v", err) + } + + if err := connections.CloseConnection(lifetime, connection.ID()); err != nil { + t.Fatal(err) + } + } + }) + } +} diff --git a/server/metadata_benchmark_test.go b/server/metadata_benchmark_test.go new file mode 100644 index 0000000..b15ee78 --- /dev/null +++ b/server/metadata_benchmark_test.go @@ -0,0 +1,75 @@ +package server_test + +import ( + "context" + "testing" + + "github.com/MontFerret/api" + "github.com/MontFerret/wire/client" +) + +func BenchmarkRuntimeConnectClose(b *testing.B) { + env := newIntegrationEnv(b, &contractRuntime{}) + ctx := b.Context() + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + remote, err := client.New(ctx, env.conn) + if err != nil { + b.Fatal(err) + } + + if err := remote.Close(); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkPlanCompileClose(b *testing.B) { + for _, debug := range []bool{false, true} { + name := "normal" + + if debug { + name = "debug" + } + + b.Run(name, func(b *testing.B) { + runtime := &contractRuntime{compile: func(context.Context, api.Source, bool, contractPlanOptions) (api.Plan, error) { + return &contractPlan{params: []string{"input", "other"}}, nil + }} + env := newIntegrationEnv(b, runtime) + ctx := b.Context() + + remote, err := client.New(ctx, env.conn) + if err != nil { + b.Fatal(err) + } + + b.Cleanup(func() { + if err := remote.Close(); err != nil { + b.Error(err) + } + }) + compile := remote.Compile + + if debug { + compile = remote.CompileDebug + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + plan, err := compile(ctx, api.NewAnonymousSource("RETURN [@input, @other]")) + if err != nil { + b.Fatal(err) + } + + if err := plan.Close(); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/server/plan_spy_test.go b/server/plan_spy_test.go index 87d7e7c..0aabd9e 100644 --- a/server/plan_spy_test.go +++ b/server/plan_spy_test.go @@ -17,7 +17,11 @@ type apiPlanSpy struct { closeCalls int } -func (p *apiPlanSpy) Params() ([]string, error) { +func (p *apiPlanSpy) Params(ctx context.Context) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + p.mu.Lock() defer p.mu.Unlock() diff --git a/server/protocol_contract_test.go b/server/protocol_contract_test.go index 16d5a77..58304f4 100644 --- a/server/protocol_contract_test.go +++ b/server/protocol_contract_test.go @@ -1,11 +1,13 @@ package server_test import ( + "bytes" "os" "path/filepath" "strings" "testing" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" @@ -20,7 +22,7 @@ func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { }{ { message: wirev1.File_ferret_wire_v1_runtime_proto.Messages().ByName("ConnectResponse"), - fields: []protoreflect.Name{"connection_id", "protocol", "runtime_identity"}, + fields: []protoreflect.Name{"connection_id", "protocol", "runtime_identity", "runtime_version"}, numbers: []protoreflect.FieldNumber{1, 2}, reserved: []protoreflect.Name{"opened", "closing"}, }, @@ -342,3 +344,44 @@ func TestProtocolSourcesContainNoNativeMetadataOrFakeCapabilities(t *testing.T) } } } + +func TestRuntimeVersionFieldPreservesPresence(t *testing.T) { + field := wirev1.File_ferret_wire_v1_runtime_proto.Messages().ByName("ConnectResponse").Fields().ByName("runtime_version") + if field == nil || field.Number() != 6 || field.Kind() != protoreflect.BytesKind || !field.HasOptionalKeyword() || !field.HasPresence() { + t.Fatalf("runtime_version must remain optional bytes field 6: %v", field) + } +} + +func TestRuntimeVersionSerializationPreservesBytesAndPresence(t *testing.T) { + for _, test := range []struct { + name string + value []byte + }{ + {name: "absent"}, + {name: "present empty", value: []byte{}}, + {name: "Unicode", value: []byte("版本-α")}, + {name: "invalid UTF-8", value: []byte{0xff}}, + {name: "mixed NUL and invalid bytes", value: []byte("runtime\x00\xff\xc3")}, + } { + t.Run(test.name, func(t *testing.T) { + message := &wirev1.ConnectResponse{RuntimeVersion: test.value} + + encoded, err := proto.Marshal(message) + if err != nil { + t.Fatal(err) + } + + var decoded wirev1.ConnectResponse + if err := proto.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + + field := decoded.ProtoReflect().Descriptor().Fields().ByName("runtime_version") + + present := test.value != nil + if decoded.ProtoReflect().Has(field) != present || (decoded.RuntimeVersion != nil) != present || !bytes.Equal(decoded.RuntimeVersion, test.value) { + t.Fatalf("decoded version=%q presence=%v; want %q presence=%v", decoded.RuntimeVersion, decoded.ProtoReflect().Has(field), test.value, present) + } + }) + } +} diff --git a/server/runtime_adapter_plan_fake_test.go b/server/runtime_adapter_plan_fake_test.go index 39a21a2..14aeb78 100644 --- a/server/runtime_adapter_plan_fake_test.go +++ b/server/runtime_adapter_plan_fake_test.go @@ -19,7 +19,11 @@ type contractPlan struct { closeCalls int } -func (p *contractPlan) Params() ([]string, error) { +func (p *contractPlan) Params(ctx context.Context) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + p.mu.Lock() defer p.mu.Unlock() diff --git a/server/runtime_adapter_runtime_fake_test.go b/server/runtime_adapter_runtime_fake_test.go index f1cfea6..b3cab4a 100644 --- a/server/runtime_adapter_runtime_fake_test.go +++ b/server/runtime_adapter_runtime_fake_test.go @@ -19,6 +19,10 @@ type contractRuntime struct { closeCalls int } +func (r *contractRuntime) Version(ctx context.Context) (api.Version, error) { + return api.Version("runtime-test"), ctx.Err() +} + func (r *contractRuntime) Run(ctx context.Context, src api.Source, options ...api.SessionOption) (*api.Output, error) { configured, err := applyAPIOptions(options) if err != nil { diff --git a/server/runtime_spy_test.go b/server/runtime_spy_test.go index feb7a45..2a8d012 100644 --- a/server/runtime_spy_test.go +++ b/server/runtime_spy_test.go @@ -15,6 +15,10 @@ type apiRuntimeSpy struct { closeCalls int } +func (r *apiRuntimeSpy) Version(ctx context.Context) (api.Version, error) { + return api.Version("runtime-test"), ctx.Err() +} + func (r *apiRuntimeSpy) Run(context.Context, api.Source, ...api.SessionOption) (*api.Output, error) { return &api.Output{ContentType: "application/json", Content: []byte("1")}, nil } diff --git a/test/ferret/README.md b/test/ferret/README.md index d1d2021..e5035c5 100644 --- a/test/ferret/README.md +++ b/test/ferret/README.md @@ -3,12 +3,12 @@ This focused ecosystem suite exercises the complete public boundary: ```text -native engine.New → ferret/uapi.Wrap → api.Runtime → wire/server +native engine.New → ferret/uapi.Wrap(native, version) → api.Runtime → wire/server → protobuf and real gRPC over bufconn → wire/client.New → api.Runtime consumer ``` -The nested module pins Ferret `v2.0.0-alpha.56` and Universal API -`v1.0.0-alpha.19`, matching Wire's UAPI dependency. It replaces only Wire with +The nested module pins Ferret `v2.0.0-alpha.57` and Universal API +`v1.0.0-alpha.20`, matching Wire's UAPI dependency. It replaces only Wire with the local checkout. Native Ferret is not a root-module dependency and is never imported by Wire production packages. No workspace file or local Ferret checkout is required. @@ -18,10 +18,16 @@ exhaustive Wire contracts, fault injection, cancellation, quotas, and cleanup counts. These tests instead verify that the real native implementation and Wire's projection compose correctly. Both layers cross real serialization. +The harness supplies an opaque deterministic version to `uapi.Wrap`; the native +Version round trip compares direct and remote adapter results without hardcoding +a Ferret release string or deriving a version from Wire/build metadata. + ## Coverage | Test | Contract | | --- | --- | +| `TestRuntimeVersionRoundTrip` | Exact native and remote version equality, including cached reads after Wire cleanup | +| `TestPlanParametersRoundTrip` | Normal/debug local and remote `Params(ctx)` equality, empty and ordered multi-parameter snapshots, detached storage | | `TestRuntimeRun` | Real FQL, bind parameter, JSON output, and a filesystem-root override distinct from the engine default | | `TestReusablePlanAndDurableSessions` | Detached parameter metadata, independent sessions, repeated session runs, and detached encoded output | | `TestPlanClosePreservesSession` | Existing session executes after plan Close; new constructors fail while detached metadata survives | @@ -59,7 +65,7 @@ The diagnostic test compares complete portable diagnostics from direct hosted compilation with those returned through Wire for both `RETURN )` and bare `RETURN`. Both must retain the compilation-error category and gRPC `InvalidArgument` status. The EOF case explicitly verifies line `1`, column `7`, -and the zero-width insertion span `[6,6)` supplied by Ferret alpha.56. Wire +and the zero-width insertion span `[6,6)` supplied by Ferret. Wire preserves those coordinates without repairing or normalizing hosted ranges. ## Running diff --git a/test/ferret/go.mod b/test/ferret/go.mod index ecc0b18..e1f8448 100644 --- a/test/ferret/go.mod +++ b/test/ferret/go.mod @@ -3,10 +3,10 @@ module github.com/MontFerret/wire/test/ferret go 1.25.0 require ( - github.com/MontFerret/api v1.0.0-alpha.19 - github.com/MontFerret/ferret/v2 v2.0.0-alpha.56 + github.com/MontFerret/api v1.0.0-alpha.20 + github.com/MontFerret/ferret/v2 v2.0.0-alpha.57 github.com/MontFerret/wire v0.0.0 - google.golang.org/grpc v1.83.0 + google.golang.org/grpc v1.84.0 ) require ( @@ -21,9 +21,9 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/ziflex/go-options v1.6.1 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/test/ferret/go.sum b/test/ferret/go.sum index 646c6a6..e203681 100644 --- a/test/ferret/go.sum +++ b/test/ferret/go.sum @@ -1,17 +1,11 @@ -github.com/MontFerret/api v1.0.0-alpha.19 h1:zRAb2rAMFxhKggmVTEEQfWxfaSz6+p5D6b+C5P/RXZw= -github.com/MontFerret/api v1.0.0-alpha.19/go.mod h1:wz5bfUIGYkED8Hqz9CdsW0o2jSgagotbVLOCbHHacSI= -github.com/MontFerret/ferret/v2 v2.0.0-alpha.56 h1:CBBWg2om+Nxw04W8hU0duMtAQUtY5NEmtdMP88h6hAY= -github.com/MontFerret/ferret/v2 v2.0.0-alpha.56/go.mod h1:wjyA9O+zZHXn5+izgqOhuhkdHk9irwL8YsUKPCRRNRk= +github.com/MontFerret/api v1.0.0-alpha.20 h1:s09YgY0ndHudo7WY/XWIJACKdUCBt0bTA3XP8bhhmBI= +github.com/MontFerret/api v1.0.0-alpha.20/go.mod h1:wz5bfUIGYkED8Hqz9CdsW0o2jSgagotbVLOCbHHacSI= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.57 h1:E2KcHVQBLnY0A0QkIyewN5DGj9TJr2ZtGQr+I2+1wWg= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.57/go.mod h1:/qxBUibHjztUbhG6XH7ErPffepGBdm0nW3JWrqBVVWA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg= github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= @@ -48,33 +42,21 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/ziflex/go-options v1.6.1 h1:CCIn24/7lTj5Ju5cM9zxqVwgGIiZqzjmkGYjSQG8U2U= github.com/ziflex/go-options v1.6.1/go.mod h1:r2mjyrYxJ9waL5zVmAoX8aBOq7pZ3LoGku4rn8/8gxk= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g= google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= diff --git a/test/ferret/harness_test.go b/test/ferret/harness_test.go index e49c0d4..0c02c72 100644 --- a/test/ferret/harness_test.go +++ b/test/ferret/harness_test.go @@ -56,7 +56,7 @@ func newHarness(t *testing.T, options ...server.Option) *harness { t.Errorf("close native engine: %v", err) } }) - h.hosted = ferretuapi.Wrap(h.native) + h.hosted = ferretuapi.Wrap(h.native, api.Version("native-compatibility-test")) t.Cleanup(func() { if err := h.hosted.Close(); err != nil { t.Errorf("close hosted adapter: %v", err) diff --git a/test/ferret/metadata_test.go b/test/ferret/metadata_test.go new file mode 100644 index 0000000..23c01df --- /dev/null +++ b/test/ferret/metadata_test.go @@ -0,0 +1,81 @@ +package ferret_test + +import ( + "slices" + "testing" + + "github.com/MontFerret/api" +) + +func TestRuntimeVersionRoundTrip(t *testing.T) { + h := newHarness(t) + + local, err := h.hosted.Version(h.ctx) + if err != nil { + t.Fatal(err) + } + + remote, err := h.runtime.Version(h.ctx) + if err != nil || remote != local { + t.Fatalf("remote Version=%q, %v; native=%q", remote, err, local) + } + + if err := h.closeWire(); err != nil { + t.Fatal(err) + } + + remote, err = h.runtime.Version(h.ctx) + if err != nil || remote != local { + t.Fatalf("cached Version after Wire cleanup=%q, %v; native=%q", remote, err, local) + } +} + +func TestPlanParametersRoundTrip(t *testing.T) { + for _, debug := range []bool{false, true} { + for _, content := range []string{"RETURN 1", "RETURN [@second, @first, @second]"} { + t.Run(map[bool]string{false: "normal/", true: "debug/"}[debug]+content, func(t *testing.T) { + h := newHarness(t) + localCompile, remoteCompile := h.hosted.Compile, h.runtime.Compile + + if debug { + localCompile, remoteCompile = h.hosted.CompileDebug, h.runtime.CompileDebug + } + + src := api.NewSource("metadata.fql", content) + + local, err := localCompile(h.ctx, src, api.WithOptimizationLevel(api.OptimizationNone)) + if err != nil { + t.Fatal(err) + } + + h.own(local) + + remote, err := remoteCompile(h.ctx, src, api.WithOptimizationLevel(api.OptimizationNone)) + if err != nil { + t.Fatal(err) + } + + h.own(remote) + + want, err := local.Params(h.ctx) + if err != nil { + t.Fatal(err) + } + + got, err := remote.Params(h.ctx) + if err != nil || !slices.Equal(got, want) { + t.Fatalf("remote Params=%v, %v; native=%v", got, err, want) + } + + if len(got) != 0 { + got[0] = "caller mutation" + } + + again, err := remote.Params(h.ctx) + if err != nil || !slices.Equal(again, want) { + t.Fatalf("remote Params after mutation=%v, %v; native=%v", again, err, want) + } + }) + } + } +} diff --git a/test/ferret/runtime_test.go b/test/ferret/runtime_test.go index 164ba56..f16eb2b 100644 --- a/test/ferret/runtime_test.go +++ b/test/ferret/runtime_test.go @@ -166,14 +166,14 @@ func requireJSONOutput(t *testing.T, output *api.Output, want string) { func requireDetachedParams(t *testing.T, plan api.Plan) { t.Helper() - params, err := plan.Params() + params, err := plan.Params(t.Context()) if err != nil || !reflect.DeepEqual(params, []string{"value"}) { t.Fatalf("Params = %v, %v; want [value]", params, err) } params[0] = "changed" - again, err := plan.Params() + again, err := plan.Params(t.Context()) if err != nil || !reflect.DeepEqual(again, []string{"value"}) { t.Fatalf("Params after caller mutation = %v, %v; want [value]", again, err) } diff --git a/test/integration/README.md b/test/integration/README.md index 35f92a0..8b5021d 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -8,7 +8,7 @@ api.Runtime caller → wire/client → protobuf and real gRPC over bufconn ``` The suite depends on the API version pinned in the root `go.mod` (currently -`v1.0.0-alpha.19`). It imports neither native Ferret nor Wire internal packages. +`v1.0.0-alpha.20`). It imports neither native Ferret nor Wire internal packages. Existing component, conversion, low-level facade/protocol tests, and benchmarks remain beside their owning packages. The former server-package Universal API adapter, allocation, cancellation, and transport tests are consolidated here. @@ -55,16 +55,24 @@ transport/closed-handle errors. Wire must never close the hosted runtime. ## Interface coverage -Every current interface method has end-to-end coverage. Names below are Go test +Every current interface method has end-to-end coverage. The alpha.20 metadata +suite covers opaque/empty runtime versions (including invalid UTF-8 and NUL bytes), +one hosted call per connection, handshake failure and capacity reclamation, +constructor cancellation, cached metadata context errors, detached normal/debug +parameter snapshots, and reads +after cleanup. Hosted metadata hooks receive their operation context. Core/raw +protocol tests additionally prove cancellation during hosted parameter retrieval +without changing the client's detached allocation contract. Names below are Go test names; each linked file contains the complete scenario and assertions. | Interface | Method | Contract test | | --- | --- | --- | -| `api.Runtime` | `Run` | [TestRuntimeAndSessionOutputRoundTrip](runtime_test.go) | +| `api.Runtime` | `Version(ctx)` | [TestRuntimeVersionSnapshot](metadata_test.go), [TestRuntimeVersionFailureReclaimsConnection](metadata_test.go), [TestConstructionCancellationReachesRuntimeVersion](metadata_test.go) | +| | `Run` | [TestRuntimeAndSessionOutputRoundTrip](runtime_test.go) | | | `Compile` | [TestCompileRoundTrip](plan_test.go) | | | `CompileDebug` | [TestCompileRoundTrip](plan_test.go), [TestDebuggerRoundTrip](debugger_test.go) | | | `Close` | [TestRuntimeCloseBorrowsTransportAndHostedRuntime](runtime_test.go) | -| `api.Plan` | `Params` | [TestCompileRoundTrip](plan_test.go) | +| `api.Plan` | `Params(ctx)` | [TestCompileRoundTrip](plan_test.go), [TestCachedMetadataContextsAndPlanLifecycle](metadata_test.go) | | | `NewSession` | [TestReusablePlanAndDurableSessions](plan_test.go) | | | `NewDebugSession` | [TestDebuggerRoundTrip](debugger_test.go) | | | `Close` | [TestReusablePlanAndDurableSessions](plan_test.go), [TestParentClosePreservesChildrenAndActiveWork](lifecycle_test.go) | @@ -147,12 +155,12 @@ go test -race ./test/integration -count=20 -shuffle=on The root `make test` and `make test-race` include this suite on existing CI jobs. Tests need no native runtime, external server, TCP port, or additional dependency. -## Alpha.19 retained surface +## Retained API contracts -The [complete audit](../../docs/uapi-audit.md) includes `api.Runtime`, `api.Plan`, -`api.Session`, `debugger.Session`, `api.PlanOptions`, and `api.SessionOptions`, -with each method/setter, projection, and owning test. All six retain production -compile-time assertions. [api_contract_test.go](api_contract_test.go) covers output +The [interface coverage](#interface-coverage) lists retained runtime, plan, +session, and debugger methods and their owning tests. Those four interfaces and +`api.PlanOptions` and `api.SessionOptions` retain production compile-time assertions. +[api_contract_test.go](api_contract_test.go) covers output presence and cleanup errors, fallible metadata, FSRoot/content-type presence, anonymous sources, signed function IDs, atomic replacement, final enumeration, command event-plus-error results, Start lifetime, all debugger context signatures, diff --git a/test/integration/api_contract_test.go b/test/integration/api_contract_test.go index 9339f8b..69a1d04 100644 --- a/test/integration/api_contract_test.go +++ b/test/integration/api_contract_test.go @@ -81,34 +81,44 @@ func TestOutputPresenceAndCleanupErrors(t *testing.T) { } func TestMetadataFailureClosesUnpublishedPlan(t *testing.T) { - for _, mode := range []string{"error", "panic", "error and close error"} { - t.Run(mode, func(t *testing.T) { - behavior := harness.PlanBehavior{Metadata: func() ([]string, error) { - if mode == "panic" { - panic("private metadata") + for _, debug := range []bool{false, true} { + for _, mode := range []string{"error", "panic", "error and close error"} { + t.Run(map[bool]string{false: "normal/", true: "debug/"}[debug]+mode, func(t *testing.T) { + behavior := harness.PlanBehavior{Metadata: func(context.Context) ([]string, error) { + if mode == "panic" { + panic("private metadata") + } + + return []string{"partial"}, errors.New("private metadata") + }} + + if mode == "error and close error" { + behavior.Close = func() error { return errors.New("private cleanup") } } - return []string{"partial"}, errors.New("private metadata") - }} + limits := server.DefaultLimits() + limits.MaxPlansPerConnection = 1 + h := harness.New(t, harness.WithServerOptions(server.WithLimits(limits)), harness.WithBehavior(harness.RuntimeBehavior{Plan: behavior})) + compile := h.Runtime().Compile - if mode == "error and close error" { - behavior.Close = func() error { return errors.New("private cleanup") } - } + if debug { + compile = h.Runtime().CompileDebug + } - h := harness.New(t, harness.WithBehavior(harness.RuntimeBehavior{Plan: behavior})) - for range 2 { - plan, err := h.Runtime().Compile(h.Context(), api.Source{}) - if plan != nil || err == nil || strings.Contains(err.Error(), "private") { - t.Fatalf("unpublished plan=%v err=%v", plan, err) + for range 2 { + plan, err := compile(h.Context(), api.Source{}) + if plan != nil || err == nil || strings.Contains(err.Error(), "private") { + t.Fatalf("unpublished plan=%v err=%v", plan, err) + } } - } - h.RuntimeSpy().Recorder().AssertClosed(t) + h.RuntimeSpy().Recorder().AssertClosed(t) - if h.Faults().Count(harness.ReleasePlan) != 0 { - t.Fatal("unpublished plan acquired a transport handle") - } - }) + if h.Faults().Count(harness.ReleasePlan) != 0 { + t.Fatal("unpublished plan acquired a transport handle") + } + }) + } } } diff --git a/test/integration/harness/plan.go b/test/integration/harness/plan.go index ac472ce..ba9d978 100644 --- a/test/integration/harness/plan.go +++ b/test/integration/harness/plan.go @@ -12,7 +12,7 @@ type ( PlanBehavior struct { Params []string ParamsError error - Metadata func() ([]string, error) + Metadata func(context.Context) ([]string, error) NewSession func(context.Context, SessionOptions) error NewDebugSession func(context.Context, SessionOptions) error Session func(SessionOptions) SessionBehavior @@ -32,11 +32,15 @@ type ( var _ api.Plan = (*PlanSpy)(nil) // Params records inspection and returns a copy of the configured parameter names. -func (p *PlanSpy) Params() ([]string, error) { +func (p *PlanSpy) Params(ctx context.Context) ([]string, error) { p.recorder.record(Call{Resource: p.id, Method: "Params"}) if p.behavior.Metadata != nil { - return p.behavior.Metadata() + return p.behavior.Metadata(ctx) + } + + if err := ctx.Err(); err != nil { + return nil, err } return append([]string(nil), p.behavior.Params...), p.behavior.ParamsError diff --git a/test/integration/harness/runtime.go b/test/integration/harness/runtime.go index 8bf0081..ef6d461 100644 --- a/test/integration/harness/runtime.go +++ b/test/integration/harness/runtime.go @@ -9,6 +9,7 @@ import ( type ( // RuntimeBehavior is configured before the server starts. Hooks run outside locks. RuntimeBehavior struct { + Version func(context.Context) (api.Version, error) Run func(context.Context, api.Source, SessionOptions) (*api.Output, error) Compile func(context.Context, api.Source, bool, CompileOptions) error Plan PlanBehavior @@ -41,6 +42,18 @@ func (r *RuntimeSpy) ID() int { return r.id } +// Version records handshake metadata retrieval and invokes the configured hook. +func (r *RuntimeSpy) Version(ctx context.Context) (api.Version, error) { + r.recorder.record(Call{Resource: r.id, Method: "Version"}) + defer r.recorder.record(Call{Resource: r.id, Method: "VersionFinished"}) + + if r.behavior.Version != nil { + return r.behavior.Version(ctx) + } + + return api.Version("runtime-test"), ctx.Err() +} + // Run records source and applied options before invoking the configured direct-run hook. func (r *RuntimeSpy) Run(ctx context.Context, src api.Source, options ...api.SessionOption) (*api.Output, error) { configured, err := applyOptions(options) diff --git a/test/integration/metadata_test.go b/test/integration/metadata_test.go new file mode 100644 index 0000000..07f4cbb --- /dev/null +++ b/test/integration/metadata_test.go @@ -0,0 +1,248 @@ +package integration_test + +import ( + "context" + "errors" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/MontFerret/api" + "github.com/MontFerret/wire/client" + "github.com/MontFerret/wire/pkg/failure" + "github.com/MontFerret/wire/server" + "github.com/MontFerret/wire/test/integration/harness" +) + +func TestRuntimeVersionSnapshot(t *testing.T) { + for _, value := range []api.Version{"v2.0.0-alpha.test", " runtime-2.0+opaque \n", "版本-α", "\xff", "runtime\x00\xff\xc3", ""} { + t.Run(string(value), func(t *testing.T) { + h := harness.New(t, harness.WithBehavior(harness.RuntimeBehavior{Version: func(context.Context) (api.Version, error) { return value, nil }}), + harness.WithServerOptions(server.WithRuntimeIdentity(server.RuntimeIdentity{Name: "host", Version: "host-7.1"}))) + for range 3 { + got, err := h.Runtime().Version(h.Context()) + if err != nil || got != value { + t.Fatalf("Version=%q, %v; want %q", got, err, value) + } + } + + if h.RuntimeSpy().Recorder().Snapshot().Count(h.RuntimeSpy().ID(), "Version") != 1 { + t.Fatal("cached Version called hosted implementation again") + } + + if err := h.Runtime().Close(); err != nil { + t.Fatal(err) + } + + if err := h.CloseTransport(); err != nil { + t.Fatal(err) + } + + got, err := h.Runtime().Version(h.Context()) + if err != nil || got != value { + t.Fatalf("Version after cleanup=%q, %v", got, err) + } + }) + } +} + +func TestRuntimeVersionFailureReclaimsConnection(t *testing.T) { + for _, mode := range []string{"error", "panic", "canceled", "deadline"} { + t.Run(mode, func(t *testing.T) { + var calls atomic.Int32 + limits := server.DefaultLimits() + limits.MaxConnections = 1 + + h := harness.New(t, harness.WithServerOptions(server.WithLimits(limits)), harness.WithBehavior(harness.RuntimeBehavior{Version: func(context.Context) (api.Version, error) { + if calls.Add(1) != 2 { + return "runtime-test", nil + } + + switch mode { + case "panic": + panic("private version") + case "canceled": + return "", context.Canceled + case "deadline": + return "", context.DeadlineExceeded + default: + return "", errors.New("private version") + } + }})) + if err := h.Runtime().Close(); err != nil { + t.Fatal(err) + } + + remote, err := h.OpenRuntime() + if remote != nil || err == nil || strings.Contains(err.Error(), "private") { + t.Fatalf("failed handshake=%v, %v", remote, err) + } + + wantCode := codes.Internal + switch mode { + case "canceled": + wantCode = codes.Canceled + if !errors.Is(err, context.Canceled) { + t.Fatalf("lost cancellation: %v", err) + } + case "deadline": + wantCode = codes.DeadlineExceeded + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("lost deadline: %v", err) + } + default: + var wireErr *client.Error + if !errors.As(err, &wireErr) || wireErr.Category != failure.CategoryInternalRuntime { + t.Fatalf("lost runtime boundary category: %v", err) + } + } + + if status.Code(err) != wantCode { + t.Fatalf("handshake code=%v; want %v", status.Code(err), wantCode) + } + + if _, err := h.OpenRuntime(); err != nil { + t.Fatalf("failed handshake retained connection quota: %v", err) + } + }) + } +} + +func TestConstructionCancellationReachesRuntimeVersion(t *testing.T) { + for _, deadline := range []bool{false, true} { + t.Run(map[bool]string{false: "canceled", true: "deadline"}[deadline], func(t *testing.T) { + var calls atomic.Int32 + block := harness.NewBlock(t) + h := harness.New(t, harness.WithBehavior(harness.RuntimeBehavior{Version: func(ctx context.Context) (api.Version, error) { + if calls.Add(1) == 1 { + return "runtime-test", nil + } + + return "", block.Wait(ctx) + }})) + ctx, cancel := context.WithCancel(h.Context()) + defer cancel() + + if deadline { + ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + } + + finished := make(chan error, 1) + go func() { + remote, err := client.New(ctx, h.Faults()) + if remote != nil { + err = errors.Join(errors.New("canceled handshake returned a runtime"), remote.Close()) + } + + finished <- err + }() + harness.Await(t, block.Started) + + if !deadline { + cancel() + } + + want := context.Canceled + + if deadline { + want = context.DeadlineExceeded + } + + if err := harness.Await(t, finished); !errors.Is(err, want) { + t.Fatalf("construction error=%v; want %v", err, want) + } + + harness.Await(t, block.Cancelled) + harness.Await(t, block.Finished) + }) + } +} + +func TestCachedMetadataContextsAndPlanLifecycle(t *testing.T) { + for _, debug := range []bool{false, true} { + for _, params := range [][]string{nil, {"second", "first"}} { + name := map[bool]string{false: "normal", true: "debug"}[debug] + + if len(params) == 0 { + name += "/empty" + } + + t.Run(name, func(t *testing.T) { + h := harness.New(t, harness.WithBehavior(harness.RuntimeBehavior{Plan: harness.PlanBehavior{Params: params}})) + compile := h.Runtime().Compile + + if debug { + compile = h.Runtime().CompileDebug + } + + plan, err := compile(h.Context(), api.Source{}) + if err != nil { + t.Fatal(err) + } + + h.Own(plan) + read := func() { + got, err := plan.Params(h.Context()) + if err != nil || !reflect.DeepEqual(got, params) { + t.Fatalf("Params=%v, %v; want %v", got, err, params) + } + + if len(got) != 0 { + got[0] = "caller mutation" + } + } + read() + read() + + canceled, cancel := context.WithCancel(h.Context()) + cancel() + expired, expire := context.WithDeadline(h.Context(), time.Now().Add(-time.Second)) + defer expire() + for _, test := range []struct { + ctx context.Context + err error + }{{nil, nil}, {canceled, context.Canceled}, {expired, context.DeadlineExceeded}} { + for _, operation := range []func(context.Context) error{ + func(ctx context.Context) error { _, err := h.Runtime().Version(ctx); return err }, + func(ctx context.Context) error { _, err := plan.Params(ctx); return err }, + } { + if err := operation(test.ctx); err == nil || (test.err != nil && !errors.Is(err, test.err)) { + t.Fatalf("metadata context error=%v; want %v", err, test.err) + } + } + } + + if err := plan.Close(); err != nil { + t.Fatal(err) + } + + if err := h.Runtime().Close(); err != nil { + t.Fatal(err) + } + + if err := h.CloseTransport(); err != nil { + t.Fatal(err) + } + + before := h.RuntimeSpy().Recorder().Snapshot() + read() + read() + + if _, err := h.Runtime().Version(h.Context()); err != nil { + t.Fatal(err) + } + + after := h.RuntimeSpy().Recorder().Snapshot() + if !reflect.DeepEqual(before, after) || after.Count(after.OfKind("plan")[0].ID, "Params") != 1 { + t.Fatal("cached metadata contacted the hosted implementation") + } + }) + } + } +} diff --git a/test/integration/plan_test.go b/test/integration/plan_test.go index d804980..e55d16f 100644 --- a/test/integration/plan_test.go +++ b/test/integration/plan_test.go @@ -255,7 +255,7 @@ func TestReusablePlanAndDurableSessions(t *testing.T) { func requirePlanParams(t *testing.T, plan api.Plan) []string { t.Helper() - values, err := plan.Params() + values, err := plan.Params(t.Context()) if err != nil { t.Fatal(err) }