diff --git a/README.md b/README.md index cc3ece2..151e7fb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ host application client application owns and secures net.Listener owns transport lifetime | | v v - server.Server <-------- ferret.wire.v1 ------ client.Runtime / Client + server.Server <-------- ferret.wire.v1 ------ api.Runtime via client.New borrows runtime owns Connect stream | logical Connection @@ -44,7 +44,7 @@ Unary execution and debug resume calls publish work before returning. Once publi 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 Go client converts parameters without reflection. `client.Parameters` 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. +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. See [Wire Protocol](docs/protocol.md) for every RPC/message/enum, lifecycle and watch semantics, compatibility classifications, Unified API gaps, and deferred work. @@ -71,157 +71,91 @@ ownership or requiring an adapter. For an application-private Unix socket, the caller creates `net.Listen("unix", socket)`, applies appropriate directory and socket permissions, and closes both the listener and runtime after the Wire server has shut down. -The caller owns the gRPC transport. Closing either Wire client facade closes -only its logical connection and the remote resources created through it. +## Remote runtime example -The Universal API facade is the primary path for callers that do not need -Wire-specific asynchronous handles: +Configure the transport before constructing the remote runtime. For a private +Unix socket, the caller can use: ```go -remoteRuntime, err := client.NewRuntime(ctx, conn) -if err != nil { - log.Fatal(err) -} -defer func() { - if err := remoteRuntime.Close(); err != nil { - log.Printf("close remote runtime: %v", err) - } -}() - -output, err := remoteRuntime.Run( - ctx, - api.NewSource("example.fql", "RETURN @input"), - api.WithParam("input", "hello"), - api.WithOutputContentType("application/json"), -) -if err != nil { - log.Fatal(err) -} -fmt.Printf("%s: %s\n", output.ContentType, output.Content) -``` - -`NewRuntime` returns `client.Runtime`, an alias of the canonical `api.Runtime` -interface backed by a private Wire adapter. `client.Session` aliases -`api.Session`, and `client.Output` aliases `api.Output`, whose definition belongs -to `github.com/MontFerret/api/result`. These aliases preserve canonical type -identity. Plans and debugger Sessions use `api.Plan` and `api/debugger.Session`; -their Wire adapters remain private. Source constructors, options, and debugger -inspection types remain in their canonical packages. - -Plans and durable Sessions may be reused; normal Session runs are sequential. -All adapter `Close` methods use bounded detached cleanup and never close `conn`. - -Code that explicitly declared `*client.Runtime` must now use `client.Runtime`. -The inferred `remoteRuntime, err := client.NewRuntime(ctx, conn)` usage above is -unchanged. The lower-level `client.Plan` and `client.DebugSession` handles keep -their existing names and methods. - -Allocation replies that race cancellation are reclaimed automatically. If a -reply is lost, the adapter closes the nearest owning Session or Plan and -escalates to its logical Runtime only when needed. The caller's gRPC transport -remains open. See [allocation and cancellation](docs/client.md#allocation-and-cancellation) -for the bounded cleanup contract. - -The lower-level `client.Client` remains available. Its common one-shot path creates and releases -its plan and execution automatically: - -```go -output, err := wireClient.Run( - ctx, - api.NewSource("example.fql", "RETURN @input"), - client.Parameters{"input": "hello"}, - client.RunOptions{}, -) -if err != nil { - log.Fatal(err) -} -fmt.Printf("%s: %s\n", output.ContentType, output.Content) -``` - -Use explicit handles when plans must be reused or execution needs watching, -cancellation, or separately reported cleanup: - -```go -const socket = "/var/run/my-app/ferret-wire.sock" conn, err := grpc.NewClient( "passthrough:///ferret-wire", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { - return new(net.Dialer).DialContext(ctx, "unix", socket) + return new(net.Dialer).DialContext(ctx, "unix", "/var/run/my-app/ferret-wire.sock") }), ) -if err != nil { - log.Fatal(err) -} -defer conn.Close() +``` -wireClient, err := client.New(ctx, conn) -if err != nil { - log.Fatal(err) -} -defer func() { - if err := wireClient.Close(context.Background()); err != nil { - log.Printf("close Wire client: %v", err) - } -}() - -plan, err := wireClient.Compile( - ctx, - api.NewSource("example.fql", "RETURN {input: @input}"), - client.CompileOptions{ - PlanOptions: []api.PlanOption{api.WithOptimizationLevel(api.OptimizationBasic)}, - }, -) -if err != nil { - log.Fatal(err) -} -defer func() { - if err := plan.Close(context.Background()); err != nil { - log.Printf("close plan: %v", err) - } -}() +The caller checks the connection error and closes `conn` after its remote +runtimes. Credentials, TLS, dial options, and message limits belong to this +transport setup. `client.New` borrows the supplied connection and returns +`api.Runtime`; subsequent operations use the same interfaces as a local runtime: -execution, err := plan.Execute(ctx, map[string]any{"input": "hello"}, client.ExecuteOptions{}) -if err != nil { - log.Fatal(err) -} -defer func() { - if err := execution.Close(context.Background()); err != nil { - log.Printf("close execution: %v", err) +```go +func runRemote(ctx context.Context, conn grpc.ClientConnInterface) (out api.Output, err error) { + remote, err := client.New(ctx, conn) + if err != nil { + return api.Output{}, err } -}() + defer func() { err = errors.Join(err, remote.Close()) }() -events, err := execution.Watch(ctx) -if err != nil { - log.Fatal(err) -} -for { - event, err := events.Recv() + plan, err := remote.Compile( + ctx, + api.NewSource("example.fql", "RETURN @input"), + api.WithOptimizationLevel(api.OptimizationBasic), + ) if err != nil { - log.Fatal(err) - } - if event.Snapshot.State == execution.StateCompleted { - fmt.Printf("%s: %s\n", event.Snapshot.Output.ContentType, event.Snapshot.Output.Content) - break + return api.Output{}, err } - if event.Snapshot.State.Terminal() { - log.Fatalf("execution ended in state %v: %v", event.Snapshot.State, event.Snapshot.Failure) + defer func() { err = errors.Join(err, plan.Close()) }() + + session, err := plan.NewSession( + ctx, + api.WithParam("input", "hello"), + api.WithOutputContentType("application/json"), + ) + if err != nil { + return api.Output{}, err } + defer func() { err = errors.Join(err, session.Close()) }() + + return session.Run(ctx) } ``` -The public Go API is split by ownership: `server` hosts a borrowed `api.Runtime`, -`client` owns remote handles, and `pkg/execution`, `pkg/debugger`, and -`pkg/failure` contain the semantic values shared by both sides. The module root -intentionally has no Go compatibility package. - -Wire failures expose `failure.Category` through `*client.Error`. When the -runtime returns typed `diagnostics.Diagnostics`, `*client.Error` and -asynchronous `*failure.Failure` preserve that canonical collection, including -source content and ordered annotations. Bare cancellation, deadline, -invalid-request, unavailable, and resource-exhaustion statuses remain -category-free and are classified with `status.Code(err)`. Remote connection and -resource IDs are not part of the high-level client error model. +For a one-shot invocation, `remote.Run(ctx, source, options...)` calls the hosted +`api.Runtime.Run` directly. Plans and durable sessions may be reused; normal +session runs are sequential. Output remains `api.Output`: content type and +encoded bytes. + +For debugging, use `remote.CompileDebug`, `plan.NewDebugSession`, and the +canonical `api/debugger.Session` commands and events. Connection IDs, execution +handles, and Wire watch streams remain private. + +The constructor context bounds the handshake. Cancelling it after construction +does not close the runtime. All resource `Close` methods use bounded detached +cleanup and leave `conn` open. Allocation replies that race cancellation are +reclaimed automatically. If a reply is lost, the adapter closes the nearest +owning session or plan and escalates to its logical runtime only when needed. +See [allocation and cancellation](docs/client.md#allocation-and-cancellation). + +The public client exports only `New`, `Error`, `ErrClosed`, and +`ErrExecutionCancelled`. Existing users of `NewRuntime` should call `New`; +`client.Runtime`, `client.Session`, and `client.Output` declarations should use +the canonical `api` types. The previous lower-level handles, options, metadata, +and convenience operations have been removed without compatibility aliases. + +Immediate failures expose a Wire `failure.Category` through `*client.Error`. +Terminal failures use `*failure.Failure`; both preserve canonical typed +diagnostics and sanitized messages. `errors.Is` distinguishes `ErrClosed`, +`ErrExecutionCancelled`, and caller context errors. Operation and cleanup +errors remain joined. Transport causes remain accessible through `Unwrap` and +`status.Code(err)` when transport-specific handling is needed. The API has no +general remote-error taxonomy to substitute for these Wire errors. + +`server` hosts a borrowed `api.Runtime`, and `client` implements that interface +remotely. `pkg/execution`, `pkg/debugger`, and `pkg/failure` retain the domain +values shared by both sides. The module root has no Go compatibility package. ## Security and trust model @@ -233,7 +167,7 @@ Windows named pipes and remote TCP/TLS can be added later by supplying ordinary ## Non-goals and current limitations -Wire does not provide runtime introspection, Ferret module discovery, language intelligence, LSP, DAP translation, listener policy, downstream ferretd/CLI/Lab integration, TTLs, heartbeats, negotiated advanced capabilities, or node/distributed bytecode transport. Further lower-level client redesign is separate from the Universal API facade. Wire makes no changes to Ferret core or other MontFerret repositories. +Wire does not provide runtime introspection, Ferret module discovery, language intelligence, LSP, DAP translation, listener policy, downstream ferretd/CLI/Lab integration, TTLs, heartbeats, negotiated advanced capabilities, or node/distributed bytecode transport. Wire makes no changes to Ferret core or other MontFerret repositories. Wire forwards cancellation to Unified API compile and session operations. Whether an implementation can promptly interrupt its internal work remains a runtime diff --git a/client/client.go b/client/client.go index de30d4c..47d29e9 100644 --- a/client/client.go +++ b/client/client.go @@ -6,27 +6,14 @@ import ( "io" "sync" - "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/pkg/failure" "google.golang.org/grpc" ) type ( - // Runtime is the canonical api.Runtime contract, re-exported for remote use. - // NewRuntime returns a private Wire implementation of this interface. - Runtime = api.Runtime - - // Session is the canonical api.Session contract for reusable normal sessions. - // NewRuntime's plan adapters return private implementations of this interface. - Session = api.Session - - // Output aliases api.Output, defined by github.com/MontFerret/api/result. - // Wire preserves its content type and encoded bytes without interpretation. - Output = api.Output - - // Client owns one logical Wire connection while borrowing its gRPC transport. - Client struct { + // connectionHandle owns one logical connection and borrows its gRPC transport. + connectionHandle struct { runtimeClient wirev1.RuntimeServiceClient planClient wirev1.PlanServiceClient sessionClient wirev1.SessionServiceClient @@ -34,7 +21,6 @@ type ( debugClient wirev1.DebugServiceClient connectionID string - info RuntimeInfo stream wirev1.RuntimeService_ConnectClient streamCancel context.CancelFunc streamDone chan struct{} @@ -51,10 +37,10 @@ type ( } ) -// New opens one logical Wire connection over a caller-owned gRPC connection. +// newConnection opens one logical Wire connection over a caller-owned gRPC connection. // The construction context bounds the Connect handshake; Close owns the // resulting long-lived logical lifecycle. -func New(ctx context.Context, connection grpc.ClientConnInterface) (*Client, error) { +func newConnection(ctx context.Context, connection grpc.ClientConnInterface) (*connectionHandle, error) { if connection == nil { return nil, errors.New("gRPC connection is required") } @@ -104,14 +90,13 @@ func New(ctx context.Context, connection grpc.ClientConnInterface) (*Client, err } lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background()) - client := &Client{ + client := &connectionHandle{ runtimeClient: runtimeClient, planClient: wirev1.NewPlanServiceClient(connection), sessionClient: wirev1.NewSessionServiceClient(connection), executionClient: wirev1.NewExecutionServiceClient(connection), debugClient: wirev1.NewDebugServiceClient(connection), connectionID: response.GetConnectionId().GetValue(), - info: convertRuntimeInfo(response.GetProtocol(), response.GetRuntimeIdentity()), stream: stream, streamCancel: streamCancel, streamDone: make(chan struct{}), @@ -125,37 +110,9 @@ func New(ctx context.Context, connection grpc.ClientConnInterface) (*Client, err return client, nil } -// RuntimeInfo returns a copy of the server and host information published by -// the Connect handshake. -func (c *Client) RuntimeInfo() RuntimeInfo { - result := c.info - - if c.info.RuntimeIdentity != nil { - identity := *c.info.RuntimeIdentity - result.RuntimeIdentity = &identity - } - - return result -} - -// Run compiles and executes source once, returning the runtime's encoded output. -// It releases the Plan and Execution resources it creates before returning and -// joins operation and release errors. -func (c *Client) Run(ctx context.Context, src api.Source, parameters Parameters, options RunOptions) (Output, error) { - plan, err := c.Compile(ctx, src, options.Compile) - if err != nil { - return Output{}, err - } - - output, runErr := plan.Run(ctx, parameters, options.Execute) - closeErr := boundedCleanup(ctx, convenienceCleanupTimeout, plan.Close) - - return output, errors.Join(runErr, closeErr) -} - // Close releases the logical Wire connection without closing the caller-owned // gRPC transport. Concurrent callers wait for the same retained result. -func (c *Client) Close(ctx context.Context) error { +func (c *connectionHandle) Close(ctx context.Context) error { c.closeOnce.Do(func() { c.closeMu.Lock() c.closing = true @@ -182,7 +139,7 @@ func (c *Client) Close(ctx context.Context) error { } } -func (c *Client) monitorConnect() { +func (c *connectionHandle) monitorConnect() { _, err := c.stream.Recv() c.streamMu.Lock() @@ -198,7 +155,7 @@ func (c *Client) monitorConnect() { close(c.streamDone) } -func (c *Client) checkOpen() error { +func (c *connectionHandle) checkOpen() error { if c == nil { return ErrClosed } @@ -225,7 +182,7 @@ func (c *Client) checkOpen() error { } } -func (c *Client) closeResult(ctx context.Context) (bool, error) { +func (c *connectionHandle) closeResult(ctx context.Context) (bool, error) { if c == nil { return true, ErrClosed } @@ -251,18 +208,18 @@ func (c *Client) closeResult(ctx context.Context) (bool, error) { } } -func (c *Client) retainedCloseResult() error { +func (c *connectionHandle) retainedCloseResult() error { c.closeMu.Lock() defer c.closeMu.Unlock() return c.closeErr } -func (c *Client) connectionProto() *wirev1.ConnectionId { +func (c *connectionHandle) connectionProto() *wirev1.ConnectionId { return &wirev1.ConnectionId{Value: c.connectionID} } -func (c *Client) settleClose(ctx context.Context) { +func (c *connectionHandle) settleClose(ctx context.Context) { var result error defer func() { if recover() != nil { @@ -294,7 +251,7 @@ func (c *Client) settleClose(ctx context.Context) { } } -func (c *Client) watchContext(ctx context.Context) (context.Context, context.CancelFunc) { +func (c *connectionHandle) watchContext(ctx context.Context) (context.Context, context.CancelFunc) { watch, cancel := context.WithCancel(ctx) stop := context.AfterFunc(c.lifecycleCtx, cancel) diff --git a/client/client_test.go b/client/client_test.go index c115395..1bf7bd6 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -33,9 +33,14 @@ type ( wirev1.UnimplementedRuntimeServiceServer wirev1.UnimplementedPlanServiceServer wirev1.UnimplementedExecutionServiceServer + wirev1.UnimplementedSessionServiceServer mu sync.Mutex + handshake *wirev1.ConnectResponse + connectErr error + connectDone chan struct{} plans int + sessions int executions int calls []string compileErr error @@ -56,10 +61,23 @@ type ( ) func (s *clientTestServer) Connect(_ *wirev1.ConnectRequest, stream wirev1.RuntimeService_ConnectServer) error { - if err := stream.Send(&wirev1.ConnectResponse{ - ConnectionId: &wirev1.ConnectionId{Value: "connection"}, - Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"}, - }); err != nil { + if s.connectDone != nil { + defer close(s.connectDone) + } + + if s.connectErr != nil { + return s.connectErr + } + + response := s.handshake + if response == nil { + response = &wirev1.ConnectResponse{ + ConnectionId: &wirev1.ConnectionId{Value: "connection"}, + Protocol: &wirev1.ProtocolInfo{Name: "ferret.wire", Version: "v1"}, + } + } + + if err := stream.Send(response); err != nil { return err } @@ -127,22 +145,48 @@ func (s *clientTestServer) ReleasePlan(ctx context.Context, request *wirev1.Rele return &wirev1.ReleasePlanResponse{}, nil } -func (s *clientTestServer) Execute(_ context.Context, request *wirev1.ExecuteRequest) (*wirev1.ExecuteResponse, error) { +func (s *clientTestServer) Run(_ context.Context, request *wirev1.RunRequest) (*wirev1.RunResponse, error) { + value, err := s.startExecution("run", request.GetConnectionId().GetValue(), "", request.GetOutputContentType()) + + return &wirev1.RunResponse{Execution: value}, err +} + +func (s *clientTestServer) RunSession(_ context.Context, request *wirev1.RunSessionRequest) (*wirev1.RunSessionResponse, error) { + value, err := s.startExecution("run-session", request.GetConnectionId().GetValue(), request.GetSessionId().GetValue(), "") + + return &wirev1.RunSessionResponse{Execution: value}, err +} + +func (s *clientTestServer) startExecution(operation, connectionID, parentID, contentType string) (*wirev1.Execution, error) { s.mu.Lock() - s.calls = append(s.calls, call("execute", request.GetConnectionId().GetValue(), request.GetPlanId().GetValue())) - s.lastOutputContentType = request.GetOutputContentType() - err := s.executeErr - if err == nil { - s.executions++ - } - executionID := fmt.Sprintf("execution-%d", s.executions) - s.mu.Unlock() + defer s.mu.Unlock() - if err != nil { - return nil, err + s.calls = append(s.calls, call(operation, connectionID, parentID)) + s.lastOutputContentType = contentType + if s.executeErr != nil { + return nil, s.executeErr } - return &wirev1.ExecuteResponse{Execution: executionSnapshotProto(executionID, wirev1.ExecutionState_EXECUTION_STATE_RUNNING, nil, nil)}, nil + s.executions++ + + return executionSnapshotProto(fmt.Sprintf("execution-%d", s.executions), wirev1.ExecutionState_EXECUTION_STATE_RUNNING, nil, nil), nil +} + +func (s *clientTestServer) CreateSession(_ context.Context, request *wirev1.CreateSessionRequest) (*wirev1.CreateSessionResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.sessions++ + s.calls = append(s.calls, call("new-session", request.GetConnectionId().GetValue(), request.GetPlanId().GetValue())) + id := fmt.Sprintf("session-%d", s.sessions) + + return &wirev1.CreateSessionResponse{Session: &wirev1.Session{Id: &wirev1.SessionId{Value: id}}}, nil +} + +func (s *clientTestServer) ReleaseSession(_ context.Context, request *wirev1.ReleaseSessionRequest) (*wirev1.ReleaseSessionResponse, error) { + s.record("release-session", request.GetConnectionId().GetValue(), request.GetSessionId().GetValue()) + + return &wirev1.ReleaseSessionResponse{}, nil } func (s *clientTestServer) ReleaseExecution(ctx context.Context, request *wirev1.ReleaseExecutionRequest) (*wirev1.ReleaseExecutionResponse, error) { @@ -223,43 +267,38 @@ func assertCleanupDeadline(t *testing.T, kind string, deadline time.Time) { } } -func TestClientRunOwnsCreatedResources(t *testing.T) { +func TestRuntimeRunOwnsItsExecution(t *testing.T) { t.Run("success and options", func(t *testing.T) { server := &clientTestServer{watchScripts: []executionWatchScript{{events: []*wirev1.WatchExecutionResponse{ executionCompletedEvent("execution-1", "application/json", []byte(`{"value":1}`)), }}}} - client := openTestClient(t, startClientTestServer(t, server)) + client := openTestRuntime(t, startClientTestServer(t, server)) - output, err := client.Run(testClientContext(t), api.Source{Content: "RETURN 1"}, nil, RunOptions{ - Compile: CompileOptions{Debuggable: true}, - Execute: ExecuteOptions{OutputContentType: "application/json"}, - }) + output, err := client.Run(testClientContext(t), api.Source{Content: "RETURN 1"}, api.WithOutputContentType("application/json")) if err != nil || string(output.Content) != `{"value":1}` { - t.Fatalf("unexpected Client.Run result: %#v, %v", output, err) + t.Fatalf("unexpected Runtime.Run result: %#v, %v", output, err) } calls, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() want := []string{ - call("compile", "connection", ""), - call("execute", "connection", "plan-1"), + call("run", "connection", ""), call("watch", "connection", "execution-1"), call("release-execution", "connection", "execution-1"), - call("release-plan", "connection", "plan-1"), } server.mu.Lock() debuggable := server.lastCompileDebuggable contentType := server.lastOutputContentType server.mu.Unlock() - if !slices.Equal(calls, want) || !debuggable || contentType != "application/json" || releaseExecutionCalls != 1 || releasePlanCalls != 1 { - t.Fatalf("Client.Run orchestration: calls=%v debug=%v content=%q releases=%d/%d", calls, debuggable, contentType, releaseExecutionCalls, releasePlanCalls) + if !slices.Equal(calls, want) || debuggable || contentType != "application/json" || releaseExecutionCalls != 1 || releasePlanCalls != 0 { + t.Fatalf("Runtime.Run orchestration: calls=%v debug=%v content=%q releases=%d/%d", calls, debuggable, contentType, releaseExecutionCalls, releasePlanCalls) } }) t.Run("compile failure creates nothing", func(t *testing.T) { server := &clientTestServer{compileErr: status.Error(codes.InvalidArgument, "compile failed")} - client := openTestClient(t, startClientTestServer(t, server)) + client := openTestRuntime(t, startClientTestServer(t, server)) - _, err := client.Run(testClientContext(t), api.Source{Content: "invalid"}, nil, RunOptions{}) + _, err := client.Compile(testClientContext(t), api.Source{Content: "invalid"}) var wireErr *Error if !errors.As(err, &wireErr) || wireErr.Message != "compile failed" { t.Fatalf("unexpected compile failure: %v", err) @@ -270,22 +309,20 @@ func TestClientRunOwnsCreatedResources(t *testing.T) { } }) - t.Run("execute failure releases plan", func(t *testing.T) { + t.Run("run rejection creates no resources", func(t *testing.T) { server := &clientTestServer{executeErr: status.Error(codes.InvalidArgument, "execute failed")} - client := openTestClient(t, startClientTestServer(t, server)) + client := openTestRuntime(t, startClientTestServer(t, server)) - _, err := client.Run(testClientContext(t), api.Source{Content: "RETURN 1"}, nil, RunOptions{}) + _, err := client.Run(testClientContext(t), api.Source{Content: "RETURN 1"}) var wireErr *Error if !errors.As(err, &wireErr) || wireErr.Message != "execute failed" { t.Fatalf("unexpected execute failure: %v", err) } calls, watchCalls, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() want := []string{ - call("compile", "connection", ""), - call("execute", "connection", "plan-1"), - call("release-plan", "connection", "plan-1"), + call("run", "connection", ""), } - if !slices.Equal(calls, want) || watchCalls != 0 || releaseExecutionCalls != 0 || releasePlanCalls != 1 { + if !slices.Equal(calls, want) || watchCalls != 0 || releaseExecutionCalls != 0 || releasePlanCalls != 0 { t.Fatalf("execute failure cleanup: %v", calls) } }) @@ -297,36 +334,38 @@ func TestClientRunOwnsCreatedResources(t *testing.T) { block: true, entered: entered, }}} - client := openTestClient(t, startClientTestServer(t, server)) + client := openTestRuntime(t, startClientTestServer(t, server)) ctx, cancel := context.WithCancel(context.Background()) result := make(chan error, 1) go func() { - _, err := client.Run(ctx, api.Source{Content: "RETURN 1"}, nil, RunOptions{}) + _, err := client.Run(ctx, api.Source{Content: "RETURN 1"}) result <- err }() select { case <-entered: case <-time.After(10 * time.Second): - t.Fatal("Client.Run did not begin waiting") + t.Fatal("Runtime.Run did not begin waiting") } cancel() select { case err := <-result: if !errors.Is(err, context.Canceled) { - t.Fatalf("Client.Run lost caller cancellation: %v", err) + t.Fatalf("Runtime.Run lost caller cancellation: %v", err) } case <-time.After(10 * time.Second): - t.Fatal("Client.Run cleanup did not settle") + t.Fatal("Runtime.Run cleanup did not settle") } _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() - if releaseExecutionCalls != 1 || releasePlanCalls != 1 { - t.Fatalf("cancelled Client.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) + if releaseExecutionCalls != 1 || releasePlanCalls != 0 { + t.Fatalf("cancelled Runtime.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) } executionDeadline, planDeadline := server.releaseDeadlineSnapshot() assertCleanupDeadline(t, "execution", executionDeadline) - assertCleanupDeadline(t, "plan", planDeadline) + if !planDeadline.IsZero() { + t.Fatal("direct run released a plan") + } }) t.Run("stream failure still cleans up", func(t *testing.T) { @@ -334,16 +373,16 @@ func TestClientRunOwnsCreatedResources(t *testing.T) { events: []*wirev1.WatchExecutionResponse{executionStartedEvent("execution-1")}, err: status.Error(codes.Unavailable, "watch transport failed"), }}} - client := openTestClient(t, startClientTestServer(t, server)) + client := openTestRuntime(t, startClientTestServer(t, server)) - _, err := client.Run(testClientContext(t), api.Source{Content: "RETURN 1"}, nil, RunOptions{}) + _, err := client.Run(testClientContext(t), api.Source{Content: "RETURN 1"}) var wireErr *Error if !errors.As(err, &wireErr) || status.Code(err) != codes.Unavailable || wireErr.Message != "watch transport failed" { - t.Fatalf("Client.Run lost the stream failure: %v", err) + t.Fatalf("Runtime.Run lost the stream failure: %v", err) } _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() - if releaseExecutionCalls != 1 || releasePlanCalls != 1 { - t.Fatalf("stream-failed Client.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) + if releaseExecutionCalls != 1 || releasePlanCalls != 0 { + t.Fatalf("stream-failed Runtime.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) } }) @@ -356,26 +395,25 @@ func TestClientRunOwnsCreatedResources(t *testing.T) { }), }}}, releaseExecutionErr: status.Error(codes.Internal, "execution cleanup failed"), - releasePlanErr: status.Error(codes.Unavailable, "plan cleanup failed"), } - client := openTestClient(t, startClientTestServer(t, server)) + client := openTestRuntime(t, startClientTestServer(t, server)) - output, err := client.Run(testClientContext(t), api.Source{Content: "RETURN 1"}, nil, RunOptions{}) + output, err := client.Run(testClientContext(t), api.Source{Content: "RETURN 1"}) var terminalFailure *failure.Failure if string(output.Content) != "partial" || !errors.As(err, &terminalFailure) || terminalFailure.Message != "execution failed" || - !strings.Contains(err.Error(), "execution cleanup failed") || !strings.Contains(err.Error(), "plan cleanup failed") { - t.Fatalf("Client.Run did not preserve all errors: %#v, %v", output, err) + !strings.Contains(err.Error(), "execution cleanup failed") { + t.Fatalf("Runtime.Run did not preserve all errors: %#v, %v", output, err) } _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() - if releaseExecutionCalls != 1 || releasePlanCalls != 1 { - t.Fatalf("failed Client.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) + if releaseExecutionCalls != 1 || releasePlanCalls != 0 { + t.Fatalf("failed Runtime.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) } }) } -func openTestClient(t *testing.T, connection grpc.ClientConnInterface) *Client { +func openTestClient(t *testing.T, connection grpc.ClientConnInterface) *connectionHandle { t.Helper() - client, err := New(testClientContext(t), connection) + client, err := newConnection(testClientContext(t), connection) if err != nil { t.Fatal(err) } @@ -395,6 +433,7 @@ func startClientTestServer(t *testing.T, implementation *clientTestServer) *grpc wirev1.RegisterRuntimeServiceServer(server, implementation) wirev1.RegisterPlanServiceServer(server, implementation) wirev1.RegisterExecutionServiceServer(server, implementation) + wirev1.RegisterSessionServiceServer(server, implementation) serveDone := make(chan error, 1) go func() { serveDone <- server.Serve(listener) }() @@ -430,3 +469,19 @@ func startClientTestServer(t *testing.T, implementation *clientTestServer) *grpc return connection } + +func openTestRuntime(t *testing.T, connection grpc.ClientConnInterface) api.Runtime { + t.Helper() + runtime, err := New(testClientContext(t), connection) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + if err := runtime.Close(); err != nil { + t.Errorf("runtime cleanup failed: %v", err) + } + }) + + return runtime +} diff --git a/client/compile.go b/client/compile.go index 0c1f1f8..059639e 100644 --- a/client/compile.go +++ b/client/compile.go @@ -8,26 +8,8 @@ import ( wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) -// Compile creates a connection-owned plan through the hosted runtime. -func (c *Client) Compile(ctx context.Context, src api.Source, options CompileOptions) (*Plan, error) { - if err := c.checkOpen(); err != nil { - return nil, err - } - - if err := ctx.Err(); err != nil { - return nil, err - } - - configured, err := applyRuntimePlanOptions(options.PlanOptions) - if err != nil { - return nil, err - } - - return c.compileConfigured(ctx, src, options.Debuggable, configured) -} - // compileConfigured shares allocation transport without reapplying option callbacks. -func (c *Client) compileConfigured(ctx context.Context, src api.Source, debuggable bool, configured runtimePlanOptions) (*Plan, error) { +func (c *connectionHandle) compileConfigured(ctx context.Context, src api.Source, debuggable bool, configured runtimePlanOptions) (*planHandle, error) { if err := c.checkOpen(); err != nil { return nil, err } @@ -70,11 +52,10 @@ func (c *Client) compileConfigured(ctx context.Context, src api.Source, debuggab return nil, &allocationError{cause: errors.New("Wire server returned an invalid compiled plan")} } - return &Plan{ + return &planHandle{ client: c, id: value.GetId().GetValue(), parameters: append([]string(nil), value.GetParameters()...), - debuggable: debuggable, close: &closeState{}, }, nil } diff --git a/client/compile_options.go b/client/compile_options.go index 01c84dd..5c1852d 100644 --- a/client/compile_options.go +++ b/client/compile_options.go @@ -7,16 +7,6 @@ import ( wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) -// CompileOptions controls runtime plan construction. -type CompileOptions struct { - Debuggable bool - - // PlanOptions are applied once, in order, before dispatch. Omitting an - // optimization option preserves the hosted runtime's default, while - // api.WithOptimizationLevel(api.OptimizationNone) explicitly disables it. - PlanOptions []api.PlanOption -} - func encodeCompileOptions(level api.OptimizationLevel, present bool) (*wirev1.CompileOptions, error) { if !present { return nil, nil diff --git a/client/constructor_test.go b/client/constructor_test.go new file mode 100644 index 0000000..d362f7d --- /dev/null +++ b/client/constructor_test.go @@ -0,0 +1,90 @@ +package client + +import ( + "context" + "errors" + "testing" + + "github.com/MontFerret/api" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestNewFailureReturnsNilRuntimeAndClosesHandshake(t *testing.T) { + for _, test := range []struct { + name string + handshake *wirev1.ConnectResponse + err error + }{ + {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"}}}, + } { + t.Run(test.name, func(t *testing.T) { + ended := make(chan struct{}) + server := &clientTestServer{handshake: test.handshake, connectErr: test.err, connectDone: ended} + connection := startClientTestServer(t, server) + ctx := testClientContext(t) + remote, err := New(ctx, connection) + if err == nil || remote != nil { + t.Fatalf("New returned %v, %v; want nil runtime and an error", remote, err) + } + + if test.err != nil { + var decoded *Error + if !errors.As(err, &decoded) || status.Code(err) != codes.Unavailable { + t.Fatalf("constructor lost RPC failure: %v", err) + } + } + + select { + case <-ended: + case <-ctx.Done(): + t.Fatal("failed constructor retained its Connect stream") + } + }) + } +} + +func TestNewBorrowsTransportAndDetachesConstructionContext(t *testing.T) { + server := &clientTestServer{watchScripts: []executionWatchScript{{events: []*wirev1.WatchExecutionResponse{ + executionCompletedEvent("execution-1", "text/plain", []byte("done")), + }}}} + connection := startClientTestServer(t, server) + ctx, cancel := context.WithCancel(testClientContext(t)) + defer cancel() + + remote, err := New(ctx, connection) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + if err := remote.Close(); err != nil { + t.Error(err) + } + }) + cancel() + + output, err := remote.Run(testClientContext(t), api.NewAnonymousSource("RETURN 1")) + if err != nil || string(output.Content) != "done" { + t.Fatalf("construction cancellation affected runtime: %v, %v", output, err) + } + + if err := remote.Close(); err != nil { + t.Fatal(err) + } + + other, err := New(testClientContext(t), connection) + if err != nil { + t.Fatalf("runtime Close affected borrowed transport: %v", err) + } + + if err := other.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/client/debug.go b/client/debug.go index 0534e81..56bc586 100644 --- a/client/debug.go +++ b/client/debug.go @@ -6,17 +6,17 @@ import ( wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) -// DebugSession is one remote Unified API debugger session owned by its Plan. -type DebugSession struct { - client *Client - plan *Plan +// debugSessionHandle is a remote debugger session owned by its plan. +type debugSessionHandle struct { + client *connectionHandle + plan *planHandle id string close *closeState } // Start begins a newly created debug session. Watch publishes the running and // subsequent stop or terminal snapshots. -func (d *DebugSession) Start(ctx context.Context) error { +func (d *debugSessionHandle) Start(ctx context.Context) error { if err := d.checkOpen(); err != nil { return err } @@ -29,7 +29,7 @@ func (d *DebugSession) Start(ctx context.Context) error { } // Continue resumes a stopped debug session. -func (d *DebugSession) Continue(ctx context.Context) error { +func (d *debugSessionHandle) Continue(ctx context.Context) error { if err := d.checkOpen(); err != nil { return err } @@ -42,7 +42,7 @@ func (d *DebugSession) Continue(ctx context.Context) error { } // Pause requests a pause from a running debug session. -func (d *DebugSession) Pause(ctx context.Context) error { +func (d *debugSessionHandle) Pause(ctx context.Context) error { if err := d.checkOpen(); err != nil { return err } @@ -55,7 +55,7 @@ func (d *DebugSession) Pause(ctx context.Context) error { } // StepOver resumes until the next statement without entering a called function. -func (d *DebugSession) StepOver(ctx context.Context) error { +func (d *debugSessionHandle) StepOver(ctx context.Context) error { if err := d.checkOpen(); err != nil { return err } @@ -69,7 +69,7 @@ func (d *DebugSession) StepOver(ctx context.Context) error { // StepIn resumes until the next statement, entering a called function when // applicable. -func (d *DebugSession) StepIn(ctx context.Context) error { +func (d *debugSessionHandle) StepIn(ctx context.Context) error { if err := d.checkOpen(); err != nil { return err } @@ -82,7 +82,7 @@ func (d *DebugSession) StepIn(ctx context.Context) error { } // StepOut resumes until execution leaves the current frame. -func (d *DebugSession) StepOut(ctx context.Context) error { +func (d *debugSessionHandle) StepOut(ctx context.Context) error { if err := d.checkOpen(); err != nil { return err } @@ -94,23 +94,9 @@ func (d *DebugSession) StepOut(ctx context.Context) error { return decodeError(err) } -// Stop terminates a non-terminal debug session without releasing its remote -// resource. Close performs the distinct release operation. -func (d *DebugSession) Stop(ctx context.Context) error { - if err := d.checkOpen(); err != nil { - return err - } - - _, err := d.client.debugClient.Terminate(ctx, &wirev1.TerminateRequest{ - ConnectionId: d.client.connectionProto(), DebugSessionId: &wirev1.DebugSessionId{Value: d.id}, - }) - - return decodeError(err) -} - -// Watch opens an ordered event stream tied to both ctx and the Client's -// logical lifecycle. It begins with the latest state published by the server. -func (d *DebugSession) Watch(ctx context.Context) (*DebugEvents, error) { +// Watch opens an ordered event stream tied to ctx and the logical connection. +// It begins with the latest state published by the server. +func (d *debugSessionHandle) Watch(ctx context.Context) (*debugEvents, error) { if err := d.checkOpen(); err != nil { return nil, err } @@ -125,12 +111,12 @@ func (d *DebugSession) Watch(ctx context.Context) (*DebugEvents, error) { return nil, decodeError(err) } - return &DebugEvents{stream: stream, cancel: cancel}, nil + return &debugEvents{stream: stream, cancel: cancel}, nil } // Close terminates and releases the remote debug session. Concurrent and // repeated calls observe one retained release result. -func (d *DebugSession) Close(ctx context.Context) error { +func (d *debugSessionHandle) Close(ctx context.Context) error { if d == nil || d.client == nil || d.plan == nil || d.id == "" || d.close == nil { return ErrClosed } @@ -142,7 +128,7 @@ func (d *DebugSession) Close(ctx context.Context) error { return d.close.Wait(ctx) } -func (d *DebugSession) checkOpen() error { +func (d *debugSessionHandle) checkOpen() error { if d == nil || d.client == nil || d.plan == nil || d.id == "" || d.close == nil || d.close.Started() { return ErrClosed } @@ -150,7 +136,7 @@ func (d *DebugSession) checkOpen() error { return d.plan.checkOpen() } -func (d *DebugSession) release(ctx context.Context) error { +func (d *debugSessionHandle) release(ctx context.Context) error { if closing, err := d.plan.ancestorCloseResult(ctx); closing { return err } diff --git a/client/debug_events.go b/client/debug_events.go index 44b7e44..338d849 100644 --- a/client/debug_events.go +++ b/client/debug_events.go @@ -9,16 +9,16 @@ import ( "github.com/MontFerret/wire/pkg/debugger" ) -// DebugEvents receives published debug snapshots until the terminal event or +// debugEvents receives published debug snapshots until the terminal event or // stream cancellation. -type DebugEvents struct { +type debugEvents struct { stream wirev1.DebugService_WatchDebugClient cancel context.CancelFunc } // Recv blocks for the next ordered debug event. It releases the local stream // when a terminal event or error is observed. -func (events *DebugEvents) Recv() (debugger.Event, error) { +func (events *debugEvents) Recv() (debugger.Event, error) { if events == nil || events.stream == nil { return debugger.Event{}, errors.New("debug event receiver is nil") } diff --git a/client/debug_inspection.go b/client/debug_inspection.go index b2a70cf..9a1f007 100644 --- a/client/debug_inspection.go +++ b/client/debug_inspection.go @@ -10,17 +10,9 @@ import ( wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) -// SetBreakpoint adds one runtime breakpoint. Line must be positive; column zero -// means unspecified. -func (d *DebugSession) SetBreakpoint(ctx context.Context, location source.Location) (debugger.Breakpoint, error) { - return d.SetBreakpointAt(ctx, location, debugger.BreakpointOptions{ - BindingMode: debugger.BreakpointBindNextExecutableInSource, - }) -} - // SetBreakpointAt adds one runtime breakpoint using the requested canonical // Unified API binding mode. -func (d *DebugSession) SetBreakpointAt( +func (d *debugSessionHandle) SetBreakpointAt( ctx context.Context, location source.Location, options debugger.BreakpointOptions, @@ -79,7 +71,7 @@ func breakpointBindingModeToProto(value debugger.BreakpointBindingMode) (wirev1. // DeleteBreakpoint removes one server-issued breakpoint from a created or // stopped session. -func (d *DebugSession) DeleteBreakpoint(ctx context.Context, breakpointID debugger.BreakpointID) error { +func (d *debugSessionHandle) DeleteBreakpoint(ctx context.Context, breakpointID debugger.BreakpointID) error { if err := d.checkOpen(); err != nil { return err } @@ -99,7 +91,7 @@ func (d *DebugSession) DeleteBreakpoint(ctx context.Context, breakpointID debugg // Frames returns the current paused frame followed by its callers. The slice // index is the frame index accepted by FrameLocals and EvaluateFrame. -func (d *DebugSession) Frames(ctx context.Context) ([]debugger.Frame, error) { +func (d *debugSessionHandle) Frames(ctx context.Context) ([]debugger.Frame, error) { if err := d.checkOpen(); err != nil { return nil, err } @@ -125,7 +117,7 @@ func (d *DebugSession) Frames(ctx context.Context) ([]debugger.Frame, error) { // FrameLocals returns runtime variables for a paused frame. Parameters are // identified by debugger.Variable.Param. -func (d *DebugSession) FrameLocals(ctx context.Context, frameIndex int) ([]debugger.Variable, error) { +func (d *debugSessionHandle) FrameLocals(ctx context.Context, frameIndex int) ([]debugger.Variable, error) { if err := d.checkOpen(); err != nil { return nil, err } @@ -155,7 +147,7 @@ func (d *DebugSession) FrameLocals(ctx context.Context, frameIndex int) ([]debug // Variables expands a non-zero debug value reference. References become stale // after every resume. -func (d *DebugSession) Variables(ctx context.Context, reference debugger.ValueReference) ([]debugger.Variable, error) { +func (d *debugSessionHandle) Variables(ctx context.Context, reference debugger.ValueReference) ([]debugger.Variable, error) { if err := d.checkOpen(); err != nil { return nil, err } @@ -184,7 +176,7 @@ func (d *DebugSession) Variables(ctx context.Context, reference debugger.ValueRe } // EvaluateFrame evaluates an FQL expression in one paused frame. -func (d *DebugSession) EvaluateFrame(ctx context.Context, frameIndex int, expression string) (debugger.Value, error) { +func (d *debugSessionHandle) EvaluateFrame(ctx context.Context, frameIndex int, expression string) (debugger.Value, error) { if err := d.checkOpen(); err != nil { return debugger.Value{}, err } diff --git a/client/debug_session_options.go b/client/debug_session_options.go deleted file mode 100644 index a743f02..0000000 --- a/client/debug_session_options.go +++ /dev/null @@ -1,6 +0,0 @@ -package client - -// DebugSessionOptions controls encoded debug completion output. -type DebugSessionOptions struct { - OutputContentType string -} diff --git a/client/debug_test.go b/client/debug_test.go index 7fcf0d6..ce1c62d 100644 --- a/client/debug_test.go +++ b/client/debug_test.go @@ -358,7 +358,7 @@ func TestDebugConversionsRejectMalformedTransportValues(t *testing.T) { func TestDebugEventsCancelWatchOnMalformedServerValue(t *testing.T) { cancelled := false - events := &DebugEvents{ + events := &debugEvents{ stream: &debugResponseStream{response: &wirev1.WatchDebugResponse{ Kind: wirev1.DebugEventKind_DEBUG_EVENT_KIND_STOPPED, Session: &wirev1.DebugSession{ diff --git a/client/doc.go b/client/doc.go index cbe55a7..9561eeb 100644 --- a/client/doc.go +++ b/client/doc.go @@ -1,16 +1,15 @@ -// Package client provides a domain-oriented Ferret Wire client over a -// caller-owned gRPC connection. +// Package client implements the Universal Ferret API over a caller-owned gRPC +// connection. New returns api.Runtime; plans, sessions, output, options, and +// debugger values use github.com/MontFerret/api and its canonical subpackages. // -// Runtime and Session alias the canonical github.com/MontFerret/api interfaces. -// Output aliases api.Output, defined by github.com/MontFerret/api/result. -// NewRuntime returns a private implementation over one logical Wire connection; -// its plan, normal-session, and debugger adapters also remain private. Client -// exposes the lower-level Plan, Execution, and DebugSession handles. Callers -// close resources explicitly, while protocol resource identifiers remain -// private to both facades. +// The construction context bounds the handshake, while the returned runtime +// owns the logical connection lifetime. Runtime.Run invokes the hosted runtime +// directly. Plans create durable sessions whose runs are sequential. Callers +// close resources explicitly; Close uses bounded detached cleanup and never +// closes the physical transport. // -// Runtime.Run invokes the hosted runtime directly. Client.Run is the lower-level -// one-shot composition: it compiles source, executes it, waits for encoded -// output, and releases the temporary resources. Callers that need Wire-specific -// events use the explicit Client, Plan, Execution, and DebugSession handles. +// Wire resource IDs, RPC clients, and watch streams remain private. Immediate +// remote failures expose Error, terminal failures use pkg/failure.Failure, and +// ErrClosed and ErrExecutionCancelled distinguish local closure and remote +// cancellation from the caller's context errors. package client diff --git a/client/errors.go b/client/errors.go index ab2de59..25ce169 100644 --- a/client/errors.go +++ b/client/errors.go @@ -22,12 +22,12 @@ type Error struct { } var ( - // ErrClosed reports an operation attempted through a closed Client or resource + // ErrClosed reports an operation attempted through a closed runtime or resource // handle. Closing begins when the first Close call commits teardown. ErrClosed = errors.New("Wire client or resource is closed") // ErrExecutionCancelled reports that a remote execution reached its cancelled - // terminal state. It is distinct from cancellation of a Wait caller's context. + // terminal state. It is distinct from cancellation of the caller's context. ErrExecutionCancelled = errors.New("remote execution was cancelled") ) diff --git a/client/execute_options.go b/client/execute_options.go deleted file mode 100644 index 116f24d..0000000 --- a/client/execute_options.go +++ /dev/null @@ -1,6 +0,0 @@ -package client - -// ExecuteOptions controls encoded execution output. -type ExecuteOptions struct { - OutputContentType string -} diff --git a/client/execution.go b/client/execution.go index e4f55d1..88c2bdd 100644 --- a/client/execution.go +++ b/client/execution.go @@ -4,57 +4,40 @@ import ( "context" "errors" + "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/pkg/execution" ) -// Execution is one asynchronous remote operation owned by a Client, Plan, or -// durable Session. -type Execution struct { - client *Client - plan *Plan +// executionHandle is one asynchronous operation owned by a logical connection +// or durable session. Plan ownership is reached through the session. +type executionHandle struct { + client *connectionHandle session *sessionHandle id string close *closeState } func newExecutionHandle( - client *Client, - plan *Plan, + client *connectionHandle, session *sessionHandle, value *wirev1.Execution, -) (*Execution, error) { +) (*executionHandle, error) { if value == nil || value.GetId().GetValue() == "" { return nil, &allocationError{cause: errors.New("Wire server returned an invalid execution")} } - return &Execution{ + return &executionHandle{ client: client, - plan: plan, session: session, id: value.GetId().GetValue(), close: &closeState{}, }, nil } -// Cancel requests execution cancellation. The ordered terminal cancellation -// snapshot remains observable through Watch. -func (e *Execution) Cancel(ctx context.Context) error { - if err := e.checkOpen(); err != nil { - return err - } - - _, err := e.client.executionClient.CancelExecution(ctx, &wirev1.CancelExecutionRequest{ - ConnectionId: e.client.connectionProto(), - ExecutionId: &wirev1.ExecutionId{Value: e.id}, - }) - - return decodeError(err) -} - -// Watch opens an ordered event stream tied to both ctx and the Client's -// logical lifecycle. Its first event contains the current remote snapshot. -func (e *Execution) Watch(ctx context.Context) (*ExecutionEvents, error) { +// Watch opens an ordered event stream tied to ctx and the logical connection. +// Its first event contains the current remote snapshot. +func (e *executionHandle) Watch(ctx context.Context) (*executionEvents, error) { if err := e.checkOpen(); err != nil { return nil, err } @@ -70,7 +53,7 @@ func (e *Execution) Watch(ctx context.Context) (*ExecutionEvents, error) { return nil, decodeError(err) } - return &ExecutionEvents{stream: stream, cancel: cancel}, nil + return &executionEvents{stream: stream, cancel: cancel}, nil } // Wait observes execution events until the remote execution reaches a terminal @@ -78,24 +61,24 @@ func (e *Execution) Watch(ctx context.Context) (*ExecutionEvents, error) { // returns ErrExecutionCancelled. Caller cancellation returns the waiting // context's error. Wait does not release the execution or retain mutable // snapshot state. -func (e *Execution) Wait(ctx context.Context) (Output, error) { +func (e *executionHandle) Wait(ctx context.Context) (api.Output, error) { events, err := e.Watch(ctx) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { - return Output{}, ctxErr + return api.Output{}, ctxErr } - return Output{}, err + return api.Output{}, err } for { event, receiveErr := events.Recv() if receiveErr != nil { if ctxErr := ctx.Err(); ctxErr != nil { - return Output{}, ctxErr + return api.Output{}, ctxErr } - return Output{}, receiveErr + return api.Output{}, receiveErr } if !event.Snapshot.State.Terminal() { @@ -106,7 +89,7 @@ func (e *Execution) Wait(ctx context.Context) (Output, error) { switch event.Snapshot.State { case execution.StateCompleted: if event.Snapshot.Output == nil { - return Output{}, errors.New("Wire server returned a completed execution without output") + return api.Output{}, errors.New("Wire server returned a completed execution without output") } return output, nil @@ -124,7 +107,7 @@ func (e *Execution) Wait(ctx context.Context) (Output, error) { // Close commits cancellation and remote execution cleanup. Concurrent and // repeated calls observe one retained release result. -func (e *Execution) Close(ctx context.Context) error { +func (e *executionHandle) Close(ctx context.Context) error { if e == nil || e.client == nil || e.id == "" || e.close == nil { return ErrClosed } @@ -136,7 +119,7 @@ func (e *Execution) Close(ctx context.Context) error { return e.close.Wait(ctx) } -func (e *Execution) checkOpen() error { +func (e *executionHandle) checkOpen() error { if e == nil || e.client == nil || e.id == "" || e.close == nil || e.close.Started() { return ErrClosed } @@ -145,22 +128,14 @@ func (e *Execution) checkOpen() error { return e.session.checkOpen() } - if e.plan == nil { - return e.client.checkOpen() - } - - return e.plan.checkOpen() + return e.client.checkOpen() } -func (e *Execution) release(ctx context.Context) error { +func (e *executionHandle) release(ctx context.Context) error { if e.session != nil { if closing, err := e.session.ancestorCloseResult(ctx); closing { return err } - } else if e.plan != nil { - if closing, err := e.plan.ancestorCloseResult(ctx); closing { - return err - } } else if closing, err := e.client.closeResult(ctx); closing { return err } @@ -179,11 +154,11 @@ func (e *Execution) release(ctx context.Context) error { // waitAndRelease is the adapter's one-shot invocation lifecycle. Release itself // cancels running work and waits for teardown; a separate Cancel RPC is redundant. -func (e *Execution) waitAndRelease(ctx context.Context) (Output, error) { +func (e *executionHandle) waitAndRelease(ctx context.Context) (api.Output, error) { output, waitErr := e.Wait(ctx) // The ID is known: a failed release is retained on this handle and does - // not invalidate its Session, Plan, or logical Runtime. + // not invalidate its session, plan, or logical runtime. closeErr := boundedCleanup(ctx, convenienceCleanupTimeout, e.Close) return output, errors.Join(waitErr, closeErr) diff --git a/client/execution_events.go b/client/execution_events.go index 4d3ec45..25215a0 100644 --- a/client/execution_events.go +++ b/client/execution_events.go @@ -9,16 +9,16 @@ import ( "github.com/MontFerret/wire/pkg/execution" ) -// ExecutionEvents receives the current execution snapshot followed by ordered +// executionEvents receives the current execution snapshot followed by ordered // state changes until the terminal event or stream cancellation. -type ExecutionEvents struct { +type executionEvents struct { stream wirev1.ExecutionService_WatchExecutionClient cancel context.CancelFunc } // Recv blocks for the next ordered execution event. It releases the local // stream when a terminal event or error is observed. -func (events *ExecutionEvents) Recv() (execution.Event, error) { +func (events *executionEvents) Recv() (execution.Event, error) { if events == nil || events.stream == nil { return execution.Event{}, errors.New("execution event receiver is nil") } diff --git a/client/execution_test.go b/client/execution_test.go index d8f15a4..411af7d 100644 --- a/client/execution_test.go +++ b/client/execution_test.go @@ -273,10 +273,10 @@ func TestExecutionWaitRejectsIncompleteTerminalSnapshots(t *testing.T) { } } -func openTestExecution(t *testing.T, server *clientTestServer) (*Client, *Plan, *Execution) { +func openTestExecution(t *testing.T, server *clientTestServer) (*connectionHandle, *planHandle, *executionHandle) { t.Helper() client := openTestClient(t, startClientTestServer(t, server)) - plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}, CompileOptions{}) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, false, runtimePlanOptions{}) if err != nil { t.Fatal(err) } @@ -286,7 +286,7 @@ func openTestExecution(t *testing.T, server *clientTestServer) (*Client, *Plan, } }) - execution, err := plan.Execute(testClientContext(t), nil, ExecuteOptions{}) + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) } diff --git a/client/handle_lifecycle_test.go b/client/handle_lifecycle_test.go index 2daba1f..a728222 100644 --- a/client/handle_lifecycle_test.go +++ b/client/handle_lifecycle_test.go @@ -23,11 +23,11 @@ func TestHandleCloseContinuesAfterFirstCallerCancellation(t *testing.T) { } connection := startHandleServer(t, implementation) client := openHandleClient(t, connection) - plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}, CompileOptions{}) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, false, runtimePlanOptions{}) if err != nil { t.Fatal(err) } - execution, err := plan.Execute(testClientContext(t), nil, ExecuteOptions{}) + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) } @@ -46,8 +46,8 @@ func TestHandleCloseContinuesAfterFirstCallerCancellation(t *testing.T) { if err := receiveCloseResult(t, first, "first execution close"); !errors.Is(err, context.Canceled) { t.Fatalf("first close did not stop waiting after cancellation: %v", err) } - if err := execution.Cancel(testClientContext(t)); !errors.Is(err, ErrClosed) { - t.Fatalf("closed execution accepted cancellation: %v", err) + if _, err := execution.Watch(testClientContext(t)); !errors.Is(err, ErrClosed) { + t.Fatalf("closed execution accepted a watch: %v", err) } second := make(chan error, 1) @@ -81,11 +81,11 @@ func TestConcurrentHandleCloseReleasesOnce(t *testing.T) { } connection := startHandleServer(t, implementation) client := openHandleClient(t, connection) - plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}, CompileOptions{}) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, false, runtimePlanOptions{}) if err != nil { t.Fatal(err) } - execution, err := plan.Execute(testClientContext(t), nil, ExecuteOptions{}) + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) } @@ -136,15 +136,15 @@ func TestDescendantCloseDuringAncestorCloseObservesRetainedResult(t *testing.T) } connection := startHandleServer(t, implementation) client := openHandleClient(t, connection) - plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}, CompileOptions{Debuggable: true}) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, true, runtimePlanOptions{}) if err != nil { t.Fatal(err) } - execution, err := plan.Execute(testClientContext(t), nil, ExecuteOptions{}) + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) } - debug, err := plan.NewDebugSession(testClientContext(t), nil, DebugSessionOptions{}) + debug, err := plan.NewDebugSession(testClientContext(t), runtimeSessionOptions{}) if err != nil { t.Fatal(err) } @@ -205,15 +205,15 @@ func TestDescendantCloseAfterAncestorCloseObservesRetainedResult(t *testing.T) { implementation := &handleServer{} connection := startHandleServer(t, implementation) client := openHandleClient(t, connection) - plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}, CompileOptions{Debuggable: true}) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, true, runtimePlanOptions{}) if err != nil { t.Fatal(err) } - execution, err := plan.Execute(testClientContext(t), nil, ExecuteOptions{}) + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) } - debug, err := plan.NewDebugSession(testClientContext(t), nil, DebugSessionOptions{}) + debug, err := plan.NewDebugSession(testClientContext(t), runtimeSessionOptions{}) if err != nil { t.Fatal(err) } @@ -221,7 +221,7 @@ func TestDescendantCloseAfterAncestorCloseObservesRetainedResult(t *testing.T) { if err := plan.Close(testClientContext(t)); err != nil { t.Fatal(err) } - if err := execution.Cancel(testClientContext(t)); !errors.Is(err, ErrClosed) { + if _, err := execution.Watch(testClientContext(t)); !errors.Is(err, ErrClosed) { t.Fatalf("execution survived plan close: %v", err) } if err := debug.Start(testClientContext(t)); !errors.Is(err, ErrClosed) { @@ -246,23 +246,23 @@ func TestDescendantCloseAfterAncestorCloseObservesRetainedResult(t *testing.T) { } func TestZeroValueHandlesAreClosed(t *testing.T) { - var plan Plan - if _, err := plan.Execute(testClientContext(t), nil, ExecuteOptions{}); !errors.Is(err, ErrClosed) { - t.Fatalf("zero plan accepted execution: %v", err) + var plan planHandle + if _, err := plan.newSession(testClientContext(t), runtimeSessionOptions{}); !errors.Is(err, ErrClosed) { + t.Fatalf("zero plan accepted session creation: %v", err) } if err := plan.Close(testClientContext(t)); !errors.Is(err, ErrClosed) { t.Fatalf("zero plan close was not closed: %v", err) } - var execution Execution - if err := execution.Cancel(testClientContext(t)); !errors.Is(err, ErrClosed) { - t.Fatalf("zero execution accepted cancellation: %v", err) + var execution executionHandle + if _, err := execution.Watch(testClientContext(t)); !errors.Is(err, ErrClosed) { + t.Fatalf("zero execution accepted a watch: %v", err) } if err := execution.Close(testClientContext(t)); !errors.Is(err, ErrClosed) { t.Fatalf("zero execution close was not closed: %v", err) } - var debug DebugSession + var debug debugSessionHandle if err := debug.Start(testClientContext(t)); !errors.Is(err, ErrClosed) { t.Fatalf("zero debug session accepted start: %v", err) } diff --git a/client/lifecycle_test.go b/client/lifecycle_test.go index a3aaf03..6aab804 100644 --- a/client/lifecycle_test.go +++ b/client/lifecycle_test.go @@ -121,7 +121,7 @@ func (s *lifecycleServer) WatchExecution(_ *wirev1.WatchExecutionRequest, stream func TestCloseAfterServerDisconnectTreatsMissingConnectionAsSettled(t *testing.T) { server := &lifecycleServer{disconnect: true} connection := startLifecycleServer(t, server) - client, err := New(testClientContext(t), connection) + client, err := newConnection(testClientContext(t), connection) if err != nil { t.Fatal(err) } @@ -144,12 +144,13 @@ func TestCloseAfterServerDisconnectTreatsMissingConnectionAsSettled(t *testing.T func TestCloseRejectsNewOperationsAndCancelsFacadeWatchers(t *testing.T) { server := &lifecycleServer{closeEntered: make(chan struct{}), allowClose: make(chan struct{})} connection := startLifecycleServer(t, server) - client, err := New(testClientContext(t), connection) + client, err := newConnection(testClientContext(t), connection) if err != nil { t.Fatal(err) } - plan := &Plan{client: client, id: "plan", close: &closeState{}} - execution := &Execution{client: client, plan: plan, id: "execution", close: &closeState{}} + plan := &planHandle{client: client, id: "plan", close: &closeState{}} + session := &sessionHandle{client: client, plan: plan, id: "session", close: &closeState{}} + execution := &executionHandle{client: client, session: session, id: "execution", close: &closeState{}} events, err := execution.Watch(testClientContext(t)) if err != nil { t.Fatal(err) @@ -168,7 +169,7 @@ func TestCloseRejectsNewOperationsAndCancelsFacadeWatchers(t *testing.T) { t.Fatal("client close did not reach the server") } - if _, err := client.Compile(context.Background(), api.Source{Content: "RETURN 1"}, CompileOptions{}); !errors.Is(err, ErrClosed) { + if _, err := client.compileConfigured(context.Background(), api.Source{Content: "RETURN 1"}, false, runtimePlanOptions{}); !errors.Is(err, ErrClosed) { t.Fatalf("client accepted a new operation after close started: %v", err) } close(server.allowClose) diff --git a/client/metadata.go b/client/metadata.go deleted file mode 100644 index a5a2bb2..0000000 --- a/client/metadata.go +++ /dev/null @@ -1,41 +0,0 @@ -package client - -import ( - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - "github.com/MontFerret/wire/pkg/execution" -) - -type ( - // Capabilities reports the operation families supported by the server. - Capabilities struct { - Execution bool - Debugging bool - Cancellation bool - } - - // RuntimeInfo is the immutable server metadata returned by the Connect handshake. - RuntimeInfo struct { - APIIdentity string - WireVersion string - FerretVersion string - RuntimeIdentity *execution.Identity - Capabilities Capabilities - } -) - -func convertRuntimeInfo(protocol *wirev1.ProtocolInfo, identity *wirev1.RuntimeIdentity) RuntimeInfo { - result := RuntimeInfo{ - APIIdentity: protocol.GetName(), - WireVersion: protocol.GetVersion(), - } - - if identity != nil { - result.RuntimeIdentity = &execution.Identity{ - Name: identity.GetName(), - Version: identity.GetVersion(), - InstanceID: identity.GetInstanceId(), - } - } - - return result -} diff --git a/client/metadata_test.go b/client/metadata_test.go deleted file mode 100644 index 8a0a0be..0000000 --- a/client/metadata_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package client - -import ( - "testing" - - "github.com/MontFerret/wire/pkg/execution" -) - -func TestRuntimeInfoReturnsDefensiveIdentityCopy(t *testing.T) { - client := &Client{info: RuntimeInfo{ - APIIdentity: "ferret.wire", - WireVersion: "v1", - RuntimeIdentity: &execution.Identity{Name: "host", Version: "1.0.0", InstanceID: "instance"}, - }} - - first := client.RuntimeInfo() - first.RuntimeIdentity.Name = "changed" - second := client.RuntimeInfo() - - if second.RuntimeIdentity == nil || second.RuntimeIdentity.Name != "host" || second.APIIdentity != "ferret.wire" || - second.WireVersion != "v1" || second.FerretVersion != "" || second.Capabilities != (Capabilities{}) { - t.Fatalf("RuntimeInfo returned mutable client metadata: %#v", second) - } -} diff --git a/client/ownership_test.go b/client/ownership_test.go index 5e4f2a3..20c3f4f 100644 --- a/client/ownership_test.go +++ b/client/ownership_test.go @@ -25,10 +25,12 @@ type handleServer struct { wirev1.UnimplementedRuntimeServiceServer wirev1.UnimplementedPlanServiceServer wirev1.UnimplementedExecutionServiceServer + wirev1.UnimplementedSessionServiceServer wirev1.UnimplementedDebugServiceServer mu sync.Mutex connections int + sessions int executions int calls []string releasePlanCalls int @@ -111,20 +113,31 @@ func (s *handleServer) ReleasePlan(_ context.Context, request *wirev1.ReleasePla return &wirev1.ReleasePlanResponse{}, nil } -func (s *handleServer) Execute(_ context.Context, request *wirev1.ExecuteRequest) (*wirev1.ExecuteResponse, error) { +func (s *handleServer) CreateSession(_ context.Context, request *wirev1.CreateSessionRequest) (*wirev1.CreateSessionResponse, error) { s.mu.Lock() - s.executions++ - id := fmt.Sprintf("execution-%d", s.executions) - s.calls = append(s.calls, call("execute", request.GetConnectionId().GetValue(), request.GetPlanId().GetValue())) - s.mu.Unlock() + defer s.mu.Unlock() + + s.sessions++ + s.calls = append(s.calls, call("new-session", request.GetConnectionId().GetValue(), request.GetPlanId().GetValue())) + id := fmt.Sprintf("session-%d", s.sessions) + + return &wirev1.CreateSessionResponse{Session: &wirev1.Session{Id: &wirev1.SessionId{Value: id}}}, nil +} + +func (s *handleServer) ReleaseSession(_ context.Context, request *wirev1.ReleaseSessionRequest) (*wirev1.ReleaseSessionResponse, error) { + s.record("release-session", request.GetConnectionId().GetValue(), request.GetSessionId().GetValue()) - return &wirev1.ExecuteResponse{Execution: executionProto(id)}, nil + return &wirev1.ReleaseSessionResponse{}, nil } -func (s *handleServer) CancelExecution(_ context.Context, request *wirev1.CancelExecutionRequest) (*wirev1.CancelExecutionResponse, error) { - s.record("cancel", request.GetConnectionId().GetValue(), request.GetExecutionId().GetValue()) +func (s *handleServer) RunSession(_ context.Context, request *wirev1.RunSessionRequest) (*wirev1.RunSessionResponse, error) { + s.mu.Lock() + s.executions++ + id := fmt.Sprintf("execution-%d", s.executions) + s.calls = append(s.calls, call("run-session", request.GetConnectionId().GetValue(), request.GetSessionId().GetValue())) + s.mu.Unlock() - return &wirev1.CancelExecutionResponse{}, nil + return &wirev1.RunSessionResponse{Execution: executionProto(id)}, nil } func (s *handleServer) ReleaseExecution(_ context.Context, request *wirev1.ReleaseExecutionRequest) (*wirev1.ReleaseExecutionResponse, error) { @@ -204,12 +217,6 @@ func (s *handleServer) StepOut(_ context.Context, request *wirev1.StepOutRequest return &wirev1.StepOutResponse{}, nil } -func (s *handleServer) Terminate(_ context.Context, request *wirev1.TerminateRequest) (*wirev1.TerminateResponse, error) { - s.record("stop", request.GetConnectionId().GetValue(), request.GetDebugSessionId().GetValue()) - - return &wirev1.TerminateResponse{}, nil -} - func (s *handleServer) SetBreakpoint(_ context.Context, request *wirev1.SetBreakpointRequest) (*wirev1.SetBreakpointResponse, error) { s.record("set-breakpoint", request.GetConnectionId().GetValue(), request.GetDebugSessionId().GetValue()) @@ -314,31 +321,28 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { first := openHandleClient(t, connection) second := openHandleClient(t, connection) - plan, err := first.Compile(testClientContext(t), api.Source{Content: "RETURN @input"}, CompileOptions{Debuggable: true}) + plan, err := first.compileConfigured(testClientContext(t), api.Source{Content: "RETURN @input"}, true, runtimePlanOptions{}) if err != nil { t.Fatal(err) } parameters := plan.Parameters() parameters[0] = "changed" - if got := plan.Parameters(); !slices.Equal(got, []string{"input"}) || !plan.Debuggable() { - t.Fatalf("plan metadata was not immutable: %v, %v", got, plan.Debuggable()) + if got := plan.Parameters(); !slices.Equal(got, []string{"input"}) { + t.Fatalf("plan metadata was not immutable: %v", got) } if err := second.Close(testClientContext(t)); err != nil { t.Fatal(err) } - firstExecution, err := plan.Execute(testClientContext(t), Parameters{"input": 1}, ExecuteOptions{}) + firstExecution, err := startTestPlanExecution(testClientContext(t), plan, map[string]any{"input": 1}) if err != nil { t.Fatal(err) } - secondExecution, err := plan.Execute(testClientContext(t), Parameters{"input": 2}, ExecuteOptions{}) + secondExecution, err := startTestPlanExecution(testClientContext(t), plan, map[string]any{"input": 2}) if err != nil { t.Fatal(err) } - if err := firstExecution.Cancel(testClientContext(t)); err != nil { - t.Fatal(err) - } executionEvents, err := firstExecution.Watch(testClientContext(t)) if err != nil { t.Fatal(err) @@ -347,23 +351,23 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { t.Fatalf("unexpected execution event: %#v, %v", event, err) } - debug, err := plan.NewDebugSession(testClientContext(t), nil, DebugSessionOptions{}) + debug, err := plan.NewDebugSession(testClientContext(t), runtimeSessionOptions{}) if err != nil { t.Fatal(err) } for name, command := range map[string]func(context.Context) error{ "start": debug.Start, "continue": debug.Continue, "pause": debug.Pause, - "step-over": debug.StepOver, "step-in": debug.StepIn, "step-out": debug.StepOut, "stop": debug.Stop, + "step-over": debug.StepOver, "step-in": debug.StepIn, "step-out": debug.StepOut, } { if err := command(testClientContext(t)); err != nil { t.Fatalf("%s failed: %v", name, err) } } - breakpoint, err := debug.SetBreakpoint(testClientContext(t), source.Location{ + breakpoint, err := debug.SetBreakpointAt(testClientContext(t), source.Location{ Position: source.Position{Line: 1}, SourceName: "query.fql", - }) + }, debugger.BreakpointOptions{BindingMode: debugger.BreakpointBindNextExecutableInSource}) if err != nil { t.Fatal(err) } @@ -425,14 +429,13 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { want := []string{ call("compile", "connection-1", ""), - call("execute", "connection-1", "plan-connection-1"), - call("execute", "connection-1", "plan-connection-1"), - call("cancel", "connection-1", "execution-1"), + call("run-session", "connection-1", "session-1"), + call("run-session", "connection-1", "session-2"), call("watch-execution", "connection-1", "execution-1"), call("new-debug", "connection-1", "plan-connection-1"), } debugID := "debug-connection-1" - for _, name := range []string{"start", "continue", "pause", "step-over", "step-in", "step-out", "stop", "set-breakpoint", "delete-breakpoint", "frames", "frame-locals", "variables", "evaluate", "watch-debug", "release-debug"} { + for _, name := range []string{"start", "continue", "pause", "step-over", "step-in", "step-out", "set-breakpoint", "delete-breakpoint", "frames", "frame-locals", "variables", "evaluate", "watch-debug", "release-debug"} { want = append(want, call(name, "connection-1", debugID)) } want = append(want, @@ -458,9 +461,9 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { } } -func openHandleClient(t *testing.T, connection grpc.ClientConnInterface) *Client { +func openHandleClient(t *testing.T, connection grpc.ClientConnInterface) *connectionHandle { t.Helper() - client, err := New(testClientContext(t), connection) + client, err := newConnection(testClientContext(t), connection) if err != nil { t.Fatal(err) } @@ -480,6 +483,7 @@ func startHandleServer(t *testing.T, implementation *handleServer) *grpc.ClientC wirev1.RegisterRuntimeServiceServer(server, implementation) wirev1.RegisterPlanServiceServer(server, implementation) wirev1.RegisterExecutionServiceServer(server, implementation) + wirev1.RegisterSessionServiceServer(server, implementation) wirev1.RegisterDebugServiceServer(server, implementation) serveDone := make(chan error, 1) go func() { serveDone <- server.Serve(listener) }() @@ -527,3 +531,14 @@ func countCall(calls []string, target string) int { return count } + +// startTestPlanExecution creates the private session/execution subtree exercised +// by handle ownership tests. The fixture's plan or connection owns final cleanup. +func startTestPlanExecution(ctx context.Context, plan *planHandle, parameters map[string]any) (*executionHandle, error) { + session, err := plan.newSession(ctx, runtimeSessionOptions{parameters: parameters}) + if err != nil { + return nil, err + } + + return session.run(ctx) +} diff --git a/client/params.go b/client/params.go index 68aace1..6fc67c5 100644 --- a/client/params.go +++ b/client/params.go @@ -10,10 +10,6 @@ import ( const maxParameterDepth = 64 -// Parameters is the explicit Wire parameter model accepted by Plan.Execute -// and Plan.NewDebugSession. Unsupported Go values are rejected locally. -type Parameters map[string]any - func encodeParameters(values map[string]any) (*wirev1.Parameters, error) { result := &wirev1.Parameters{Values: make(map[string]*wirev1.Value, len(values))} for name, value := range values { diff --git a/client/plan.go b/client/plan.go index 6cc7e05..658e5d4 100644 --- a/client/plan.go +++ b/client/plan.go @@ -7,42 +7,16 @@ import ( wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) -// Plan is a compiled remote runtime plan owned by one Client. -type Plan struct { - client *Client +// planHandle is a compiled remote runtime plan owned by one connectionHandle. +type planHandle struct { + client *connectionHandle id string parameters []string - debuggable bool close *closeState } -// Execute publishes a remote execution of this plan. Output remains the Unified API -// encoded content-type and byte contract. -func (p *Plan) Execute(ctx context.Context, parameters Parameters, options ExecuteOptions) (*Execution, error) { - if err := p.checkOpen(); err != nil { - return nil, err - } - - converted, err := encodeParameters(parameters) - if err != nil { - return nil, err - } - - response, err := p.client.executionClient.Execute(ctx, &wirev1.ExecuteRequest{ - ConnectionId: p.client.connectionProto(), - PlanId: &wirev1.PlanId{Value: p.id}, - Parameters: converted, - OutputContentType: options.OutputContentType, - }) - if err != nil { - return nil, decodeError(err) - } - - return newExecutionHandle(p.client, p, nil, response.GetExecution()) -} - // Parameters returns a copy of the FQL parameters declared by this plan. -func (p *Plan) Parameters() []string { +func (p *planHandle) Parameters() []string { if p == nil { return nil } @@ -50,34 +24,14 @@ func (p *Plan) Parameters() []string { return append([]string(nil), p.parameters...) } -// Debuggable reports whether this plan was compiled for debugging. -func (p *Plan) Debuggable() bool { - return p != nil && p.debuggable -} - -// Run executes the plan once, waits for encoded output, and releases the -// execution it creates. Execution and release errors are joined. The caller -// retains ownership of the Plan. -func (p *Plan) Run(ctx context.Context, parameters Parameters, options ExecuteOptions) (Output, error) { - execution, err := p.Execute(ctx, parameters, options) - if err != nil { - return Output{}, err - } - - output, waitErr := execution.Wait(ctx) - closeErr := boundedCleanup(ctx, convenienceCleanupTimeout, execution.Close) - - return output, errors.Join(waitErr, closeErr) -} - // NewDebugSession creates a Unified API debug session for a plan compiled with -// CompileOptions.Debuggable. -func (p *Plan) NewDebugSession(ctx context.Context, parameters Parameters, options DebugSessionOptions) (*DebugSession, error) { +// CompileDebug. +func (p *planHandle) NewDebugSession(ctx context.Context, configured runtimeSessionOptions) (*debugSessionHandle, error) { if err := p.checkOpen(); err != nil { return nil, err } - converted, err := encodeParameters(parameters) + converted, err := encodeParameters(configured.parameters) if err != nil { return nil, err } @@ -86,7 +40,7 @@ func (p *Plan) NewDebugSession(ctx context.Context, parameters Parameters, optio ConnectionId: p.client.connectionProto(), PlanId: &wirev1.PlanId{Value: p.id}, Parameters: converted, - OutputContentType: options.OutputContentType, + OutputContentType: configured.outputContentType, }) if err != nil { return nil, allocationRPCError(err) @@ -97,12 +51,12 @@ func (p *Plan) NewDebugSession(ctx context.Context, parameters Parameters, optio return nil, &allocationError{cause: errors.New("Wire server returned an invalid debug session")} } - return &DebugSession{client: p.client, plan: p, id: value.GetId().GetValue(), close: &closeState{}}, nil + return &debugSessionHandle{client: p.client, plan: p, id: value.GetId().GetValue(), close: &closeState{}}, nil } // Close releases the plan and its remote sessions, executions, and debug sessions. // Concurrent and repeated calls observe one retained release result. -func (p *Plan) Close(ctx context.Context) error { +func (p *planHandle) Close(ctx context.Context) error { if p == nil || p.client == nil || p.id == "" || p.close == nil { return ErrClosed } @@ -114,7 +68,7 @@ func (p *Plan) Close(ctx context.Context) error { return p.close.Wait(ctx) } -func (p *Plan) checkOpen() error { +func (p *planHandle) checkOpen() error { if p == nil || p.client == nil || p.id == "" || p.close == nil || p.close.Started() { return ErrClosed } @@ -122,7 +76,7 @@ func (p *Plan) checkOpen() error { return p.client.checkOpen() } -func (p *Plan) ancestorCloseResult(ctx context.Context) (bool, error) { +func (p *planHandle) ancestorCloseResult(ctx context.Context) (bool, error) { if p == nil || p.client == nil || p.close == nil { return true, ErrClosed } @@ -134,7 +88,7 @@ func (p *Plan) ancestorCloseResult(ctx context.Context) (bool, error) { return p.client.closeResult(ctx) } -func (p *Plan) release(ctx context.Context) error { +func (p *planHandle) release(ctx context.Context) error { if closing, err := p.client.closeResult(ctx); closing { return err } @@ -151,16 +105,15 @@ func (p *Plan) release(ctx context.Context) error { return decodeError(err) } -func (p *Plan) newSession( +func (p *planHandle) newSession( ctx context.Context, - parameters Parameters, - options ExecuteOptions, + configured runtimeSessionOptions, ) (*sessionHandle, error) { if err := p.checkOpen(); err != nil { return nil, err } - converted, err := encodeParameters(parameters) + converted, err := encodeParameters(configured.parameters) if err != nil { return nil, err } @@ -169,7 +122,7 @@ func (p *Plan) newSession( ConnectionId: p.client.connectionProto(), PlanId: &wirev1.PlanId{Value: p.id}, Parameters: converted, - OutputContentType: options.OutputContentType, + OutputContentType: configured.outputContentType, }) if err != nil { return nil, allocationRPCError(err) diff --git a/client/plan_test.go b/client/plan_test.go index 6e1b400..fcec252 100644 --- a/client/plan_test.go +++ b/client/plan_test.go @@ -13,15 +13,15 @@ import ( func TestCompileMapsCanonicalSource(t *testing.T) { server := &clientTestServer{} - client := openTestClient(t, startClientTestServer(t, server)) + client := openTestRuntime(t, startClientTestServer(t, server)) src := api.Source{Name: "query.fql", Content: "RETURN 1"} - plan, err := client.Compile(testClientContext(t), src, CompileOptions{}) + plan, err := client.Compile(testClientContext(t), src) if err != nil { t.Fatal(err) } defer func() { - if err := plan.Close(testClientContext(t)); err != nil { + if err := plan.Close(); err != nil { t.Errorf("close plan: %v", err) } }() @@ -35,41 +35,46 @@ func TestCompileMapsCanonicalSource(t *testing.T) { } } -func TestPlanRunOwnsOnlyItsExecution(t *testing.T) { +func TestSessionRunPreservesCallerOwnedPlan(t *testing.T) { t.Run("success", func(t *testing.T) { server := &clientTestServer{watchScripts: []executionWatchScript{{events: []*wirev1.WatchExecutionResponse{ executionStartedEvent("execution-1"), executionCompletedEvent("execution-1", "text/plain", []byte("done")), }}}} - client := openTestClient(t, startClientTestServer(t, server)) - plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}, CompileOptions{}) + client := openTestRuntime(t, startClientTestServer(t, server)) + plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) } - output, err := plan.Run(testClientContext(t), nil, ExecuteOptions{}) + session, err := plan.NewSession(testClientContext(t)) + if err != nil { + t.Fatal(err) + } + + output, err := session.Run(testClientContext(t)) if err != nil || string(output.Content) != "done" { - t.Fatalf("unexpected Plan.Run result: %#v, %v", output, err) + t.Fatalf("unexpected Session.Run result: %#v, %v", output, err) } _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() if releaseExecutionCalls != 1 || releasePlanCalls != 0 { - t.Fatalf("Plan.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) + t.Fatalf("Session.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) } executionDeadline, planDeadline := server.releaseDeadlineSnapshot() assertCleanupDeadline(t, "execution", executionDeadline) if !planDeadline.IsZero() { - t.Fatalf("Plan.Run released the caller-owned plan with deadline %v", planDeadline) + t.Fatalf("Session.Run released the caller-owned plan with deadline %v", planDeadline) } - extra, err := plan.Execute(testClientContext(t), nil, ExecuteOptions{}) + extra, err := plan.NewSession(testClientContext(t)) if err != nil { - t.Fatalf("Plan.Run closed the caller-owned plan: %v", err) + t.Fatalf("Session.Run closed the caller-owned plan: %v", err) } - if err := plan.Close(testClientContext(t)); err != nil { + if err := plan.Close(); err != nil { t.Fatal(err) } - if err := extra.Close(testClientContext(t)); err != nil { + if err := extra.Close(); err != nil { t.Fatal(err) } }) @@ -84,24 +89,29 @@ func TestPlanRunOwnsOnlyItsExecution(t *testing.T) { }}}, releaseExecutionErr: status.Error(codes.Internal, "execution cleanup failed"), } - client := openTestClient(t, startClientTestServer(t, server)) - plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}, CompileOptions{}) + client := openTestRuntime(t, startClientTestServer(t, server)) + plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}) + if err != nil { + t.Fatal(err) + } + + session, err := plan.NewSession(testClientContext(t)) if err != nil { t.Fatal(err) } - output, err := plan.Run(testClientContext(t), nil, ExecuteOptions{}) + output, err := session.Run(testClientContext(t)) var terminalFailure *failure.Failure var wireErr *Error if string(output.Content) != "partial" || !errors.As(err, &terminalFailure) || !errors.As(err, &wireErr) || terminalFailure.Message != "execution failed" || wireErr.Message != "execution cleanup failed" { - t.Fatalf("Plan.Run did not preserve joined errors: %#v, %v", output, err) + t.Fatalf("Session.Run did not preserve joined errors: %#v, %v", output, err) } _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() if releaseExecutionCalls != 1 || releasePlanCalls != 0 { - t.Fatalf("Plan.Run failure cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) + t.Fatalf("Session.Run failure cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) } - if err := plan.Close(testClientContext(t)); err != nil { + if err := plan.Close(); err != nil { t.Fatal(err) } }) diff --git a/client/remote_debug_session.go b/client/remote_debug_session.go index 88d58df..19a6d3d 100644 --- a/client/remote_debug_session.go +++ b/client/remote_debug_session.go @@ -11,7 +11,7 @@ import ( ) type remoteDebugSession struct { - session *DebugSession + session *debugSessionHandle ctx context.Context cancel context.CancelFunc @@ -22,7 +22,7 @@ type remoteDebugSession struct { var _ debugger.Session = (*remoteDebugSession)(nil) -func newRemoteDebugSession(session *DebugSession) *remoteDebugSession { +func newRemoteDebugSession(session *debugSessionHandle) *remoteDebugSession { ctx, cancel := context.WithCancel(context.Background()) return &remoteDebugSession{ diff --git a/client/remote_plan.go b/client/remote_plan.go index 2645425..0c546e9 100644 --- a/client/remote_plan.go +++ b/client/remote_plan.go @@ -9,7 +9,7 @@ import ( ) type remotePlan struct { - plan *Plan + plan *planHandle } var _ api.Plan = (*remotePlan)(nil) @@ -22,7 +22,7 @@ func (p *remotePlan) Params() []string { return p.plan.Parameters() } -func (p *remotePlan) NewSession(ctx context.Context, options ...api.SessionOption) (Session, error) { +func (p *remotePlan) NewSession(ctx context.Context, options ...api.SessionOption) (api.Session, error) { if p == nil || p.plan == nil { return nil, ErrClosed } @@ -41,9 +41,7 @@ func (p *remotePlan) NewSession(ctx context.Context, options ...api.SessionOptio return nil, err } - session, err := p.plan.newSession(creationCtx, configured.parameters, ExecuteOptions{ - OutputContentType: configured.outputContentType, - }) + session, err := p.plan.newSession(creationCtx, configured) cancel() if err != nil { return nil, p.plan.client.reclaimAllocation(ctx, err, p.plan.Close) @@ -80,9 +78,7 @@ func (p *remotePlan) NewDebugSession( return nil, err } - session, err := p.plan.NewDebugSession(creationCtx, configured.parameters, DebugSessionOptions{ - OutputContentType: configured.outputContentType, - }) + session, err := p.plan.NewDebugSession(creationCtx, configured) cancel() if err != nil { return nil, p.plan.client.reclaimAllocation(ctx, err, p.plan.Close) diff --git a/client/remote_session.go b/client/remote_session.go index ed09a27..c8a6981 100644 --- a/client/remote_session.go +++ b/client/remote_session.go @@ -12,24 +12,24 @@ type remoteSession struct { var _ api.Session = (*remoteSession)(nil) -func (s *remoteSession) Run(ctx context.Context) (Output, error) { +func (s *remoteSession) Run(ctx context.Context) (api.Output, error) { if s == nil || s.session == nil { - return Output{}, ErrClosed + return api.Output{}, ErrClosed } if err := ctx.Err(); err != nil { - return Output{}, err + return api.Output{}, err } creationCtx, cancel, err := runtimeAllocationContext(ctx) if err != nil { - return Output{}, err + return api.Output{}, err } execution, err := s.session.run(creationCtx) cancel() if err != nil { - return Output{}, s.session.client.reclaimAllocation(ctx, err, s.session.Close, s.session.plan.Close) + return api.Output{}, s.session.client.reclaimAllocation(ctx, err, s.session.Close, s.session.plan.Close) } return execution.waitAndRelease(ctx) diff --git a/client/run_options.go b/client/run_options.go deleted file mode 100644 index 368de7c..0000000 --- a/client/run_options.go +++ /dev/null @@ -1,9 +0,0 @@ -package client - -// RunOptions composes plan compilation and execution options for Client.Run. -type RunOptions struct { - // Compile controls construction of the temporary plan. - Compile CompileOptions - // Execute controls the temporary execution and its encoded output. - Execute ExecuteOptions -} diff --git a/client/runtime.go b/client/runtime.go index 6965916..6d603a7 100644 --- a/client/runtime.go +++ b/client/runtime.go @@ -11,17 +11,18 @@ import ( // remoteRuntime is a remote implementation of the Universal Ferret API. It owns // one logical Wire client and borrows the caller's gRPC transport. type remoteRuntime struct { - client *Client + client *connectionHandle } var _ api.Runtime = (*remoteRuntime)(nil) -// NewRuntime opens a logical Wire connection and exposes it through the -// canonical api.Runtime interface. The private adapter releases its resources -// with bounded detached cleanup; closing it never closes connection. -// On failure, NewRuntime returns a nil Runtime and the connection error. -func NewRuntime(ctx context.Context, connection grpc.ClientConnInterface) (Runtime, error) { - wireClient, err := New(ctx, connection) +// 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 +// 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) if err != nil { return nil, err } @@ -29,44 +30,42 @@ func NewRuntime(ctx context.Context, connection grpc.ClientConnInterface) (Runti return &remoteRuntime{client: wireClient}, nil } -// Run invokes the hosted Runtime.Run operation once and releases the temporary +// 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, error) { +func (r *remoteRuntime) Run(ctx context.Context, src api.Source, options ...api.SessionOption) (api.Output, error) { if r == nil || r.client == nil { - return Output{}, ErrClosed + return api.Output{}, ErrClosed } if err := ctx.Err(); err != nil { - return Output{}, err + return api.Output{}, err } configured, err := applyRuntimeSessionOptions(options) if err != nil { - return Output{}, err + return api.Output{}, err } creationCtx, cancel, err := runtimeAllocationContext(ctx) if err != nil { - return Output{}, err + return api.Output{}, err } - execution, err := r.client.run(creationCtx, src, configured.parameters, ExecuteOptions{ - OutputContentType: configured.outputContentType, - }) + execution, err := r.client.run(creationCtx, src, configured) cancel() if err != nil { - return Output{}, r.client.reclaimAllocation(ctx, err) + return api.Output{}, r.client.reclaimAllocation(ctx, err) } return execution.waitAndRelease(ctx) } -// Compile creates a reusable remote Universal API Plan. +// Compile creates a reusable remote Universal API plan. func (r *remoteRuntime) Compile(ctx context.Context, src api.Source, options ...api.PlanOption) (api.Plan, error) { return r.compile(ctx, src, false, options) } -// CompileDebug creates a reusable remote Plan with debugger metadata. +// CompileDebug creates a reusable remote plan with debugger metadata. func (r *remoteRuntime) CompileDebug(ctx context.Context, src api.Source, options ...api.PlanOption) (api.Plan, error) { return r.compile(ctx, src, true, options) } diff --git a/client/runtime_allocation.go b/client/runtime_allocation.go index a0919e9..8bab3ff 100644 --- a/client/runtime_allocation.go +++ b/client/runtime_allocation.go @@ -11,7 +11,7 @@ import ( // The logical connection is the final owner, and its Connect stream supplies // the lifetime signal when connection release cannot be acknowledged. // Known-handle release failures must never enter this allocation-only path. -func (c *Client) reclaimAllocation(ctx context.Context, err error, parents ...func(context.Context) error) error { +func (c *connectionHandle) reclaimAllocation(ctx context.Context, err error, parents ...func(context.Context) error) error { var uncertain *allocationError if !errors.As(err, &uncertain) { return errors.Join(ctx.Err(), err) diff --git a/client/runtime_contract_test.go b/client/runtime_contract_test.go index ac6edf2..7308a53 100644 --- a/client/runtime_contract_test.go +++ b/client/runtime_contract_test.go @@ -2,20 +2,58 @@ package client_test import ( "context" + "go/ast" + "go/parser" + "go/token" + "os" + "slices" + "strings" + "testing" "github.com/MontFerret/api" - "github.com/MontFerret/api/result" "github.com/MontFerret/wire/client" "google.golang.org/grpc" ) -// Function types require identical parameter and result types, so these checks -// reject new defined types even when they satisfy the same interfaces. -var ( - _ func() client.Runtime = (func() api.Runtime)(nil) - _ func() client.Session = (func() api.Session)(nil) - _ func() client.Output = (func() api.Output)(nil) - _ func() client.Output = (func() result.Output)(nil) +// The constructor returns the canonical interface, including a nil interface +// on failure, without requiring a Wire resource type in consumer code. +var _ func(context.Context, grpc.ClientConnInterface) (api.Runtime, error) = client.New - _ func(context.Context, grpc.ClientConnInterface) (client.Runtime, error) = client.NewRuntime -) +func TestNewRejectsMissingTransport(t *testing.T) { + remote, err := client.New(t.Context(), nil) + if err == nil || remote != nil { + t.Fatalf("New(nil) = %v, %v; want nil runtime and an error", remote, err) + } +} + +func TestPublicSurface(t *testing.T) { + // go test runs in the package directory, including when built with -trimpath. + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + + var exported []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + + file, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, 0) + if err != nil { + t.Fatal(err) + } + + for name := range file.Scope.Objects { + if ast.IsExported(name) { + exported = append(exported, name) + } + } + } + + slices.Sort(exported) + want := []string{"ErrClosed", "ErrExecutionCancelled", "Error", "New"} + if !slices.Equal(exported, want) { + t.Fatalf("public client surface = %v, want %v", exported, want) + } +} diff --git a/client/runtime_session_options.go b/client/runtime_session_options.go index ff83e15..ef7a34c 100644 --- a/client/runtime_session_options.go +++ b/client/runtime_session_options.go @@ -7,12 +7,12 @@ import ( ) type runtimeSessionOptions struct { - parameters Parameters + parameters map[string]any outputContentType string } func applyRuntimeSessionOptions(options []api.SessionOption) (runtimeSessionOptions, error) { - configured := runtimeSessionOptions{parameters: make(Parameters)} + configured := runtimeSessionOptions{parameters: make(map[string]any)} var result error for _, option := range options { if option == nil { diff --git a/client/runtime_transport.go b/client/runtime_transport.go index fd734c9..4fbcf54 100644 --- a/client/runtime_transport.go +++ b/client/runtime_transport.go @@ -7,17 +7,16 @@ import ( wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) -func (c *Client) run( +func (c *connectionHandle) run( ctx context.Context, src api.Source, - parameters Parameters, - options ExecuteOptions, -) (*Execution, error) { + configured runtimeSessionOptions, +) (*executionHandle, error) { if err := c.checkOpen(); err != nil { return nil, err } - converted, err := encodeParameters(parameters) + converted, err := encodeParameters(configured.parameters) if err != nil { return nil, err } @@ -26,11 +25,11 @@ func (c *Client) run( ConnectionId: c.connectionProto(), Source: &wirev1.Source{Name: src.Name, Content: src.Content}, Parameters: converted, - OutputContentType: options.OutputContentType, + OutputContentType: configured.outputContentType, }) if err != nil { return nil, allocationRPCError(err) } - return newExecutionHandle(c, nil, nil, response.GetExecution()) + return newExecutionHandle(c, nil, response.GetExecution()) } diff --git a/client/session_handle.go b/client/session_handle.go index 1a76196..abcab14 100644 --- a/client/session_handle.go +++ b/client/session_handle.go @@ -8,13 +8,13 @@ import ( // sessionHandle is the private Wire handle for one durable Unified API session. type sessionHandle struct { - client *Client - plan *Plan + client *connectionHandle + plan *planHandle id string close *closeState } -func (s *sessionHandle) run(ctx context.Context) (*Execution, error) { +func (s *sessionHandle) run(ctx context.Context) (*executionHandle, error) { if err := s.checkOpen(); err != nil { return nil, err } @@ -27,7 +27,7 @@ func (s *sessionHandle) run(ctx context.Context) (*Execution, error) { return nil, allocationRPCError(err) } - return newExecutionHandle(s.client, s.plan, s, response.GetExecution()) + return newExecutionHandle(s.client, s, response.GetExecution()) } func (s *sessionHandle) Close(ctx context.Context) error { diff --git a/docs/architecture.md b/docs/architecture.md index 3e6de63..6472763 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,13 +32,16 @@ Runtime implementations must never depend on Wire. Wire must not absorb DAP, LSP transport-security, or host-configuration semantics for downstream convenience. -The public `client.Runtime`, `client.Session`, and `client.Output` names are -convenience aliases of `api.Runtime`, `api.Session`, and `api.Output`; -`server.Runtime` also aliases `api.Runtime`. Output's canonical definition is -`api/result.Output`. Aliases preserve type identity and leave semantic ownership -with the API. `client.NewRuntime` returns the canonical runtime interface backed -by a private Wire adapter. The existing lower-level `client.Plan` and -`client.DebugSession` handles retain their separate Wire lifecycle contracts. +`client.New(ctx, conn)` returns the canonical `api.Runtime` interface. +Private adapters implement `api.Plan`, `api.Session`, and `api/debugger.Session`; +output is `api.Output`, whose definition belongs to `api/result`. The client +does not re-export aliases or expose a second resource or event model. +Its logical connection, allocation handles, RPC clients, and watches remain +private within the owning client package. + +The caller supplies and owns the physical transport. Runtime and resource +`Close` methods release logical resources with bounded detached cleanup. +`server.Runtime` continues to alias `api.Runtime`; host ownership is unchanged. ## gRPC service composition diff --git a/docs/client.md b/docs/client.md index 708a14a..242a81f 100644 --- a/docs/client.md +++ b/docs/client.md @@ -1,147 +1,112 @@ -# Client Handles +# Client API -The handwritten `client` package is a domain facade over the generated gRPC -clients. It owns one logical Wire connection while borrowing the caller's -`grpc.ClientConnInterface`. +`client.New(ctx, conn)` returns a remote implementation of `api.Runtime`. +The caller configures the endpoint, credentials, TLS, dialer, and transport +limits on the supplied `grpc.ClientConnInterface` and retains its ownership. +There is no second handwritten Wire resource API. ## Resource model -Remote resources are exposed as opaque, typed handles: - ```text -Runtime (= api.Runtime; private Wire implementation) -└── private Universal API adapters - ├── api.Plan - │ ├── Session (= api.Session) - │ └── debugger.Session - └── temporary Execution handles - -Client (lower-level Wire facade) -└── Plan - ├── Execution - └── DebugSession +api.Runtime +└── api.Plan + ├── api.Session + └── api/debugger.Session ``` -`NewRuntime(ctx, conn)` is the Universal API-first entry point. It returns -`Runtime`, an alias of `api.Runtime`, backed by a private `remoteRuntime` that -owns one logical `Client`. The returned Plan, normal Session, and debugger -adapters also remain private behind their Universal API interfaces. -The runtime adapter's `Run` uses `RuntimeService.Run` to call the hosted -`api.Runtime.Run` directly. A normal Session is created remotely once and each sequential `Run` uses a hidden -Execution that is watched and released before returning. Closing any adapter -uses the 30-second detached cleanup bound; closing Runtime never closes `conn`. - -The client re-exports three canonical types for ordinary remote-runtime use: - -| Alias | Canonical contract | Ordinary use | -| --- | --- | --- | -| `client.Runtime` | `api.Runtime` | Store or pass the runtime returned by `NewRuntime`. | -| `client.Session` | `api.Session` | Store or pass reusable normal sessions created by a plan. | -| `client.Output` | `api.Output`, defined in `api/result` | Consume encoded execution results. | - -These are true Go aliases: ownership and type identity stay in -`github.com/MontFerret/api` and its canonical subpackages. Source construction, -options, diagnostics, debugger inspection values, and shared Wire snapshots -continue to use their owning packages. The existing `client.Plan` and -`client.DebugSession` names denote lower-level Wire handles; the Universal API -workflow returns `api.Plan` and `api/debugger.Session` instead. - -`client.Runtime` was previously an exported concrete adapter. Explicit -`*client.Runtime` declarations must become `client.Runtime`; inferred -`remote, err := client.NewRuntime(ctx, conn)` calls remain unchanged. Constructor -failures return a nil interface and the existing error. - -`Client` compiles plans. A `Plan` executes its compiled program and creates -debug sessions. Execution and debugger operations live on their respective -handles. The protocol still carries connection, plan, normal-session, -execution, and debug-session IDs, but the facade retains and propagates them privately. -Callers cannot manually combine a handle with another client's connection. -Breakpoint IDs and debug value references remain visible because callers pass -them back to debugger operations; they do not expose connection or handle -ownership. A positive value reference is usable only while that debug session -remains in its current stopped state; zero and stale references are rejected. - -Debugger inspection uses the canonical Unified API types directly: +All implementations remain private. Sources, options, output, diagnostics, +breakpoints, locations, frames, variables, reasons, and debugger events use +their canonical Universal API types directly. `client` exports only `New`, +`Error`, `ErrClosed`, and `ErrExecutionCancelled`. + +`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` returns a defensive copy. 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 +rejected until the previous invocation's temporary execution has been released. +Distinct sessions and plans may execute concurrently. + +Each runtime/session invocation privately acquires, watches, and releases an +execution. Output remains `api.Output`: its content type and encoded bytes are +copied without interpretation. No IDs, RPC handles, execution snapshots, or +connection metadata are exposed by the returned API interfaces. + +## Options and parameters + +Use `api.WithOptimizationLevel` for plan compilation and `api.WithParam`, +`api.WithParams`, and `api.WithOutputContentType` for direct runs and session +creation. There are no Wire-specific semantic option structs. + +Omitting optimization preserves the hosted default; an explicit +`api.OptimizationNone` transports the zero level. Non-nil callbacks run exactly +once in order. Later settings override earlier ones, callback errors are +joined, and failed options prevent dispatch. Cancellation is checked before +callbacks and again before allocation. + +Parameter conversion accepts only Wire's portable subset: null, booleans, +signed integers, unsigned integers fitting `int64`, finite floats, strings, +bytes, `[]any`, and `map[string]any`. Integer and floating-point values remain +distinct. Empty parameter names, excessive nesting, non-finite numbers, +duration, datetime, regexp, and unsupported custom values are rejected locally. +The Universal API does not declare a portable parameter subset; this remains +a transport constraint, documented without introducing a parallel public type. + +## Example + +Transport construction is separate from runtime use. This function accepts +any local or remote Universal API runtime, borrowing it while owning the plan +and session it creates: ```go -SetBreakpoint(context.Context, source.Location) (debugger.Breakpoint, error) -SetBreakpointAt(context.Context, source.Location, debugger.BreakpointOptions) (debugger.Breakpoint, error) -DeleteBreakpoint(context.Context, debugger.BreakpointID) error -Frames(context.Context) ([]debugger.Frame, error) -FrameLocals(context.Context, int) ([]debugger.Variable, error) -Variables(context.Context, debugger.ValueReference) ([]debugger.Variable, error) -EvaluateFrame(context.Context, int, string) (debugger.Value, error) -StepOver(context.Context) error -StepIn(context.Context) error -StepOut(context.Context) error +func runQuery(ctx context.Context, runtime api.Runtime) (out api.Output, err error) { + plan, err := runtime.Compile(ctx, api.NewSource("query.fql", "RETURN @input")) + if err != nil { + return api.Output{}, err + } + defer func() { err = errors.Join(err, plan.Close()) }() + + session, err := plan.NewSession(ctx, api.WithParam("input", "hello")) + if err != nil { + return api.Output{}, err + } + defer func() { err = errors.Join(err, session.Close()) }() + + return session.Run(ctx) +} ``` -The slice index returned by `Frames` is the index accepted by `FrameLocals` -and `EvaluateFrame`; Wire does not expose or transmit a second frame-identity -type. Breakpoints preserve requested and resolved locations, spans, point and -function IDs, binding mode, and bound state. Frames preserve their function ID, -variables preserve mutable and parameter flags, and debug snapshots preserve -depth, stop reason, hit breakpoint IDs, output, and failure. -`source.Location.SourceName` is a semantic source identifier and is never -interpreted as a filesystem path by the client. - -The protocol and lower-level facade accept explicit breakpoint binding options -through `SetBreakpointAt`. `SetBreakpoint` remains the default-binding -convenience. - -Compilation and one-shot execution accept `api.Source` directly. Its `Name` -and `Content` map to the protocol `Source` message. - -```go -src := api.NewSource("query.fql", "RETURN @input") -plan, err := wireClient.Compile(ctx, src, client.CompileOptions{}) -if err != nil { - return err -} -defer func() { - if closeErr := plan.Close(context.Background()); closeErr != nil { - log.Printf("close plan: %v", closeErr) - } -}() - -execution, err := plan.Execute(ctx, parameters, client.ExecuteOptions{}) -if err != nil { - return err -} -defer func() { - if closeErr := execution.Close(context.Background()); closeErr != nil { - log.Printf("close execution: %v", closeErr) - } -}() +Create the remote runtime with `client.New(ctx, conn)` and close it after its +resources. Closing it never closes `conn`. Constructor failure returns a nil +`api.Runtime` interface and the decoded error. -output, err := execution.Wait(ctx) -``` +## Debugger -Plan metadata is immutable. `Parameters` returns a defensive copy. -`CompileOptions.Debuggable` chooses the protocol's `CompileDebug` operation -instead of `Compile`. `CompileOptions.PlanOptions` accepts the same -`api.PlanOption` callbacks as the Universal API adapter: +`CompileDebug` followed by `Plan.NewDebugSession` returns +`api/debugger.Session`. `Start`, `Continue`, `StepIn`, `StepOver`, and `StepOut` +return canonical debugger events at the next stop or completion. The private +adapter serializes these commands and consumes Wire watches internally. -```go -options := client.CompileOptions{ - PlanOptions: []api.PlanOption{api.WithOptimizationLevel(api.OptimizationNone)}, -} -``` +`Pause`, breakpoint operations, `Frames`, `Locals`, `FrameLocals`, `Variables`, +`Evaluate`, and `EvaluateFrame` retain their canonical signatures. Operations +without a caller context use the debugger's lifetime context; closing the +debugger cancels its pending work. Cancelling a resume command closes the +debugger and joins any cleanup error with caller cancellation. -Omitting the optimization option preserves the hosted runtime default; supplying -it transports the explicit level, including `api.OptimizationNone`. Presence -tracking stays private. Both entry points apply non-nil callbacks exactly once, -in order, before dispatch. The last valid setting wins; callback errors are -joined and prevent dispatch, as does caller cancellation before or after option -application. +Frame slice order defines the zero-based index for frame-local and evaluation +operations. Breakpoint IDs and value references remain public because they are +canonical debugger concepts; they do not identify Wire ownership scopes. +Positive references are usable only at the current stopped state; zero and +stale references are rejected. Source names are semantic identifiers, never +interpreted as local paths. -The metadata facade maps `APIIdentity` and `WireVersion` from the handshake's -protocol name and version and maps optional host runtime identity directly to -`*execution.Identity` from `pkg/execution`. -Legacy Ferret-version and capability fields remain empty rather than -fabricating values not carried by the protocol. +Breakpoints preserve requested/resolved locations, spans, binding mode, +point/function IDs, and bound state. Events preserve stop reason, depth, hit +breakpoint IDs, output, and failure. Runtime-error stops carry the failure in +`debugger.Event.Error`; failed debugger commands return an error. Completion +and termination map to their canonical reasons, without a second event API. -### Allocation and cancellation +## Allocation and cancellation The Universal API adapter checks cancellation before sending an allocation request, including after option callbacks. Only the acquisition RPC is detached @@ -175,160 +140,58 @@ Operation and cleanup errors remain joined. The caller-owned physical transport and other logical clients on it remain open. These bounds limit client waiting; the hosted implementation must still honor its cancellation and Close contracts. +## Closing resources -## Convenience execution - -For a one-shot program, `Client.Run` composes compile, execute, wait, and -ordered cleanup while preserving the Unified API encoded output boundary: - -```go -output, err := wireClient.Run( - ctx, - api.NewSource("query.fql", "RETURN @input"), - client.Parameters{"input": "hello"}, - client.RunOptions{Execute: client.ExecuteOptions{OutputContentType: "application/json"}}, -) -``` - -A caller that owns a reusable plan can run it repeatedly without surrendering -plan ownership: - -```go -src := api.NewSource("query.fql", "RETURN @input") -plan, err := wireClient.Compile(ctx, src, client.CompileOptions{}) -if err != nil { - return err -} -defer func() { - if closeErr := plan.Close(context.Background()); closeErr != nil { - log.Printf("close plan: %v", closeErr) - } -}() - -output, err := plan.Run(ctx, parameters, client.ExecuteOptions{}) -``` +The constructor context bounds the handshake, not the lifetime of the returned +runtime. Cancelling it after construction does not close the runtime. -The ownership boundary is explicit: - -| Operation | Creates | Releases | -| --- | --- | --- | -| `Client.Run` | Plan and Execution | Plan and Execution | -| `Plan.Run` | Execution | Execution only | -| `Execution.Wait` | Nothing | Nothing | - -`Execution.Wait` opens a fresh watch, ignores non-terminal snapshots, and -returns when the execution completes, fails, or is remotely cancelled. The -method, along with `Client.Run` and `Plan.Run`, returns `Output`, the alias of -`api.Output`. Failed terminal snapshots return `*failure.Failure`; remote cancellation returns -`client.ErrExecutionCancelled`. Cancellation of the caller's waiting context -instead returns that context's error. - -Convenience cleanup is synchronous and uses a cancellation-detached context -with a fresh 30-second deadline for each release, so resources created by -`Run` are still released after the request context is cancelled without -allowing a stalled cleanup call to block forever. Execution and cleanup errors -are joined rather than replacing one another. - -Universal API resource-allocation RPCs use the same bounded detached context. -This prevents a cancellation race from discarding the only opaque handle after -the server has published a resource. The original caller context is checked -after allocation; a resource that raced cancellation is cancelled and released -before the adapter returns the caller-visible cancellation. - -## Snapshots and events - -Handles represent identity, ownership, and operations; they are not mutable -state snapshots. Execution state crosses the facade as `execution.Event` and -`execution.Snapshot` from `pkg/execution`; debug state crosses as `debugger.Event` -and `debugger.Snapshot` from Wire's `pkg/debugger`. These snapshots preserve -Unified API `api.Output` and use API `debugger.Reason`, `source.Range`, and -`debugger.BreakpointID` values without exposing generated protobuf messages. -The client allocates fresh output bytes, diagnostics, ranges, and breakpoint-ID -slices while decoding each protobuf response. - -Execution and debugger command methods return errors only. Their state changes -are observed through `Execution.Watch` or `DebugSession.Watch`. A watch sends -the server's latest published event first, then ordered changes through one -terminal event. A newly created debug session replays sequence 1 with -`debugger.EventCreated` and a created snapshot; the client does not need a Get -RPC. - -`execution.Event` contains a sequence and execution snapshot. -`execution.State.Terminal` identifies completed, failed, and cancelled states. -Debug events retain a separate `debugger.EventKind` because starting and -continuing are distinct transitions that both publish a running snapshot. -`debugger.State.Terminal` centralizes completed, failed, and terminated state -handling. - -Watch streams are tied to both the operation context and the logical Client -lifecycle. A watch opened before resource closure remains able to receive the -server's terminal event. New watches are rejected after the handle or an -ancestor starts closing. +Public resources implement `Close() error`. Closing uses a detached context +with a 30-second bound. The first close commits teardown exactly once. +Concurrent and repeated callers observe the retained release result; a caller +whose wait expires does not abandon committed cleanup. Failed releases remain +observable rather than being hidden or automatically retried. -## Closing resources +Closing a runtime or plan owns descendant cleanup. Descendant operations are +rejected as soon as ancestor closure begins. A descendant closed after ancestor +cleanup begins observes the ancestor's retained result instead of issuing a +duplicate release. Close children before parents when each direct cleanup +result matters; normal defer ordering provides this. -The lower-level `Plan`, `Execution`, and `DebugSession` expose -`Close(context.Context) error`. -Close maps to the corresponding protocol release operation; it is never driven -by finalizers or garbage collection. - -The first Close commits teardown exactly once. Concurrent and repeated callers -wait for the same retained release result. A waiter's context can expire -without cancelling the committed cleanup, and a later call can still observe -the retained result. Release failures are retained rather than hidden or -retried. Once close begins, the handle rejects new operations with an error -matching `client.ErrClosed`. - -Closing a Client or Plan owns cleanup of its descendants. Descendant operations -are rejected as soon as ancestor closure begins. A descendant first closed -after ancestor cleanup begins observes the ancestor's retained result rather -than issuing a duplicate release. Close children before parents when reporting -each resource's direct release result matters; normal `defer` ordering provides -this naturally. - -`DebugSession.Stop` and `DebugSession.Close` are intentionally distinct. Stop -terminates debugger execution without releasing the remote ID; Close commits -termination and resource release. +Private watches are tied to operation and logical connection contexts. An +existing watch may receive the terminal event during resource closure; new +operations are rejected after closure begins. Private handles and cleanup +helpers remain with their existing lifecycle owners inside `client`. ## Errors -Immediate Wire failures are exposed as `*client.Error` with a -`failure.Category`, sanitized gRPC message, and canonical -`diagnostics.Diagnostics` when the runtime returned that typed collection. -Terminal `*failure.Failure` values carry the same canonical diagnostics. Wire -never parses error strings to construct them. Invalid requests, cancellation, -deadlines, unavailable transports, and resource exhaustion use their native -gRPC codes without a duplicate Wire category; `client.Error.Category` is zero -unless an `ErrorDetail` was transmitted. The error unwraps its transport cause, -so callers can use `status.Code(err)` without making transport codes part of -the semantic category. Protocol resource identifiers remain private. - -Terminal execution and debug failures use `*failure.Failure`, while local -lifecycle and waiting conditions remain distinguishable through -`client.ErrClosed`, `client.ErrExecutionCancelled`, and context errors. -Convenience APIs join operation and cleanup errors so `errors.Is` and -`errors.As` continue to find each component. - -## Facade responsibilities - -The client package is limited to: - -- logical Connect lifecycle; -- a complete remote `api.Runtime` adapter; -- typed plan, execution, debugger, and event operations; -- private connection and resource-ID propagation; -- explicit parameter conversion; -- transport-neutral snapshots with defensive conversion and structured error mapping; -- hiding protobuf and gRPC ceremony without hiding protocol concepts. - -Parameter conversion deliberately accepts only the portable Wire subset: -null, booleans, signed integers, finite doubles, strings, bytes, `[]any`, and -`map[string]any`. Exact signed `int64` and floating-point values remain distinct; -NaN and infinities are rejected even when nested. Duration, datetime, regexp, -and custom values are rejected locally. Further lower-level client redesign is -separate from the Universal API adapter. - -Closing the Client never closes the caller-owned gRPC connection. The facade -does not construct runtimes or transports. Its convenience execution methods -compose the same handles and watches; they do not duplicate runtime -semantics. +Immediate failures remain `*client.Error`, preserving `failure.Category`, +sanitized message, canonical `diagnostics.Diagnostics`, and the transport cause +through `Unwrap`. Categories are set only when the server supplies an +`ErrorDetail`; transport-native cancellation, deadlines, unavailable, +invalid-request, and resource-exhaustion errors keep category zero. +`status.Code(err)` remains available for transport-specific handling. +Wire never parses arbitrary error strings to reconstruct diagnostics. + +Terminal execution and debugger failures remain `*failure.Failure`. +`client.ErrClosed` identifies closed logical resources. +`client.ErrExecutionCancelled` identifies remote execution cancellation and +remains distinct from cancellation of the caller's context. +Operation and cleanup errors are joined so `errors.Is` and `errors.As` can find +each component. + +These Wire errors remain public because the Universal API has no equivalent +general remote-error taxonomy. Connection and allocation IDs remain private, +and contained implementation panic details remain sanitized. + +## Migration + +Call `client.New` instead of `NewRuntime`. Use `api.Runtime`, `api.Session`, +and `api.Output` instead of client aliases. The old `Client`, `Plan`, +`Execution`, `DebugSession`, and event receiver types, semantic option structs, +`Parameters`, `RuntimeInfo`, and `Capabilities` have been removed without +compatibility shims. + +Use canonical runtime/plan/session operations, cancellation contexts, and +debugger events. The versioned protobuf services and shared domain packages +remain unchanged; callers implementing protocol tooling may still use the +generated bindings directly. diff --git a/docs/protocol.md b/docs/protocol.md index 84d5267..338dcba 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -111,12 +111,13 @@ rejects those values on both client encoding and server decoding. Execution and debugger completion preserve Unified API encoded output exactly: `content_type` plus bytes. Wire never interprets the bytes as runtime values. -The handwritten Go adapters project these messages onto the public semantic -packages `pkg/execution`, `pkg/debugger`, and `pkg/failure`. These values are -shared by client and server and deliberately omit protocol resource IDs. The -adapters copy mutable output, diagnostic, range, and breakpoint data at -ownership and delivery boundaries and validate every state, event-kind, and -failure-category enum explicitly in both directions. +The handwritten Go adapters use `pkg/execution`, `pkg/debugger`, and +`pkg/failure` for shared Wire semantics, without protocol resource IDs. +The public client projects execution and debugging onto canonical Universal +API interfaces and events; Wire snapshots and watch streams remain private to +its implementation. The adapters copy mutable output, diagnostic, range, and +breakpoint data at ownership and delivery boundaries and validate every state, +event-kind, and failure-category enum explicitly in both directions. Only typed `diagnostics.Diagnostics` values cross the boundary. Wire never parses runtime error strings. Each diagnostic preserves kind, message, source @@ -331,7 +332,8 @@ taxonomy, a Unified API declaration of accepted parameter values, runtime introspection/versioning, or capability negotiation. Host-supplied `RuntimeIdentity` is not presented as API introspection. -A broad lower-level client redesign, native Ferret consumer migration, bytecode/node -protocols, distributed execution, and advanced negotiated capabilities remain -separate work. The Universal API adapter deliberately composes the same Wire -resources and does not add reconnection, leases, or transport construction. +Native Ferret consumer migration, bytecode/node protocols, distributed execution, +and advanced negotiated capabilities remain separate work. The public client +uses the Universal API programming model over private Wire resources. Removing +the lower-level Go facade does not remove protocol operations or add +reconnection, leases, or transport construction. diff --git a/server/integration_test.go b/server/integration_test.go index 058a4e2..b4409d4 100644 --- a/server/integration_test.go +++ b/server/integration_test.go @@ -28,7 +28,7 @@ type integrationEnv struct { server *server.Server listener *bufconn.Listener conn *grpc.ClientConn - client *client.Client + client api.Runtime serveErr chan error shutdown bool transportClosed bool @@ -51,28 +51,41 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { Name: "test-host", Version: "1.2.3", InstanceID: "instance-1", })) - info := env.client.RuntimeInfo() - if info.APIIdentity != "ferret.wire" || info.WireVersion != "v1" || info.FerretVersion != "" { - t.Fatalf("unexpected generic runtime info: %#v", info) + streamCtx, cancel := context.WithCancel(testContext(t)) + defer cancel() + rpc := wirev1.NewRuntimeServiceClient(env.conn) + stream, err := rpc.Connect(streamCtx, &wirev1.ConnectRequest{}) + if err != nil { + t.Fatal(err) } - if info.RuntimeIdentity == nil || info.RuntimeIdentity.Name != "test-host" || info.Capabilities != (client.Capabilities{}) { - t.Fatalf("unexpected identity or legacy capabilities: %#v", info) + info, err := stream.Recv() + if err != nil { + t.Fatal(err) + } + + if info.GetProtocol().GetName() != "ferret.wire" || info.GetProtocol().GetVersion() != "v1" || + info.GetRuntimeIdentity().GetName() != "test-host" { + t.Fatalf("unexpected handshake metadata: %v", info) + } + + if _, err := rpc.CloseConnection(testContext(t), &wirev1.CloseConnectionRequest{ConnectionId: info.GetConnectionId()}); err != nil { + t.Fatal(err) } compiled, err := env.client.Compile(context.Background(), api.Source{ Name: "unified.fql", Content: "RETURN @input", - }, client.CompileOptions{}) + }) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(compiled.Parameters(), []string{"input"}) { - t.Fatalf("unexpected plan parameters: %#v", compiled.Parameters()) + if !reflect.DeepEqual(compiled.Params(), []string{"input"}) { + t.Fatalf("unexpected plan parameters: %#v", compiled.Params()) } - parameters := client.Parameters{ + parameters := map[string]any{ "input": map[string]any{ "none": nil, "boolean": true, @@ -84,12 +97,12 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { }, } for range 2 { - execution, err := compiled.Execute(context.Background(), parameters, client.ExecuteOptions{OutputContentType: "application/json"}) + session, err := compiled.NewSession(testContext(t), api.WithParams(parameters), api.WithOutputContentType("application/json")) if err != nil { t.Fatal(err) } - output, err := execution.Wait(testContext(t)) + output, err := session.Run(testContext(t)) if err != nil { t.Fatal(err) } @@ -98,7 +111,7 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { t.Fatalf("unexpected output: %#v", output) } - if err := execution.Close(testContext(t)); err != nil { + if err := session.Close(); err != nil { t.Fatal(err) } } @@ -120,11 +133,11 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { assertTransportNeutralParams(t, options[0].params) - if err := compiled.Close(testContext(t)); err != nil { + if err := compiled.Close(); err != nil { t.Fatal(err) } - if err := env.client.Close(testContext(t)); err != nil { + if err := env.client.Close(); err != nil { t.Fatal(err) } @@ -167,16 +180,28 @@ func TestServerShutdownClosesOwnedResourcesWithoutClosingRuntime(t *testing.T) { return plan, nil }} env := newIntegrationEnv(t, runtime) - compiled, err := env.client.Compile(context.Background(), api.Source{Name: "shutdown.fql", Content: "RETURN 1"}, client.CompileOptions{}) + compiled, err := env.client.Compile(context.Background(), api.Source{Name: "shutdown.fql", Content: "RETURN 1"}) if err != nil { t.Fatal(err) } - if _, err := compiled.Execute(context.Background(), nil, client.ExecuteOptions{}); err != nil { + remoteSession, err := compiled.NewSession(testContext(t)) + if err != nil { t.Fatal(err) } - <-started + result := make(chan error, 1) + runCtx := testContext(t) + go func() { + _, err := remoteSession.Run(runCtx) + result <- err + }() + + select { + case <-started: + case <-runCtx.Done(): + t.Fatal("hosted session did not start") + } if err := env.server.Shutdown(testContext(t)); err != nil { t.Fatal(err) @@ -184,6 +209,15 @@ func TestServerShutdownClosesOwnedResourcesWithoutClosingRuntime(t *testing.T) { env.shutdown = true + select { + case err := <-result: + if err == nil { + t.Fatal("shutdown execution unexpectedly succeeded") + } + case <-runCtx.Done(): + t.Fatal("remote execution did not settle after shutdown") + } + session.mu.Lock() sessionCloseCalls := session.closeCalls session.mu.Unlock() @@ -230,7 +264,7 @@ func TestGenericRuntimeFailuresAreStructuredAndSanitized(t *testing.T) { return nil, secret }} env := newIntegrationEnv(t, runtime) - _, err := env.client.Compile(context.Background(), api.Source{Content: "broken"}, client.CompileOptions{}) + _, err := env.client.Compile(context.Background(), api.Source{Content: "broken"}) var wireErr *client.Error if !errors.As(err, &wireErr) || wireErr.Category != failure.CategoryCompilation { t.Fatalf("unexpected compile error: %v", err) @@ -250,7 +284,7 @@ func TestPortableDiagnosticsCrossImmediateAndAsynchronousFailures(t *testing.T) }} env := newIntegrationEnv(t, runtime) - _, err := env.client.Compile(context.Background(), api.Source{Name: "query.fql", Content: "RETURN"}, client.CompileOptions{}) + _, err := env.client.Compile(context.Background(), api.Source{Name: "query.fql", Content: "RETURN"}) var wireErr *client.Error if !errors.As(err, &wireErr) || wireErr.Category != failure.CategoryCompilation { t.Fatalf("unexpected compile error: %v", err) @@ -274,17 +308,17 @@ func TestPortableDiagnosticsCrossImmediateAndAsynchronousFailures(t *testing.T) }} env := newIntegrationEnv(t, runtime) - compiled, err := env.client.Compile(context.Background(), api.Source{Name: "query.fql", Content: "RETURN"}, client.CompileOptions{}) + compiled, err := env.client.Compile(context.Background(), api.Source{Name: "query.fql", Content: "RETURN"}) if err != nil { t.Fatal(err) } - execution, err := compiled.Execute(context.Background(), nil, client.ExecuteOptions{}) + session, err := compiled.NewSession(testContext(t)) if err != nil { t.Fatal(err) } - output, err := execution.Wait(testContext(t)) + output, err := session.Run(testContext(t)) var terminalFailure *failure.Failure if !errors.As(err, &terminalFailure) || terminalFailure.Category != failure.CategoryExecution { t.Fatalf("unexpected execution failure: %v", err) @@ -352,17 +386,17 @@ func TestMessageLimitsRemainAtTheGRPCBoundary(t *testing.T) { t.Fatalf("unexpected inbound message result: %v", err) } - compiled, err := env.client.Compile(context.Background(), api.Source{Content: "large"}, client.CompileOptions{}) + compiled, err := env.client.Compile(context.Background(), api.Source{Content: "large"}) if err != nil { t.Fatal(err) } - execution, err := compiled.Execute(context.Background(), nil, client.ExecuteOptions{}) + session, err := compiled.NewSession(testContext(t)) if err != nil { t.Fatal(err) } - if _, err := execution.Wait(testContext(t)); status.Code(err) != codes.ResourceExhausted { + if _, err := session.Run(testContext(t)); status.Code(err) != codes.ResourceExhausted { var wireErr *client.Error if !errors.As(err, &wireErr) || status.Code(err) != codes.ResourceExhausted { t.Fatalf("unexpected outbound message result: %v", err) @@ -400,7 +434,7 @@ func newIntegrationEnv(t testing.TB, runtime api.Runtime, options ...server.Opti t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := wireClient.Close(ctx); err != nil && !errors.Is(err, client.ErrClosed) && !env.shutdown { + if err := wireClient.Close(); err != nil && !errors.Is(err, client.ErrClosed) && !env.shutdown { t.Errorf("client cleanup failed: %v", err) } diff --git a/server/protocol_resource_test.go b/server/protocol_resource_test.go new file mode 100644 index 0000000..b90c2fe --- /dev/null +++ b/server/protocol_resource_test.go @@ -0,0 +1,152 @@ +package server_test + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/MontFerret/api" + "github.com/MontFerret/api/debugger" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" +) + +// Termination before Start only needs Close. Any unexpected debugger operation +// reaches the embedded nil interface and fails the hosted implementation boundary. +type unstartedProtocolDebugger struct { + debugger.Session + closes atomic.Int64 +} + +func (d *unstartedProtocolDebugger) Close() error { + d.closes.Add(1) + + return nil +} + +// Execute, CancelExecution, and Terminate remain protocol operations even though +// the handwritten client no longer exposes a second resource programming model. +func TestProtocolResourceOperationsRemainAvailable(t *testing.T) { + started := make(chan struct{}) + var sessionCloses atomic.Int64 + debug := &unstartedProtocolDebugger{} + plan := &contractPlan{ + newSession: func(_ context.Context, options apiSessionOptions) (api.Session, error) { + if options.contentType != "text/plain" || options.params["input"] != int64(7) { + t.Errorf("Execute lost session options: %+v", options) + } + + return &apiSessionSpy{ + run: func(ctx context.Context) (api.Output, error) { + close(started) + <-ctx.Done() + + return api.Output{}, ctx.Err() + }, + close: func() error { + sessionCloses.Add(1) + + return nil + }, + }, nil + }, + newDebugSession: func(context.Context, apiSessionOptions) (debugger.Session, error) { + return debug, nil + }, + } + env := newIntegrationEnv(t, &contractRuntime{compile: func(context.Context, api.Source, bool, contractPlanOptions) (api.Plan, error) { + return plan, nil + }}) + ctx := testContext(t) + connectCtx, cancel := context.WithCancel(ctx) + defer cancel() + runtimeRPC := wirev1.NewRuntimeServiceClient(env.conn) + stream, err := runtimeRPC.Connect(connectCtx, &wirev1.ConnectRequest{}) + if err != nil { + t.Fatal(err) + } + + handshake, err := stream.Recv() + if err != nil { + t.Fatal(err) + } + + connectionID := handshake.GetConnectionId() + defer func() { + if _, err := runtimeRPC.CloseConnection(testContext(t), &wirev1.CloseConnectionRequest{ConnectionId: connectionID}); err != nil { + t.Error(err) + } + }() + planRPC := wirev1.NewPlanServiceClient(env.conn) + compiled, err := planRPC.CompileDebug(ctx, &wirev1.CompileDebugRequest{ConnectionId: connectionID, Source: &wirev1.Source{Content: "RETURN @input"}}) + if err != nil { + t.Fatal(err) + } + + planID := compiled.GetPlan().GetId() + executionRPC := wirev1.NewExecutionServiceClient(env.conn) + created, err := executionRPC.Execute(ctx, &wirev1.ExecuteRequest{ + ConnectionId: connectionID, PlanId: planID, OutputContentType: "text/plain", + Parameters: &wirev1.Parameters{Values: map[string]*wirev1.Value{"input": {Value: &wirev1.Value_IntegerValue{IntegerValue: 7}}}}, + }) + if err != nil { + t.Fatal(err) + } + + select { + case <-started: + case <-ctx.Done(): + t.Fatal("Execute did not reach the hosted session") + } + + executionID := created.GetExecution().GetId() + if _, err := executionRPC.CancelExecution(ctx, &wirev1.CancelExecutionRequest{ConnectionId: connectionID, ExecutionId: executionID}); err != nil { + t.Fatal(err) + } + + watch, err := executionRPC.WatchExecution(ctx, &wirev1.WatchExecutionRequest{ConnectionId: connectionID, ExecutionId: executionID}) + if err != nil { + t.Fatal(err) + } + + event, err := watch.Recv() + if err != nil || event.GetExecution().GetState() != wirev1.ExecutionState_EXECUTION_STATE_CANCELLED { + t.Fatalf("CancelExecution lost its terminal event: %v, %v", event, err) + } + + if _, err := executionRPC.ReleaseExecution(ctx, &wirev1.ReleaseExecutionRequest{ConnectionId: connectionID, ExecutionId: executionID}); err != nil { + t.Fatal(err) + } + + debugRPC := wirev1.NewDebugServiceClient(env.conn) + debugCreated, err := debugRPC.CreateDebugSession(ctx, &wirev1.CreateDebugSessionRequest{ConnectionId: connectionID, PlanId: planID}) + if err != nil { + t.Fatal(err) + } + + debugID := debugCreated.GetSession().GetId() + if _, err := debugRPC.Terminate(ctx, &wirev1.TerminateRequest{ConnectionId: connectionID, DebugSessionId: debugID}); err != nil { + t.Fatal(err) + } + + debugWatch, err := debugRPC.WatchDebug(ctx, &wirev1.WatchDebugRequest{ConnectionId: connectionID, DebugSessionId: debugID}) + if err != nil { + t.Fatal(err) + } + + debugEvent, err := debugWatch.Recv() + if err != nil || debugEvent.GetKind() != wirev1.DebugEventKind_DEBUG_EVENT_KIND_TERMINATED { + t.Fatalf("Terminate lost its terminal event: %v, %v", debugEvent, err) + } + + if _, err := debugRPC.ReleaseDebugSession(ctx, &wirev1.ReleaseDebugSessionRequest{ConnectionId: connectionID, DebugSessionId: debugID}); err != nil { + t.Fatal(err) + } + + if _, err := planRPC.ReleasePlan(ctx, &wirev1.ReleasePlanRequest{ConnectionId: connectionID, PlanId: planID}); err != nil { + t.Fatal(err) + } + + if sessionCloses.Load() != 1 || debug.closes.Load() != 1 { + t.Fatalf("protocol cleanup counts: session=%d debugger=%d", sessionCloses.Load(), debug.closes.Load()) + } +} diff --git a/server/runtime_adapter_benchmark_test.go b/server/runtime_adapter_benchmark_test.go index 6f5be1c..b22faac 100644 --- a/server/runtime_adapter_benchmark_test.go +++ b/server/runtime_adapter_benchmark_test.go @@ -10,7 +10,7 @@ import ( func BenchmarkRuntimeAdapterDurableSession(b *testing.B) { env := newIntegrationEnv(b, &contractRuntime{}) - remote, err := client.NewRuntime(testContext(b), env.conn) + remote, err := client.New(testContext(b), env.conn) if err != nil { b.Fatal(err) } diff --git a/server/runtime_optimization_presence_test.go b/server/runtime_optimization_presence_test.go index 69ce19b..003ec10 100644 --- a/server/runtime_optimization_presence_test.go +++ b/server/runtime_optimization_presence_test.go @@ -34,15 +34,17 @@ func TestClientOptimizationPresenceRoundTrip(t *testing.T) { options = append(options, api.WithOptimizationLevel(test.level)) } - lowLevel, err := env.client.Compile(testContext(t), api.Source{Content: "RETURN 1"}, client.CompileOptions{ - Debuggable: debug, - PlanOptions: options, - }) + compile := env.client.Compile + if debug { + compile = env.client.CompileDebug + } + + plan, err := compile(testContext(t), api.Source{Content: "RETURN 1"}, options...) if err != nil { t.Fatal(err) } - if err := lowLevel.Close(testContext(t)); err != nil { + if err := plan.Close(); err != nil { t.Fatal(err) } @@ -64,28 +66,33 @@ func TestClientOptimizationPresenceRoundTrip(t *testing.T) { func TestCompileOptionsApplyOnceBeforeDispatch(t *testing.T) { for _, debug := range []bool{false, true} { for _, outcome := range []string{"success", "invalid level", "callback errors", "callback cancellation", "already cancelled"} { - name := "Client/" + map[bool]string{false: "normal/", true: "debug/"}[debug] + outcome + name := "Runtime/" + map[bool]string{false: "normal/", true: "debug/"}[debug] + outcome t.Run(name, func(t *testing.T) { hosted := &contractRuntime{} env := newIntegrationEnv(t, hosted) gate := &allocationResponseGate{ClientConnInterface: env.conn, calls: make(map[string]int)} - lowLevel, err := client.New(testContext(t), gate) + remote, err := client.New(testContext(t), gate) if err != nil { t.Fatal(err) } t.Cleanup(func() { - if err := lowLevel.Close(testContext(t)); err != nil { + if err := remote.Close(); err != nil { t.Error(err) } }) compile := func(ctx context.Context, options []api.PlanOption) error { - plan, err := lowLevel.Compile(ctx, api.Source{Content: "RETURN 1"}, client.CompileOptions{Debuggable: debug, PlanOptions: options}) + compile := remote.Compile + if debug { + compile = remote.CompileDebug + } + + plan, err := compile(ctx, api.Source{Content: "RETURN 1"}, options...) if err != nil { return err } - return plan.Close(testContext(t)) + return plan.Close() } ctx, cancel := context.WithCancel(testContext(t)) diff --git a/test/integration/harness/harness.go b/test/integration/harness/harness.go index 513708b..27236fb 100644 --- a/test/integration/harness/harness.go +++ b/test/integration/harness/harness.go @@ -141,7 +141,7 @@ func (h *Harness) Faults() *Faults { } func (h *Harness) OpenRuntime() (api.Runtime, error) { - runtime, err := client.NewRuntime(h.ctx, h.faults) + runtime, err := client.New(h.ctx, h.faults) if err != nil { return nil, err }