diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aa7380..adb17cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: with: go-version: 1.25.x cache: true + - run: make lint - run: make test-race - run: make proto-lint - run: make check-generate diff --git a/.gitignore b/.gitignore index 870896c..4be9159 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # # Binaries for programs and plugins +/bin/ *.exe *.exe~ *.dll diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1330467 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,104 @@ +version: "2" + +run: + timeout: 5m + modules-download-mode: readonly + tests: true + +linters: + default: none + enable: + - bodyclose + - copyloopvar + - errcheck + - errorlint + - govet + - grouper + - ineffassign + - misspell + - nolintlint + - revive + - staticcheck + - unused + - wsl_v5 + settings: + grouper: + # A single type may remain standalone; related types share one declaration. + type-require-single-type: true + nolintlint: + require-explanation: true + require-specific: true + allow-unused: false + revive: + severity: error + confidence: 0.8 + enable-default-rules: false + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: empty-block + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: exported + - name: if-return + - name: increment-decrement + - name: indent-error-flow + - name: package-comments + - name: range + - name: receiver-naming + - name: superfluous-else + - name: time-naming + - name: unexported-return + - name: unreachable-code + - name: unused-parameter + - name: var-declaration + - name: var-naming + wsl_v5: + # Enforce the control-flow spacing rules in AGENTS.md. + default: none + enable: + - if + - err + - return + - branch + - after-block + - leading-whitespace + - trailing-whitespace + allow-first-in-block: false + allow-whole-block: false + branch-max-lines: 0 + case-max-lines: 0 + cuddle-max-statements: 1 + exclusions: + generated: strict + presets: [] + rules: + # Wire is the product name; retain it in existing error messages. + - linters: + - staticcheck + text: '^ST1005: error strings should not be capitalized$' + source: '(errors\.New|fmt\.Errorf)\("Wire ' + paths: + - ^gen/ + - ^vendor/ + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/MontFerret + exclusions: + generated: strict + paths: + - ^gen/ + - ^vendor/ + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/AGENTS.md b/AGENTS.md index 5d9993d..7bbfcc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,6 +250,7 @@ Canonical repository validation is: ```sh make fmt make check-fmt +make lint make generate make check-generate make proto-lint @@ -261,6 +262,16 @@ make test-race make build ``` +`make fmt`, `make check-fmt`, and `make lint` automatically install the pinned +golangci-lint binary when absent; `make install-lint` is optional. First use needs +download access, curl, and a POSIX shell. Lint covers handwritten code and tests, +including exported declaration comments, error handling, and the type and +spacing rules above. Formatting uses gofmt and goimports; generated and vendor +code are excluded. Keep suppressions specific, explained, and effective, and +preserve intentional error and panic identities. See the +[development instructions](README.md#development) for the rule list and +suppression guidance. + Use the relevant subset for narrow iteration, then broaden according to risk. `make generate` is required when generator inputs change. @@ -325,8 +336,8 @@ Do not use self-review to justify speculative redesign or unrelated cleanup. ## CI and documentation synchronization CI uses the Makefile's canonical targets on Linux, macOS, and Windows. Linux -also runs race detection, protobuf linting, generation consistency checks, and -pull-request Buf breaking checks against the fetched base branch. Keep CI +also runs Go lint, race detection, protobuf linting, generation consistency checks, +and pull-request Buf breaking checks against the fetched base branch. Keep CI orchestration in the workflow and command composition in the Makefile. Documentation is part of implementation. Keep detailed architecture, protocol diff --git a/Makefile b/Makefile index 00c93f1..e913786 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,36 @@ BUF = go run github.com/bufbuild/buf/cmd/buf@v1.72.0 BUF_BREAKING_AGAINST ?= .git#branch=main +DIR_BIN = ./bin +GOLANGCI_LINT_VERSION = v2.13.2 +GOLANGCI_LINT_DIR = $(DIR_BIN)/tools/golangci-lint/$(GOLANGCI_LINT_VERSION) +GOLANGCI_LINT_SUFFIX := $(if $(filter windows,$(shell go env GOHOSTOS)),.exe) +GOLANGCI_LINT = $(GOLANGCI_LINT_DIR)/golangci-lint$(GOLANGCI_LINT_SUFFIX) -.PHONY: build check-fmt check-generate check-tidy fmt generate proto-breaking proto-lint test test-race vet +.PHONY: build check-fmt check-generate check-tidy fmt generate install-lint lint proto-breaking proto-lint test test-race vet build: go build ./... -fmt: - go fmt ./... +install-lint: $(GOLANGCI_LINT) -check-fmt: - test -z "$$(gofmt -l $$(find . -type f -name '*.go' -not -path './.git/*'))" +$(GOLANGCI_LINT): + @set -eu; \ + lint_installer=$$(mktemp); \ + trap 'rm -f "$$lint_installer"' 0; \ + curl --fail --silent --show-error --location \ + "https://raw.githubusercontent.com/golangci/golangci-lint/$(GOLANGCI_LINT_VERSION)/install.sh" \ + --output "$$lint_installer"; \ + sh "$$lint_installer" -b "$(GOLANGCI_LINT_DIR)" "$(GOLANGCI_LINT_VERSION)" + +fmt: $(GOLANGCI_LINT) + $(GOLANGCI_LINT) fmt ./... + +check-fmt: $(GOLANGCI_LINT) + $(GOLANGCI_LINT) fmt --diff ./... + +lint: $(GOLANGCI_LINT) + $(GOLANGCI_LINT) config verify && \ + $(GOLANGCI_LINT) run ./... generate: $(BUF) generate diff --git a/README.md b/README.md index d0fae28..c077fb1 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ does not synthesize intermediate output from logs. ```sh make fmt # format handwritten Go make check-fmt # verify formatting without changing files +make lint # verify configuration and lint the complete Go baseline make generate # regenerate checked-in Go/gRPC bindings make check-generate # fail when generation changes the checkout make proto-lint # Buf STANDARD lint @@ -194,6 +195,34 @@ make test-race make build ``` +The Makefile pins golangci-lint and installs its official, checksum-verified +release under ignored `bin/tools/golangci-lint//`. `make fmt`, +`make check-fmt`, and `make lint` install it automatically when the selected +version's executable is absent and reuse it afterward. Explicit installation +with `make install-lint` is optional. First use requires download access, `curl`, +and a POSIX shell (such as Git Bash on Windows). Tool selection uses the host +platform even when Go cross-compilation variables are set. `make build` remains +a compilation-only target. + +[The lint configuration](.golangci.yml) enables correctness, error handling, +resource cleanup, spelling, API documentation and naming, grouped type +declarations, and control-flow spacing checks. Its explicit linter list is +`errcheck`, `govet`, `ineffassign`, `staticcheck`, `unused`, `bodyclose`, +`errorlint`, `copyloopvar`, `nolintlint`, `revive`, `grouper`, `misspell`, +and `wsl_v5`. Formatting uses `gofmt` and `goimports`, with +`github.com/MontFerret` imports grouped together. Tests are covered; generated +code and vendor directories are excluded from lint and formatting. Linting is +read-only and analyzes the full baseline. + +Fix findings at their source. When a check conflicts with an intentional +contract, use a narrow directive such as +`//nolint:errorlint // Verify the original error is returned unchanged.` +Suppressions must name the linter, explain the reason, and suppress a real +finding. Preserve exact-error and panic-identity assertions. A configuration +exception covers only capitalization warnings for error literals beginning +with the proper name "Wire"; other Staticcheck checks remain enabled. +Architectural and ownership rules still require review. + The [Universal API integration suite](test/integration/README.md) exercises the public client/server boundary over real gRPC using an in-memory `bufconn` transport and hosted API spies. Run it independently with @@ -201,6 +230,6 @@ transport and hosted API spies. Run it independently with Package-local tests retain component, conversion, and low-level protocol coverage. CI invokes these Make targets on Linux, macOS, and Windows; Linux additionally -runs the race detector, Buf lint, checked generation, and pull-request breaking -checks against the fetched base branch. The integration suite is included in -the existing `./...` targets without build tags or extra services. +runs Go lint, the race detector, Buf lint, checked generation, and pull-request +breaking checks against the fetched base branch. The integration suite is included +in the existing `./...` targets without build tags or extra services. diff --git a/client/allocation_error.go b/client/allocation_error.go index 089566d..a11c262 100644 --- a/client/allocation_error.go +++ b/client/allocation_error.go @@ -23,6 +23,7 @@ func (e *allocationError) Unwrap() error { func allocationRPCError(err error) error { decoded := decodeError(err) + var rejection *Error if errors.As(decoded, &rejection) && rejection.Category != 0 { // Structured Wire failures describe a rejected creation. Its owner rolls diff --git a/client/client.go b/client/client.go index 47d29e9..643a5c3 100644 --- a/client/client.go +++ b/client/client.go @@ -6,9 +6,10 @@ import ( "io" "sync" + "google.golang.org/grpc" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/pkg/failure" - "google.golang.org/grpc" ) type ( @@ -47,6 +48,7 @@ func newConnection(ctx context.Context, connection grpc.ClientConnInterface) (*c runtimeClient := wirev1.NewRuntimeServiceClient(connection) streamCtx, streamCancel := context.WithCancel(context.WithoutCancel(ctx)) + stream, err := runtimeClient.Connect(streamCtx, &wirev1.ConnectRequest{}) if err != nil { streamCancel() @@ -163,6 +165,7 @@ func (c *connectionHandle) checkOpen() error { c.closeMu.Lock() closing := c.closing c.closeMu.Unlock() + if closing { return ErrClosed } @@ -172,6 +175,7 @@ func (c *connectionHandle) checkOpen() error { c.streamMu.Lock() err := c.streamErr c.streamMu.Unlock() + if err != nil { return err } @@ -190,6 +194,7 @@ func (c *connectionHandle) closeResult(ctx context.Context) (bool, error) { c.closeMu.Lock() closing := c.closing c.closeMu.Unlock() + if !closing { return false, nil } diff --git a/client/client_test.go b/client/client_test.go index 1bf7bd6..22a8a4e 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -11,14 +11,15 @@ import ( "testing" "time" - "github.com/MontFerret/api" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - "github.com/MontFerret/wire/pkg/failure" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + + "github.com/MontFerret/api" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "github.com/MontFerret/wire/pkg/failure" ) type ( @@ -116,10 +117,12 @@ func (s *clientTestServer) compile(connectionID *wirev1.ConnectionId, source *wi s.lastCompileDebuggable = debug s.lastCompileSourceName = source.GetName() s.lastCompileContent = source.GetContent() + err := s.compileErr if err == nil { s.plans++ } + planID := fmt.Sprintf("plan-%d", s.plans) s.mu.Unlock() @@ -162,6 +165,7 @@ func (s *clientTestServer) startExecution(operation, connectionID, parentID, con defer s.mu.Unlock() s.calls = append(s.calls, call(operation, connectionID, parentID)) + s.lastOutputContentType = contentType if s.executeErr != nil { return nil, s.executeErr @@ -210,9 +214,11 @@ func (s *clientTestServer) WatchExecution(request *wirev1.WatchExecutionRequest, index := s.watchCalls s.watchCalls++ var script executionWatchScript + if index < len(s.watchScripts) { script = s.watchScripts[index] } + s.mu.Unlock() for _, event := range script.events { @@ -289,6 +295,7 @@ func TestRuntimeRunOwnsItsExecution(t *testing.T) { debuggable := server.lastCompileDebuggable contentType := server.lastOutputContentType server.mu.Unlock() + 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) } @@ -299,10 +306,12 @@ func TestRuntimeRunOwnsItsExecution(t *testing.T) { client := openTestRuntime(t, startClientTestServer(t, server)) _, 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) } + calls, watchCalls, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() if !slices.Equal(calls, []string{call("compile", "connection", "")}) || watchCalls != 0 || releaseExecutionCalls != 0 || releasePlanCalls != 0 { t.Fatalf("compile failure leaked cleanup calls: %v", calls) @@ -314,11 +323,14 @@ func TestRuntimeRunOwnsItsExecution(t *testing.T) { client := openTestRuntime(t, startClientTestServer(t, server)) _, 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("run", "connection", ""), } @@ -356,6 +368,7 @@ func TestRuntimeRunOwnsItsExecution(t *testing.T) { case <-time.After(10 * time.Second): t.Fatal("Runtime.Run cleanup did not settle") } + _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() if releaseExecutionCalls != 1 || releasePlanCalls != 0 { t.Fatalf("cancelled Runtime.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) @@ -363,6 +376,7 @@ func TestRuntimeRunOwnsItsExecution(t *testing.T) { executionDeadline, planDeadline := server.releaseDeadlineSnapshot() assertCleanupDeadline(t, "execution", executionDeadline) + if !planDeadline.IsZero() { t.Fatal("direct run released a plan") } @@ -376,10 +390,12 @@ func TestRuntimeRunOwnsItsExecution(t *testing.T) { client := openTestRuntime(t, startClientTestServer(t, server)) _, 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("Runtime.Run lost the stream failure: %v", err) } + _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() if releaseExecutionCalls != 1 || releasePlanCalls != 0 { t.Fatalf("stream-failed Runtime.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) @@ -399,11 +415,13 @@ func TestRuntimeRunOwnsItsExecution(t *testing.T) { client := openTestRuntime(t, startClientTestServer(t, server)) 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") { t.Fatalf("Runtime.Run did not preserve all errors: %#v, %v", output, err) } + _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() if releaseExecutionCalls != 1 || releasePlanCalls != 0 { t.Fatalf("failed Runtime.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) @@ -413,10 +431,12 @@ func TestRuntimeRunOwnsItsExecution(t *testing.T) { func openTestClient(t *testing.T, connection grpc.ClientConnInterface) *connectionHandle { t.Helper() + client, err := newConnection(testClientContext(t), connection) if err != nil { t.Fatal(err) } + t.Cleanup(func() { if err := client.Close(testClientContext(t)); err != nil { t.Errorf("client cleanup failed: %v", err) @@ -447,8 +467,10 @@ func startClientTestServer(t *testing.T, implementation *clientTestServer) *grpc if err != nil { t.Fatal(err) } + t.Cleanup(func() { server.Stop() + if err := connection.Close(); err != nil { t.Errorf("transport cleanup failed: %v", err) } @@ -472,6 +494,7 @@ func startClientTestServer(t *testing.T, implementation *clientTestServer) *grpc func openTestRuntime(t *testing.T, connection grpc.ClientConnInterface) api.Runtime { t.Helper() + runtime, err := New(testClientContext(t), connection) if err != nil { t.Fatal(err) diff --git a/client/compile.go b/client/compile.go index 059639e..856aac1 100644 --- a/client/compile.go +++ b/client/compile.go @@ -24,6 +24,7 @@ func (c *connectionHandle) compileConfigured(ctx context.Context, src api.Source } var value *wirev1.Plan + if debuggable { response, err := c.planClient.CompileDebug(ctx, &wirev1.CompileDebugRequest{ ConnectionId: c.connectionProto(), diff --git a/client/constructor_test.go b/client/constructor_test.go index d362f7d..32824b1 100644 --- a/client/constructor_test.go +++ b/client/constructor_test.go @@ -5,10 +5,11 @@ import ( "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" + + "github.com/MontFerret/api" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) func TestNewFailureReturnsNilRuntimeAndClosesHandshake(t *testing.T) { @@ -29,6 +30,7 @@ func TestNewFailureReturnsNilRuntimeAndClosesHandshake(t *testing.T) { 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) diff --git a/client/debug.go b/client/debug.go index 56bc586..c2b8e69 100644 --- a/client/debug.go +++ b/client/debug.go @@ -102,6 +102,7 @@ func (d *debugSessionHandle) Watch(ctx context.Context) (*debugEvents, error) { } watchCtx, cancel := d.client.watchContext(ctx) + stream, err := d.client.debugClient.WatchDebug(watchCtx, &wirev1.WatchDebugRequest{ ConnectionId: d.client.connectionProto(), DebugSessionId: &wirev1.DebugSessionId{Value: d.id}, }) diff --git a/client/debug_inspection.go b/client/debug_inspection.go index 9a1f007..131bd8c 100644 --- a/client/debug_inspection.go +++ b/client/debug_inspection.go @@ -109,6 +109,7 @@ func (d *debugSessionHandle) Frames(ctx context.Context) ([]debugger.Frame, erro if err != nil { return nil, err } + result[i] = converted } @@ -139,6 +140,7 @@ func (d *debugSessionHandle) FrameLocals(ctx context.Context, frameIndex int) ([ if err != nil { return nil, err } + result[i] = converted } @@ -169,6 +171,7 @@ func (d *debugSessionHandle) Variables(ctx context.Context, reference debugger.V if err != nil { return nil, err } + result[i] = converted } @@ -240,6 +243,7 @@ func convertBreakpoint(value *wirev1.Breakpoint) (debugger.Breakpoint, error) { } var location source.Range + if resolved != nil { location = *resolved } @@ -264,12 +268,14 @@ func convertFrame(value *wirev1.Frame, index int) (debugger.Frame, error) { if err != nil { return debugger.Frame{}, err } + functionID, err := debuggerIDFromProto[debugger.FunctionID](value.GetFunctionId(), "frame function ID", true) if err != nil { return debugger.Frame{}, err } result := debugger.Frame{Name: value.GetName(), FunctionID: functionID} + if location != nil { result.Location = *location } diff --git a/client/debug_test.go b/client/debug_test.go index ce1c62d..dac114d 100644 --- a/client/debug_test.go +++ b/client/debug_test.go @@ -5,11 +5,12 @@ import ( "strings" "testing" + "google.golang.org/grpc" + "github.com/MontFerret/api/debugger" "github.com/MontFerret/api/source" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" wiredebugger "github.com/MontFerret/wire/pkg/debugger" - "google.golang.org/grpc" ) type debugResponseStream struct { @@ -71,6 +72,7 @@ func TestDebugStateAndEventKindConversionsMapEveryProtocolValue(t *testing.T) { if err != nil { t.Fatal(err) } + if got != test.want { t.Errorf("convertDebugState(%v) = %v, want %v", test.protocol, got, test.want) } @@ -93,6 +95,7 @@ func TestDebugStateAndEventKindConversionsMapEveryProtocolValue(t *testing.T) { if err != nil { t.Fatal(err) } + if got != test.want { t.Errorf("convertDebugEventKind(%v) = %v, want %v", test.protocol, got, test.want) } @@ -101,6 +104,7 @@ func TestDebugStateAndEventKindConversionsMapEveryProtocolValue(t *testing.T) { if _, err := convertDebugState(wirev1.DebugState(99)); err == nil { t.Fatal("unknown debug state was accepted") } + if _, err := convertDebugEventKind(wirev1.DebugEventKind(99)); err == nil { t.Fatal("unknown debug event kind was accepted") } @@ -119,6 +123,7 @@ func TestDebugEventsDistinguishStartedFromContinued(t *testing.T) { if err != nil { t.Fatal(err) } + continued, err := convertDebugEvent(&wirev1.WatchDebugResponse{ Sequence: 2, Kind: wirev1.DebugEventKind_DEBUG_EVENT_KIND_CONTINUED, @@ -184,10 +189,12 @@ func TestDebugConversionsUseUnifiedAPITypesAndPreserveTransportFields(t *testing 7, }, } + snapshot, err := convertDebugSessionSnapshot(value) if err != nil { t.Fatal(err) } + if snapshot.StopReason != debugger.ReasonBreakpoint || snapshot.Location == nil || snapshot.Location.Location != (source.Location{Position: source.Position{Line: 4, Column: 2}, SourceName: "debug.fql"}) || snapshot.Location.Span != (source.Span{Start: 12, End: 18}) || snapshot.Depth != 3 || @@ -197,7 +204,8 @@ func TestDebugConversionsUseUnifiedAPITypesAndPreserveTransportFields(t *testing value.Location.Location.SourceName = "changed.fql" value.HitBreakpointIds[0] = 99 - if snapshot.Location.Location.SourceName != "debug.fql" || snapshot.HitBreakpointIDs[0] != 7 { + + if snapshot.Location.SourceName != "debug.fql" || snapshot.HitBreakpointIDs[0] != 7 { t.Fatalf("debug snapshot retained protobuf storage: %#v", snapshot) } @@ -213,6 +221,7 @@ func TestDebugConversionsUseUnifiedAPITypesAndPreserveTransportFields(t *testing if err != nil { t.Fatal(err) } + if breakpoint.ID != 9 || !breakpoint.Bound || breakpoint.RequestedLocation.Line != 3 || breakpoint.Location.Line != 4 || breakpoint.Location.Span != (source.Span{Start: 12, End: 18}) || breakpoint.PointID != 10 || breakpoint.FunctionID != 11 || breakpoint.BindingMode != debugger.BreakpointBindExact { @@ -225,6 +234,7 @@ func TestDebugConversionsUseUnifiedAPITypesAndPreserveTransportFields(t *testing if err != nil { t.Fatal(err) } + if frame.Name != "main" || frame.Location.Line != 4 || frame.FunctionID != 12 { t.Fatalf("unexpected Unified API frame: %#v", frame) } @@ -235,6 +245,7 @@ func TestDebugConversionsUseUnifiedAPITypesAndPreserveTransportFields(t *testing if err != nil { t.Fatal(err) } + if variable.Name != "input" || variable.Value.Reference != 11 || !variable.Mutable || !variable.Param { t.Fatalf("unexpected Unified API variable: %#v", variable) } diff --git a/client/errors.go b/client/errors.go index 25ce169..ac036e5 100644 --- a/client/errors.go +++ b/client/errors.go @@ -4,10 +4,11 @@ import ( "errors" "fmt" + "google.golang.org/grpc/status" + "github.com/MontFerret/api/diagnostics" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/pkg/failure" - "google.golang.org/grpc/status" ) // Error is a decoded Wire RPC failure. Category is set only when the server diff --git a/client/errors_test.go b/client/errors_test.go index f24ab35..981ed1c 100644 --- a/client/errors_test.go +++ b/client/errors_test.go @@ -5,12 +5,13 @@ import ( "reflect" "testing" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api/diagnostics" "github.com/MontFerret/api/source" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/pkg/failure" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestDecodeErrorPreservesDomainDetailsAndTransportStatus(t *testing.T) { @@ -32,10 +33,12 @@ func TestDecodeErrorPreservesDomainDetailsAndTransportStatus(t *testing.T) { Primary: true, }}, }}} + withDetails, err := status.New(codes.InvalidArgument, "compilation failed").WithDetails(detail, diagnosticSet) if err != nil { t.Fatal(err) } + transportErr := withDetails.Err() decoded := decodeError(transportErr) @@ -95,6 +98,7 @@ func TestDecodeErrorLeavesNativeTransportStatusesCategoryFree(t *testing.T) { codes.ResourceExhausted, } { decoded := decodeError(status.Error(code, "transport failure")) + var wireErr *Error if !errors.As(decoded, &wireErr) || wireErr.Category != 0 || status.Code(decoded) != code { t.Errorf("status %s decoded as %#v", code, decoded) @@ -125,6 +129,7 @@ func TestErrorCategoryConversionMapsEveryProtocolDetail(t *testing.T) { if err != nil { t.Fatal(err) } + if got != test.want { t.Errorf("convertErrorCategory(%v) = %v, want %v", test.protocol, got, test.want) } @@ -133,6 +138,7 @@ func TestErrorCategoryConversionMapsEveryProtocolDetail(t *testing.T) { if _, err := convertErrorCategory(wirev1.ErrorCategory_ERROR_CATEGORY_UNSPECIFIED, false); err == nil { t.Fatal("terminal failure accepted an unspecified category") } + if _, err := convertErrorCategory(wirev1.ErrorCategory(99), true); err == nil { t.Fatal("unknown protocol error category was accepted") } diff --git a/client/execution.go b/client/execution.go index 88c2bdd..41ab383 100644 --- a/client/execution.go +++ b/client/execution.go @@ -43,6 +43,7 @@ func (e *executionHandle) Watch(ctx context.Context) (*executionEvents, error) { } watchCtx, cancel := e.client.watchContext(ctx) + stream, err := e.client.executionClient.WatchExecution(watchCtx, &wirev1.WatchExecutionRequest{ ConnectionId: e.client.connectionProto(), ExecutionId: &wirev1.ExecutionId{Value: e.id}, diff --git a/client/execution_test.go b/client/execution_test.go index 411af7d..ad7a77b 100644 --- a/client/execution_test.go +++ b/client/execution_test.go @@ -6,12 +6,13 @@ import ( "testing" "time" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" wireexecution "github.com/MontFerret/wire/pkg/execution" "github.com/MontFerret/wire/pkg/failure" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestExecutionStateTerminal(t *testing.T) { @@ -49,6 +50,7 @@ func TestExecutionStateConversionMapsEveryProtocolValue(t *testing.T) { if err != nil { t.Fatal(err) } + if got != test.want { t.Errorf("convertExecutionState(%v) = %v, want %v", test.protocol, got, test.want) } @@ -77,12 +79,14 @@ func TestExecutionConversionDefensivelyCopiesOutputAndFailure(t *testing.T) { }, }, ) + snapshot, err := convertExecutionSnapshot(protocol) if err != nil { t.Fatal(err) } protocol.Output.Content[0] = 'X' + protocol.Failure.DiagnosticSet.Diagnostics[0].Annotations[0].Message = "changed" if string(snapshot.Output.Content) != "partial" || snapshot.Failure == nil || snapshot.Failure.Category != failure.CategoryExecution || @@ -174,11 +178,14 @@ func TestExecutionWaitUsesFreshWatchWithoutCachingOutput(t *testing.T) { if err != nil { t.Fatal(err) } + first.Content[0] = 'X' + second, err := execution.Wait(testClientContext(t)) if err != nil { t.Fatal(err) } + if string(second.Content) != "original" { t.Fatalf("Wait reused mutable output: %q", second.Content) } @@ -213,7 +220,7 @@ func TestExecutionWaitPropagatesContextAndStreamErrors(t *testing.T) { cancel() select { case err := <-result: - if err != context.Canceled { + if err != context.Canceled { //nolint:errorlint // Require the original caller cancellation error, without wrapping. t.Fatalf("Wait did not return the caller context error: %#v", err) } case <-time.After(10 * time.Second): @@ -229,6 +236,7 @@ func TestExecutionWaitPropagatesContextAndStreamErrors(t *testing.T) { _, _, execution := openTestExecution(t, server) _, err := execution.Wait(testClientContext(t)) + var wireErr *Error if !errors.As(err, &wireErr) || status.Code(err) != codes.Unavailable || wireErr.Message != "watch transport failed" { t.Fatalf("unexpected stream failure: %#v", err) @@ -276,10 +284,12 @@ func TestExecutionWaitRejectsIncompleteTerminalSnapshots(t *testing.T) { func openTestExecution(t *testing.T, server *clientTestServer) (*connectionHandle, *planHandle, *executionHandle) { t.Helper() client := openTestClient(t, startClientTestServer(t, server)) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, false, runtimePlanOptions{}) if err != nil { t.Fatal(err) } + t.Cleanup(func() { if err := plan.Close(testClientContext(t)); err != nil { t.Errorf("plan cleanup failed: %v", err) @@ -290,6 +300,7 @@ func openTestExecution(t *testing.T, server *clientTestServer) (*connectionHandl if err != nil { t.Fatal(err) } + t.Cleanup(func() { if err := execution.Close(testClientContext(t)); err != nil { t.Errorf("execution cleanup failed: %v", err) @@ -327,6 +338,7 @@ func executionCompletedEvent(id string, contentType string, content []byte) *wir func executionFailedEvent(id string, content []byte, failure *wirev1.Failure) *wirev1.WatchExecutionResponse { var output *wirev1.Output + if content != nil { output = &wirev1.Output{ContentType: "text/plain", Content: append([]byte(nil), content...)} } diff --git a/client/handle_lifecycle_test.go b/client/handle_lifecycle_test.go index a728222..a9e3b11 100644 --- a/client/handle_lifecycle_test.go +++ b/client/handle_lifecycle_test.go @@ -7,9 +7,10 @@ import ( "testing" "time" - "github.com/MontFerret/api" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/MontFerret/api" ) func TestHandleCloseContinuesAfterFirstCallerCancellation(t *testing.T) { @@ -23,10 +24,12 @@ func TestHandleCloseContinuesAfterFirstCallerCancellation(t *testing.T) { } connection := startHandleServer(t, implementation) client := openHandleClient(t, connection) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, false, runtimePlanOptions{}) if err != nil { t.Fatal(err) } + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) @@ -43,9 +46,11 @@ func TestHandleCloseContinuesAfterFirstCallerCancellation(t *testing.T) { } cancelFirst() + 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.Watch(testClientContext(t)); !errors.Is(err, ErrClosed) { t.Fatalf("closed execution accepted a watch: %v", err) } @@ -55,10 +60,12 @@ func TestHandleCloseContinuesAfterFirstCallerCancellation(t *testing.T) { go func() { second <- execution.Close(secondCtx) }() unblock(allow) want := receiveCloseResult(t, second, "later execution close") + var wireErr *Error if !errors.As(want, &wireErr) || status.Code(want) != codes.Internal || wireErr.Message != "retained release failure" { t.Fatalf("unexpected release result: %#v", want) } + if err := execution.Close(testClientContext(t)); !errors.As(err, &wireErr) || err.Error() != want.Error() { t.Fatalf("repeated close did not retain the first result: %#v", err) } @@ -66,6 +73,7 @@ func TestHandleCloseContinuesAfterFirstCallerCancellation(t *testing.T) { implementation.mu.Lock() releaseCalls := implementation.releaseExecutionCalls implementation.mu.Unlock() + if releaseCalls != 1 { t.Fatalf("close issued %d release RPCs", releaseCalls) } @@ -81,10 +89,12 @@ func TestConcurrentHandleCloseReleasesOnce(t *testing.T) { } connection := startHandleServer(t, implementation) client := openHandleClient(t, connection) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, false, runtimePlanOptions{}) if err != nil { t.Fatal(err) } + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) @@ -107,12 +117,14 @@ func TestConcurrentHandleCloseReleasesOnce(t *testing.T) { case <-time.After(10 * time.Second): t.Fatal("execution close did not reach the server") } + unblock(allow) for range callers { if err := receiveCloseResult(t, results, "concurrent execution close"); err != nil { t.Fatalf("concurrent close changed the retained success: %v", err) } } + if err := execution.Close(testClientContext(t)); err != nil { t.Fatalf("repeated close changed the retained success: %v", err) } @@ -120,6 +132,7 @@ func TestConcurrentHandleCloseReleasesOnce(t *testing.T) { implementation.mu.Lock() releaseCalls := implementation.releaseExecutionCalls implementation.mu.Unlock() + if releaseCalls != 1 { t.Fatalf("concurrent close issued %d release RPCs", releaseCalls) } @@ -136,14 +149,17 @@ func TestDescendantCloseDuringAncestorCloseObservesRetainedResult(t *testing.T) } connection := startHandleServer(t, implementation) client := openHandleClient(t, connection) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, true, runtimePlanOptions{}) if err != nil { t.Fatal(err) } + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) } + debug, err := plan.NewDebugSession(testClientContext(t), runtimeSessionOptions{}) if err != nil { t.Fatal(err) @@ -175,15 +191,18 @@ func TestDescendantCloseDuringAncestorCloseObservesRetainedResult(t *testing.T) unblock(allow) want := receiveCloseResult(t, planResult, "plan close") + var wireErr *Error if !errors.As(want, &wireErr) || status.Code(want) != codes.Internal || wireErr.Message != "retained ancestor failure" { t.Fatalf("unexpected ancestor release result: %#v", want) } + for name, result := range map[string]<-chan error{ "execution close": executionResult, "debug session close": debugResult, } { err := receiveCloseResult(t, result, name) + var descendantErr *Error if !errors.As(err, &descendantErr) || status.Code(err) != status.Code(want) || descendantErr.Message != wireErr.Message { t.Fatalf("%s did not observe the retained ancestor result: %#v", name, err) @@ -194,6 +213,7 @@ func TestDescendantCloseDuringAncestorCloseObservesRetainedResult(t *testing.T) implementation.mu.Lock() planReleaseCalls := implementation.releasePlanCalls implementation.mu.Unlock() + if planReleaseCalls != 1 || countCall(calls, call("release-execution", "connection-1", "execution-1")) != 0 || countCall(calls, call("release-debug", "connection-1", "debug-connection-1")) != 0 { @@ -205,14 +225,17 @@ func TestDescendantCloseAfterAncestorCloseObservesRetainedResult(t *testing.T) { implementation := &handleServer{} connection := startHandleServer(t, implementation) client := openHandleClient(t, connection) + plan, err := client.compileConfigured(testClientContext(t), api.Source{Content: "RETURN 1"}, true, runtimePlanOptions{}) if err != nil { t.Fatal(err) } + execution, err := startTestPlanExecution(testClientContext(t), plan, nil) if err != nil { t.Fatal(err) } + debug, err := plan.NewDebugSession(testClientContext(t), runtimeSessionOptions{}) if err != nil { t.Fatal(err) @@ -221,15 +244,19 @@ func TestDescendantCloseAfterAncestorCloseObservesRetainedResult(t *testing.T) { if err := plan.Close(testClientContext(t)); err != nil { t.Fatal(err) } + 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) { t.Fatalf("debug session survived plan close: %v", err) } + if err := execution.Close(testClientContext(t)); err != nil { t.Fatalf("execution did not observe ancestor close: %v", err) } + if err := debug.Close(testClientContext(t)); err != nil { t.Fatalf("debug session did not observe ancestor close: %v", err) } @@ -238,6 +265,7 @@ func TestDescendantCloseAfterAncestorCloseObservesRetainedResult(t *testing.T) { implementation.mu.Lock() planReleaseCalls := implementation.releasePlanCalls implementation.mu.Unlock() + if planReleaseCalls != 1 || countCall(calls, call("release-execution", "connection-1", "execution-1")) != 0 || countCall(calls, call("release-debug", "connection-1", "debug-connection-1")) != 0 { @@ -250,6 +278,7 @@ func TestZeroValueHandlesAreClosed(t *testing.T) { 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) } @@ -258,6 +287,7 @@ func TestZeroValueHandlesAreClosed(t *testing.T) { 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) } @@ -266,6 +296,7 @@ func TestZeroValueHandlesAreClosed(t *testing.T) { if err := debug.Start(testClientContext(t)); !errors.Is(err, ErrClosed) { t.Fatalf("zero debug session accepted start: %v", err) } + if err := debug.Close(testClientContext(t)); !errors.Is(err, ErrClosed) { t.Fatalf("zero debug close was not closed: %v", err) } diff --git a/client/lifecycle_test.go b/client/lifecycle_test.go index 6aab804..c331aa0 100644 --- a/client/lifecycle_test.go +++ b/client/lifecycle_test.go @@ -8,14 +8,15 @@ import ( "testing" "time" - "github.com/MontFerret/api" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - wireexecution "github.com/MontFerret/wire/pkg/execution" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + + "github.com/MontFerret/api" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + wireexecution "github.com/MontFerret/wire/pkg/execution" ) func TestBoundedCleanup(t *testing.T) { @@ -87,6 +88,7 @@ func (s *lifecycleServer) CloseConnection(context.Context, *wirev1.CloseConnecti detail := &wirev1.ErrorDetail{ Category: wirev1.ErrorCategory_ERROR_CATEGORY_CONNECTION_NOT_FOUND, } + withDetails, err := status.New(codes.NotFound, "resource not found").WithDetails(detail) if err != nil { return nil, err @@ -121,10 +123,12 @@ func (s *lifecycleServer) WatchExecution(_ *wirev1.WatchExecutionRequest, stream func TestCloseAfterServerDisconnectTreatsMissingConnectionAsSettled(t *testing.T) { server := &lifecycleServer{disconnect: true} connection := startLifecycleServer(t, server) + client, err := newConnection(testClientContext(t), connection) if err != nil { t.Fatal(err) } + select { case <-client.streamDone: case <-time.After(10 * time.Second): @@ -134,8 +138,10 @@ func TestCloseAfterServerDisconnectTreatsMissingConnectionAsSettled(t *testing.T if err := client.Close(testClientContext(t)); err != nil { t.Fatalf("close-after-disconnect did not settle successfully: %v", err) } + cancelled, cancel := context.WithCancel(context.Background()) cancel() + if err := client.Close(cancelled); err != nil { t.Fatalf("completed close did not retain its result: %v", err) } @@ -144,13 +150,16 @@ 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 := newConnection(testClientContext(t), connection) if err != nil { t.Fatal(err) } + 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) @@ -172,7 +181,9 @@ func TestCloseRejectsNewOperationsAndCancelsFacadeWatchers(t *testing.T) { 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) + if err := <-closeResult; err != nil { t.Fatal(err) } @@ -206,8 +217,10 @@ func startLifecycleServer(t *testing.T, implementation *lifecycleServer) *grpc.C if err != nil { t.Fatal(err) } + t.Cleanup(func() { server.Stop() + if err := connection.Close(); err != nil { t.Errorf("transport cleanup failed: %v", err) } @@ -215,6 +228,7 @@ func startLifecycleServer(t *testing.T, implementation *lifecycleServer) *grpc.C if err := listener.Close(); err != nil { t.Errorf("listener cleanup failed: %v", err) } + select { case err := <-serveDone: if err != nil && !errors.Is(err, grpc.ErrServerStopped) { diff --git a/client/ownership_test.go b/client/ownership_test.go index 20c3f4f..6cc4891 100644 --- a/client/ownership_test.go +++ b/client/ownership_test.go @@ -10,15 +10,16 @@ import ( "testing" "time" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" "github.com/MontFerret/api/source" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" wiredebugger "github.com/MontFerret/wire/pkg/debugger" "github.com/MontFerret/wire/pkg/execution" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/test/bufconn" ) type handleServer struct { @@ -325,8 +326,10 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { if err != nil { t.Fatal(err) } + parameters := plan.Parameters() parameters[0] = "changed" + if got := plan.Parameters(); !slices.Equal(got, []string{"input"}) { t.Fatalf("plan metadata was not immutable: %v", got) } @@ -339,14 +342,17 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { if err != nil { t.Fatal(err) } + secondExecution, err := startTestPlanExecution(testClientContext(t), plan, map[string]any{"input": 2}) if err != nil { t.Fatal(err) } + executionEvents, err := firstExecution.Watch(testClientContext(t)) if err != nil { t.Fatal(err) } + if event, err := executionEvents.Recv(); err != nil || event.Snapshot.State != execution.StateRunning { t.Fatalf("unexpected execution event: %#v, %v", event, err) } @@ -355,6 +361,7 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { 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, @@ -371,34 +378,42 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { if err != nil { t.Fatal(err) } + if breakpoint.ID != 1 || !breakpoint.Bound || breakpoint.RequestedLocation.SourceName != "query.fql" || breakpoint.Location.SourceName != "query.fql" || breakpoint.Location.Span != (source.Span{}) || breakpoint.PointID != 0 || breakpoint.FunctionID != 0 { t.Fatalf("unexpected Unified API breakpoint: %#v", breakpoint) } + if err := debug.DeleteBreakpoint(testClientContext(t), breakpoint.ID); err != nil { t.Fatal(err) } + frames, err := debug.Frames(testClientContext(t)) if err != nil || len(frames) != 1 || frames[0].Name != "main" || frames[0].FunctionID != 0 || frames[0].Location.SourceName != "query.fql" { t.Fatalf("unexpected Unified API frames: %#v, %v", frames, err) } + locals, err := debug.FrameLocals(testClientContext(t), 0) if err != nil || len(locals) != 1 || !locals[0].Param || locals[0].Value.Reference != 2 { t.Fatalf("unexpected Unified API locals: %#v, %v", locals, err) } + variables, err := debug.Variables(testClientContext(t), debugger.ValueReference(1)) if err != nil || len(variables) != 1 || variables[0].Value.Display != "2" { t.Fatalf("unexpected Unified API variables: %#v, %v", variables, err) } + evaluated, err := debug.EvaluateFrame(testClientContext(t), 0, "1 + 2") if err != nil || evaluated.Reference != 3 || evaluated.Display != "3" { t.Fatalf("unexpected Unified API value: %#v, %v", evaluated, err) } + debugEvents, err := debug.Watch(testClientContext(t)) if err != nil { t.Fatal(err) } + if event, err := debugEvents.Recv(); err != nil || event.Snapshot.State != wiredebugger.StateStopped || event.Snapshot.StopReason != debugger.ReasonBreakpoint || event.Snapshot.Location == nil || event.Snapshot.Location.SourceName != "query.fql" || len(event.Snapshot.HitBreakpointIDs) != 1 || event.Snapshot.HitBreakpointIDs[0] != 1 { @@ -408,21 +423,27 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { if err := debug.Close(testClientContext(t)); err != nil { t.Fatal(err) } + if err := debug.Close(testClientContext(t)); err != nil { t.Fatalf("repeated debug close changed its result: %v", err) } + if err := firstExecution.Close(testClientContext(t)); err != nil { t.Fatal(err) } + if err := firstExecution.Close(testClientContext(t)); err != nil { t.Fatalf("repeated execution close changed its result: %v", err) } + if err := secondExecution.Close(testClientContext(t)); err != nil { t.Fatal(err) } + if err := plan.Close(testClientContext(t)); err != nil { t.Fatal(err) } + if err := plan.Close(testClientContext(t)); err != nil { t.Fatalf("repeated plan close changed its result: %v", err) } @@ -438,6 +459,7 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { 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, call("release-execution", "connection-1", "execution-1"), call("release-execution", "connection-1", "execution-2"), @@ -449,6 +471,7 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { t.Errorf("missing call %q in %v", expected, calls) } } + for _, released := range []string{ call("release-debug", "connection-1", debugID), call("release-execution", "connection-1", "execution-1"), @@ -463,10 +486,12 @@ func TestHandleOperationsUseBoundOwnerResources(t *testing.T) { func openHandleClient(t *testing.T, connection grpc.ClientConnInterface) *connectionHandle { t.Helper() + client, err := newConnection(testClientContext(t), connection) if err != nil { t.Fatal(err) } + t.Cleanup(func() { if err := client.Close(testClientContext(t)); err != nil { t.Errorf("client cleanup failed: %v", err) @@ -498,8 +523,10 @@ func startHandleServer(t *testing.T, implementation *handleServer) *grpc.ClientC if err != nil { t.Fatal(err) } + t.Cleanup(func() { server.Stop() + if err := connection.Close(); err != nil { t.Errorf("transport cleanup failed: %v", err) } diff --git a/client/params.go b/client/params.go index 6fc67c5..616b9ae 100644 --- a/client/params.go +++ b/client/params.go @@ -4,8 +4,9 @@ import ( "fmt" "math" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "google.golang.org/protobuf/types/known/structpb" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) const maxParameterDepth = 64 @@ -16,10 +17,12 @@ func encodeParameters(values map[string]any) (*wirev1.Parameters, error) { if name == "" { return nil, fmt.Errorf("parameter name must not be empty") } + converted, err := encodeValue(value, 0) if err != nil { return nil, fmt.Errorf("parameter %q: %w", name, err) } + result.Values[name] = converted } @@ -88,6 +91,7 @@ func encodeValue(value any, depth int) (*wirev1.Value, error) { if err != nil { return nil, fmt.Errorf("array item %d: %w", i, err) } + items[i] = converted } @@ -98,10 +102,12 @@ func encodeValue(value any, depth int) (*wirev1.Value, error) { if name == "" { return nil, fmt.Errorf("object key must not be empty") } + converted, err := encodeValue(item, depth+1) if err != nil { return nil, fmt.Errorf("object field %q: %w", name, err) } + fields[name] = converted } diff --git a/client/params_test.go b/client/params_test.go index 89c5e58..a1bd225 100644 --- a/client/params_test.go +++ b/client/params_test.go @@ -58,6 +58,7 @@ func TestEncodeParametersRejectsUnsupportedAndOutOfRangeValues(t *testing.T) { if err != nil { t.Fatal(err) } + if boundaries.GetValues()["minimum"].GetIntegerValue() != math.MinInt64 || boundaries.GetValues()["maximum"].GetIntegerValue() != math.MaxInt64 { t.Fatalf("signed int64 boundaries changed: %#v", boundaries.GetValues()) @@ -67,6 +68,7 @@ func TestEncodeParametersRejectsUnsupportedAndOutOfRangeValues(t *testing.T) { for range maxParameterDepth { nested = []any{nested} } + _, err = encodeParameters(map[string]any{"nested": nested}) if err == nil || !strings.Contains(err.Error(), "nesting") { t.Fatalf("unexpected nesting error: %v", err) diff --git a/client/plan_test.go b/client/plan_test.go index fcec252..502201f 100644 --- a/client/plan_test.go +++ b/client/plan_test.go @@ -4,11 +4,12 @@ import ( "errors" "testing" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/pkg/failure" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestCompileMapsCanonicalSource(t *testing.T) { @@ -20,6 +21,7 @@ func TestCompileMapsCanonicalSource(t *testing.T) { if err != nil { t.Fatal(err) } + defer func() { if err := plan.Close(); err != nil { t.Errorf("close plan: %v", err) @@ -30,6 +32,7 @@ func TestCompileMapsCanonicalSource(t *testing.T) { name := server.lastCompileSourceName content := server.lastCompileContent server.mu.Unlock() + if name != src.Name || content != src.Content { t.Fatalf("unexpected transported source: name=%q content=%q", name, content) } @@ -42,6 +45,7 @@ func TestSessionRunPreservesCallerOwnedPlan(t *testing.T) { executionCompletedEvent("execution-1", "text/plain", []byte("done")), }}}} client := openTestRuntime(t, startClientTestServer(t, server)) + plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) @@ -56,6 +60,7 @@ func TestSessionRunPreservesCallerOwnedPlan(t *testing.T) { if err != nil || string(output.Content) != "done" { t.Fatalf("unexpected Session.Run result: %#v, %v", output, err) } + _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() if releaseExecutionCalls != 1 || releasePlanCalls != 0 { t.Fatalf("Session.Run cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) @@ -63,6 +68,7 @@ func TestSessionRunPreservesCallerOwnedPlan(t *testing.T) { executionDeadline, planDeadline := server.releaseDeadlineSnapshot() assertCleanupDeadline(t, "execution", executionDeadline) + if !planDeadline.IsZero() { t.Fatalf("Session.Run released the caller-owned plan with deadline %v", planDeadline) } @@ -71,9 +77,11 @@ func TestSessionRunPreservesCallerOwnedPlan(t *testing.T) { if err != nil { t.Fatalf("Session.Run closed the caller-owned plan: %v", err) } + if err := plan.Close(); err != nil { t.Fatal(err) } + if err := extra.Close(); err != nil { t.Fatal(err) } @@ -90,6 +98,7 @@ func TestSessionRunPreservesCallerOwnedPlan(t *testing.T) { releaseExecutionErr: status.Error(codes.Internal, "execution cleanup failed"), } client := openTestRuntime(t, startClientTestServer(t, server)) + plan, err := client.Compile(testClientContext(t), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) @@ -102,15 +111,18 @@ func TestSessionRunPreservesCallerOwnedPlan(t *testing.T) { 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("Session.Run did not preserve joined errors: %#v, %v", output, err) } + _, _, releaseExecutionCalls, releasePlanCalls := server.callSnapshot() if releaseExecutionCalls != 1 || releasePlanCalls != 0 { t.Fatalf("Session.Run failure cleanup: execution=%d plan=%d", releaseExecutionCalls, releasePlanCalls) } + if err := plan.Close(); err != nil { t.Fatal(err) } diff --git a/client/remote_debug_event.go b/client/remote_debug_event.go index f48cd1d..b589183 100644 --- a/client/remote_debug_event.go +++ b/client/remote_debug_event.go @@ -36,6 +36,7 @@ func remoteDebuggerEvent(event wiredebugger.Event) (*debugger.Event, bool, error return result, true, nil case wiredebugger.StateCompleted: result := &debugger.Event{Reason: debugger.ReasonCompleted} + if snapshot.Output != nil { result.Output = &api.Output{ ContentType: snapshot.Output.ContentType, diff --git a/client/remote_debug_session.go b/client/remote_debug_session.go index 19a6d3d..293cac7 100644 --- a/client/remote_debug_session.go +++ b/client/remote_debug_session.go @@ -180,6 +180,7 @@ func (d *remoteDebugSession) Breakpoints() []debugger.Breakpoint { for _, breakpoint := range d.breakpoints { result = append(result, breakpoint) } + d.breakpointMu.Unlock() sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) diff --git a/client/remote_plan.go b/client/remote_plan.go index 0c546e9..455e033 100644 --- a/client/remote_plan.go +++ b/client/remote_plan.go @@ -43,6 +43,7 @@ func (p *remotePlan) NewSession(ctx context.Context, options ...api.SessionOptio session, err := p.plan.newSession(creationCtx, configured) cancel() + if err != nil { return nil, p.plan.client.reclaimAllocation(ctx, err, p.plan.Close) } @@ -80,6 +81,7 @@ func (p *remotePlan) NewDebugSession( 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 c8a6981..894d45e 100644 --- a/client/remote_session.go +++ b/client/remote_session.go @@ -28,6 +28,7 @@ func (s *remoteSession) Run(ctx context.Context) (api.Output, error) { execution, err := s.session.run(creationCtx) cancel() + if err != nil { return api.Output{}, s.session.client.reclaimAllocation(ctx, err, s.session.Close, s.session.plan.Close) } diff --git a/client/runtime.go b/client/runtime.go index 6d603a7..f1c1e35 100644 --- a/client/runtime.go +++ b/client/runtime.go @@ -4,8 +4,9 @@ import ( "context" "errors" - "github.com/MontFerret/api" "google.golang.org/grpc" + + "github.com/MontFerret/api" ) // remoteRuntime is a remote implementation of the Universal Ferret API. It owns @@ -53,6 +54,7 @@ func (r *remoteRuntime) Run(ctx context.Context, src api.Source, options ...api. execution, err := r.client.run(creationCtx, src, configured) cancel() + if err != nil { return api.Output{}, r.client.reclaimAllocation(ctx, err) } @@ -96,6 +98,7 @@ func (r *remoteRuntime) compile( plan, err := r.client.compileConfigured(creationCtx, src, debuggable, configured) cancel() + if err != nil { return nil, r.client.reclaimAllocation(ctx, err) } diff --git a/client/runtime_allocation_test.go b/client/runtime_allocation_test.go index b03c946..20fd320 100644 --- a/client/runtime_allocation_test.go +++ b/client/runtime_allocation_test.go @@ -12,6 +12,7 @@ func TestRuntimeAllocationContextRetainsValuesAndBoundsDetachedAcquisition(t *te parent, cancelParent := context.WithCancel(context.WithValue(context.Background(), contextKey{}, "retained")) defer cancelParent() before := time.Now() + allocation, cancel, err := runtimeAllocationContext(parent) if err != nil { t.Fatal(err) @@ -19,6 +20,7 @@ func TestRuntimeAllocationContextRetainsValuesAndBoundsDetachedAcquisition(t *te defer cancel() cancelParent() + if allocation.Err() != nil || allocation.Value(contextKey{}) != "retained" { t.Fatal("allocation lost context values or inherited caller cancellation") } @@ -29,6 +31,7 @@ func TestRuntimeAllocationContextRetainsValuesAndBoundsDetachedAcquisition(t *te } cancel() + if !errors.Is(allocation.Err(), context.Canceled) { t.Fatal("allocation cancellation did not terminate its context") } diff --git a/client/runtime_contract_test.go b/client/runtime_contract_test.go index 7308a53..35a0760 100644 --- a/client/runtime_contract_test.go +++ b/client/runtime_contract_test.go @@ -10,9 +10,10 @@ import ( "strings" "testing" + "google.golang.org/grpc" + "github.com/MontFerret/api" "github.com/MontFerret/wire/client" - "google.golang.org/grpc" ) // The constructor returns the canonical interface, including a nil interface @@ -52,6 +53,7 @@ func TestPublicSurface(t *testing.T) { } 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_options_test.go b/client/runtime_options_test.go index a1000cb..48a93dc 100644 --- a/client/runtime_options_test.go +++ b/client/runtime_options_test.go @@ -42,6 +42,7 @@ func TestRuntimeOptionsAggregateFailuresAndPortableValidation(t *testing.T) { configured, err := applyRuntimeSessionOptions([]api.SessionOption{ func(options api.SessionOptions) error { calls++ + if setErr := options.SetParam("unsupported", make(chan struct{})); setErr != nil { return errors.Join(firstErr, setErr) } @@ -51,6 +52,7 @@ func TestRuntimeOptionsAggregateFailuresAndPortableValidation(t *testing.T) { nil, func(options api.SessionOptions) error { calls++ + if setErr := options.SetOutputContentType("application/json"); setErr != nil { return errors.Join(secondErr, setErr) } @@ -58,6 +60,7 @@ func TestRuntimeOptionsAggregateFailuresAndPortableValidation(t *testing.T) { return secondErr }, }) + if calls != 2 { t.Fatalf("not all non-nil options were applied: %d", calls) } diff --git a/pkg/debugger/debugger.go b/pkg/debugger/debugger.go index 953b242..4638358 100644 --- a/pkg/debugger/debugger.go +++ b/pkg/debugger/debugger.go @@ -34,6 +34,7 @@ type ( } ) +// Debug states distinguish an unstarted session, active commands, stops, and terminal outcomes. const ( StateCreated State = iota + 1 StateRunning @@ -43,6 +44,7 @@ const ( StateTerminated ) +// Debug event kinds preserve creation, initial start, resume, stop, and terminal transitions. const ( EventStarted EventKind = iota + 1 EventContinued diff --git a/pkg/execution/execution.go b/pkg/execution/execution.go index 6954f2a..a926b55 100644 --- a/pkg/execution/execution.go +++ b/pkg/execution/execution.go @@ -23,6 +23,7 @@ type ( } ) +// Execution states distinguish a running operation from its terminal outcome. const ( StateRunning State = iota + 1 StateCompleted diff --git a/pkg/failure/failure.go b/pkg/failure/failure.go index c3a031a..661946b 100644 --- a/pkg/failure/failure.go +++ b/pkg/failure/failure.go @@ -16,6 +16,7 @@ type ( } ) +// Failure categories preserve the structured conditions represented by the Wire protocol. const ( CategoryCompilation Category = iota + 1 CategoryExecution diff --git a/pkg/failure/failure_test.go b/pkg/failure/failure_test.go index 2272c25..b0d8bd5 100644 --- a/pkg/failure/failure_test.go +++ b/pkg/failure/failure_test.go @@ -48,6 +48,7 @@ func TestFailureCategoriesAreDistinctAndNonZero(t *testing.T) { if category == 0 { t.Fatal("a transmitted failure category used the zero value") } + if _, exists := seen[category]; exists { t.Fatalf("duplicate failure category value %d", category) } diff --git a/server/allocation_response_gate_test.go b/server/allocation_response_gate_test.go index 75cf334..f73a062 100644 --- a/server/allocation_response_gate_test.go +++ b/server/allocation_response_gate_test.go @@ -25,16 +25,6 @@ type allocationResponseGate struct { methods []string } -func (g *allocationResponseGate) arm(method, outcome string) { - g.mu.Lock() - defer g.mu.Unlock() - - g.method = method - g.outcome = outcome - g.committed = make(chan struct{}) - g.deliver = make(chan struct{}) -} - func (g *allocationResponseGate) Invoke(ctx context.Context, method string, request, response any, options ...grpc.CallOption) error { g.mu.Lock() g.calls[method]++ @@ -43,11 +33,13 @@ func (g *allocationResponseGate) Invoke(ctx context.Context, method string, requ responseFailure := g.responseFailures[method] matched := method == g.method outcome, committed, deliver := g.outcome, g.committed, g.deliver + if matched { g.method = "" } g.mu.Unlock() + if failure != nil { return failure } @@ -94,28 +86,3 @@ func (g *allocationResponseGate) count(method string) int { return g.calls[method] } - -func (g *allocationResponseGate) fail(method string, err error) { - g.mu.Lock() - defer g.mu.Unlock() - - g.failures[method] = err -} - -func (g *allocationResponseGate) failResponse(method string, err error) { - g.mu.Lock() - defer g.mu.Unlock() - - if g.responseFailures == nil { - g.responseFailures = make(map[string]error) - } - - g.responseFailures[method] = err -} - -func (g *allocationResponseGate) methodSequence() []string { - g.mu.Lock() - defer g.mu.Unlock() - - return append([]string(nil), g.methods...) -} diff --git a/server/architecture_test.go b/server/architecture_test.go index 86fa200..23476fb 100644 --- a/server/architecture_test.go +++ b/server/architecture_test.go @@ -16,6 +16,7 @@ import ( func TestPackageDependencyDirection(t *testing.T) { root := repositoryRoot(t) + legacyRuntimePackage := filepath.Join(root, "pkg", "runtime") if _, err := os.Stat(legacyRuntimePackage); err == nil { t.Errorf("obsolete shared package remains at %s", legacyRuntimePackage) @@ -27,6 +28,7 @@ func TestPackageDependencyDirection(t *testing.T) { if err != nil { t.Fatal(err) } + for _, entry := range entries { if !entry.IsDir() && filepath.Ext(entry.Name()) == ".go" { t.Errorf("module root contains Go source %s; the root compatibility package must remain absent", entry.Name()) @@ -47,6 +49,7 @@ func TestPackageDependencyDirection(t *testing.T) { if walkErr != nil { return walkErr } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { return nil } @@ -55,6 +58,7 @@ func TestPackageDependencyDirection(t *testing.T) { if err != nil { return err } + for _, spec := range parsed.Imports { importPath := strings.Trim(spec.Path.Value, "\"") for _, forbidden := range check.forbidden { diff --git a/server/integration_test.go b/server/integration_test.go index c1e5c89..19c8039 100644 --- a/server/integration_test.go +++ b/server/integration_test.go @@ -9,6 +9,12 @@ import ( "testing" "time" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "github.com/MontFerret/api" "github.com/MontFerret/api/diagnostics" "github.com/MontFerret/api/source" @@ -16,11 +22,6 @@ import ( wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/server" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/status" - "google.golang.org/grpc/test/bufconn" ) type integrationEnv struct { @@ -53,6 +54,7 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { 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) @@ -119,6 +121,7 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { sources := append([]api.Source(nil), runtime.sources...) debug := append([]bool(nil), runtime.debug...) runtime.mu.Unlock() + if len(sources) != 1 || sources[0] != (api.Source{Name: "unified.fql", Content: "RETURN @input"}) || debug[0] { t.Fatalf("unexpected compile delegation: %#v %#v", sources, debug) } @@ -126,6 +129,7 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { plan.mu.Lock() options := append([]apiSessionOptions(nil), plan.sessionOptions...) plan.mu.Unlock() + if len(options) != 2 || options[0].contentType != "application/json" || options[1].contentType != "application/json" { t.Fatalf("plan was not reusable with expected options: %#v", options) } @@ -148,6 +152,7 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { plan.mu.Lock() planCloseCalls := plan.closeCalls plan.mu.Unlock() + if planCloseCalls != 1 { t.Fatalf("Wire closed API plan %d times", planCloseCalls) } @@ -155,6 +160,7 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { runtime.mu.Lock() runtimeCloseCalls := runtime.closeCalls runtime.mu.Unlock() + if runtimeCloseCalls != 0 { t.Fatalf("Wire closed borrowed API runtime %d times", runtimeCloseCalls) } @@ -179,6 +185,7 @@ 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"}) if err != nil { t.Fatal(err) @@ -220,6 +227,7 @@ func TestServerShutdownClosesOwnedResourcesWithoutClosingRuntime(t *testing.T) { session.mu.Lock() sessionCloseCalls := session.closeCalls session.mu.Unlock() + if sessionCloseCalls != 1 { t.Fatalf("shutdown closed API session %d times", sessionCloseCalls) } @@ -227,6 +235,7 @@ func TestServerShutdownClosesOwnedResourcesWithoutClosingRuntime(t *testing.T) { plan.mu.Lock() planCloseCalls := plan.closeCalls plan.mu.Unlock() + if planCloseCalls != 1 { t.Fatalf("shutdown closed API plan %d times", planCloseCalls) } @@ -234,6 +243,7 @@ func TestServerShutdownClosesOwnedResourcesWithoutClosingRuntime(t *testing.T) { runtime.mu.Lock() runtimeCloseCalls := runtime.closeCalls runtime.mu.Unlock() + if runtimeCloseCalls != 0 { t.Fatalf("shutdown closed borrowed API runtime %d times", runtimeCloseCalls) } @@ -264,6 +274,7 @@ func TestGenericRuntimeFailuresAreStructuredAndSanitized(t *testing.T) { }} env := newIntegrationEnv(t, runtime) _, 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) @@ -284,6 +295,7 @@ func TestPortableDiagnosticsCrossImmediateAndAsynchronousFailures(t *testing.T) env := newIntegrationEnv(t, runtime) _, 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) @@ -318,6 +330,7 @@ func TestPortableDiagnosticsCrossImmediateAndAsynchronousFailures(t *testing.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) @@ -367,6 +380,7 @@ func TestMessageLimitsRemainAtTheGRPCBoundary(t *testing.T) { streamCtx, cancel := context.WithCancel(context.Background()) defer cancel() + stream, err := wirev1.NewRuntimeServiceClient(env.conn).Connect(streamCtx, &wirev1.ConnectRequest{}) if err != nil { t.Fatal(err) @@ -405,6 +419,7 @@ func TestMessageLimitsRemainAtTheGRPCBoundary(t *testing.T) { func newIntegrationEnv(t testing.TB, runtime api.Runtime, options ...server.Option) *integrationEnv { t.Helper() + server, err := server.NewServer(runtime, options...) if err != nil { t.Fatal(err) @@ -413,6 +428,7 @@ func newIntegrationEnv(t testing.TB, runtime api.Runtime, options ...server.Opti listener := bufconn.Listen(8 << 20) serveErr := make(chan error, 1) go func() { serveErr <- server.Serve(context.Background(), listener) }() + conn, err := grpc.NewClient( "passthrough:///ferret-wire-test", grpc.WithTransportCredentials(insecure.NewCredentials()), @@ -433,6 +449,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(); err != nil && !errors.Is(err, client.ErrClosed) && !env.shutdown { t.Errorf("client cleanup failed: %v", err) } @@ -462,6 +479,7 @@ func newIntegrationEnv(t testing.TB, runtime api.Runtime, options ...server.Opti func assertTransportNeutralParams(t *testing.T, values map[string]any) { t.Helper() + input, ok := values["input"].(map[string]any) if !ok { t.Fatalf("unexpected parameter map: %#v", values) diff --git a/server/internal/core/api.go b/server/internal/core/api.go index 9c1ed80..a053531 100644 --- a/server/internal/core/api.go +++ b/server/internal/core/api.go @@ -1,3 +1,4 @@ +// Package core owns Wire's logical resource lifetimes and protocol-neutral operations. package core import ( diff --git a/server/internal/core/breakpoint_set_test.go b/server/internal/core/breakpoint_set_test.go index 8d2f1fd..d1b5da2 100644 --- a/server/internal/core/breakpoint_set_test.go +++ b/server/internal/core/breakpoint_set_test.go @@ -19,6 +19,7 @@ func TestBreakpointSetOwnsOnlyBookkeepingAndCapacity(t *testing.T) { } set.add(value) + got, err := set.get(value.ID) if err != nil || got != value { t.Fatalf("unexpected stored breakpoint: %#v, %v", got, err) @@ -29,6 +30,7 @@ func TestBreakpointSetOwnsOnlyBookkeepingAndCapacity(t *testing.T) { } set.delete(value.ID) + if _, err := set.get(value.ID); !hasCategory(err, ErrorKindBreakpointNotFound) { t.Fatalf("deleted breakpoint remained registered: %v", err) } @@ -61,6 +63,7 @@ func TestDebugSessionCommitsBreakpointBookkeepingOnlyAfterRuntimeSuccess(t *test } setFailure = nil + value, err := session.SetBreakpointAt(context.Background(), location, debugger.BreakpointOptions{}) if err != nil { t.Fatalf("failed set consumed local capacity: %v", err) @@ -75,6 +78,7 @@ func TestDebugSessionCommitsBreakpointBookkeepingOnlyAfterRuntimeSuccess(t *test } deleteFailure = nil + if err := session.DeleteBreakpoint(context.Background(), value.ID); err != nil { t.Fatalf("failed delete corrupted local state: %v", err) } diff --git a/server/internal/core/compile.go b/server/internal/core/compile.go index 6228f74..8d16396 100644 --- a/server/internal/core/compile.go +++ b/server/internal/core/compile.go @@ -5,9 +5,10 @@ import ( "errors" "fmt" + "github.com/google/uuid" + "github.com/MontFerret/api" "github.com/MontFerret/wire/server/internal/panicboundary" - "github.com/google/uuid" ) // CompilePlan creates a connection-owned plan using the borrowed hosted runtime. @@ -32,6 +33,7 @@ func CompilePlan(ctx context.Context, runtime api.Runtime, store *ResourceStore, defer func() { store.finishCreation(planResource, nil, committed) }() compile := runtime.Compile + if debug { compile = runtime.CompileDebug } @@ -46,6 +48,7 @@ func CompilePlan(ctx context.Context, runtime api.Runtime, store *ResourceStore, } compileErr := compilationError("compilation failed", err) + if !isNil(compiled) { return nil, errors.Join(compileErr, closeAPIPlan(compiled)) } diff --git a/server/internal/core/component_boundary_test.go b/server/internal/core/component_boundary_test.go index 6b3a766..475eca1 100644 --- a/server/internal/core/component_boundary_test.go +++ b/server/internal/core/component_boundary_test.go @@ -36,10 +36,12 @@ func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { runtime := &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }} + host, err := newTestHost(runtime, testLimits()) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) @@ -63,9 +65,11 @@ func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { if err != nil || retained.plan != plan { t.Fatal("Wire plan did not retain the API plan") } + plan.mu.Lock() plan.params[0] = "changed by runtime" plan.mu.Unlock() + if got := retained.Params(); !reflect.DeepEqual(got, []string{"input"}) { t.Fatalf("Wire plan retained runtime parameter storage: %#v", got) } @@ -79,6 +83,7 @@ func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { if executeErr != nil { t.Fatal(executeErr) } + finished := waitExecution(t, connection, execution.ID) if finished.State != wireexecution.StateCompleted || finished.Output == nil || finished.Output.ContentType != "application/json" { t.Fatalf("unexpected execution result: %#v", finished) @@ -92,6 +97,7 @@ func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { if lookupErr != nil { continue } + if got := execution.Snapshot().Output.Content[0]; got != '{' { t.Fatalf("execution retained runtime output storage: %q", got) } @@ -123,6 +129,7 @@ func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { if err := connection.ReleasePlan(testContext(t), compiled.ID); err != nil { t.Fatal(err) } + _, _, planCloseCalls := plan.snapshot() if planCloseCalls != 1 { t.Fatalf("API plan closed %d times", planCloseCalls) @@ -134,10 +141,12 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { runtime := &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return compiled, nil }} + host, err := newTestHost(runtime, testLimits()) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) @@ -145,6 +154,7 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() + if _, err := connection.Compile(ctx, compileRequest{Source: api.Source{Name: "debug.fql", Content: "RETURN 1"}, Debuggable: true}); !errors.Is(err, context.Canceled) { t.Fatalf("unexpected cancelled compile result: %v", err) } @@ -153,6 +163,7 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { if err != nil { t.Fatal(err) } + sources, debug, _ := runtime.snapshot() if len(sources) != 1 || sources[0] != (api.Source{Name: "debug.fql", Content: "RETURN 1"}) || !debug[0] { t.Fatalf("unexpected compile delegation: %#v %#v", sources, debug) @@ -161,13 +172,16 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { if err := connection.ReleasePlan(testContext(t), plan.ID); err != nil { t.Fatal(err) } + runtime.compile = func(context.Context, api.Source, bool) (api.Plan, error) { return &spyPlan{}, nil } + anonymous, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}) if err != nil { t.Fatal(err) } + sources, debug, _ = runtime.snapshot() if len(sources) != 2 || sources[1] != (api.Source{Name: "anonymous", Content: "RETURN 2"}) || debug[1] { t.Fatalf("unexpected anonymous source delegation: %#v %#v", sources, debug) @@ -180,7 +194,7 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { started := make(chan struct{}) release := make(chan struct{}) abandoned := &spyPlan{} - runtime.compile = func(ctx context.Context, _ api.Source, _ bool) (api.Plan, error) { + runtime.compile = func(_ context.Context, _ api.Source, _ bool) (api.Plan, error) { close(started) <-release @@ -195,9 +209,11 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { <-started cancelCompile() close(release) + if err := <-compileResult; !errors.Is(err, context.Canceled) { t.Fatalf("unexpected abandoned compile result: %v", err) } + _, _, closeCalls := abandoned.snapshot() if closeCalls != 1 { t.Fatalf("abandoned API plan closed %d times", closeCalls) @@ -208,10 +224,12 @@ func TestCompileForwardsOptionalOptimizationLevel(t *testing.T) { runtime := &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return &spyPlan{}, nil }} + host, err := newTestHost(runtime, testLimits()) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) @@ -243,6 +261,7 @@ func TestCompileForwardsOptionalOptimizationLevel(t *testing.T) { if len(got) != len(levels) { t.Fatalf("recorded %d optimization values, want %d", len(got), len(levels)) } + for index, want := range levels { if got[index] != want { t.Fatalf("optimization %d = %+v, want %+v", index, got[index], want) @@ -257,6 +276,7 @@ func TestCompilePanicsAreSanitizedAndCloseReturnedPlansOnce(t *testing.T) { }}) _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) + var panicErr *panicboundary.Error if !hasCategory(err, ErrorKindInternal) || !errors.As(err, &panicErr) || strings.Contains(err.Error(), "secret") { t.Fatalf("compile panic was not sanitized: %v", err) @@ -270,6 +290,7 @@ func TestCompilePanicsAreSanitizedAndCloseReturnedPlansOnce(t *testing.T) { }}) _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) + var panicErr *panicboundary.Error if !hasCategory(err, ErrorKindInternal) || !errors.As(err, &panicErr) || strings.Contains(err.Error(), "secret") { t.Fatalf("metadata panic was not sanitized: %v", err) @@ -309,6 +330,7 @@ func TestSessionConstructionPanicsAreSanitized(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) @@ -318,6 +340,7 @@ func TestSessionConstructionPanicsAreSanitized(t *testing.T) { if err != nil { t.Fatal(err) } + finished := waitExecution(t, connection, execution.ID) if finished.State != wireexecution.StateFailed || finished.Failure == nil || finished.Failure.Category != failure.CategoryInternalRuntime || strings.Contains(finished.Failure.Message, "secret") { @@ -332,6 +355,7 @@ func TestSessionConstructionPanicsAreSanitized(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, @@ -341,6 +365,7 @@ func TestSessionConstructionPanicsAreSanitized(t *testing.T) { } _, err = connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) + var panicErr *panicboundary.Error if !hasCategory(err, ErrorKindInternal) || !errors.As(err, &panicErr) || strings.Contains(err.Error(), "secret") { t.Fatalf("debug constructor panic was not sanitized: %v", err) @@ -359,6 +384,7 @@ func TestBoundaryPanicsDoNotPoisonReusableParents(t *testing.T) { return &spyPlan{}, nil }} + connection := newTestConnection(t, runtime) if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}); !hasCategory(err, ErrorKindInternal) { t.Fatalf("first compile did not contain panic: %v", err) @@ -387,6 +413,7 @@ func TestBoundaryPanicsDoNotPoisonReusableParents(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) @@ -434,6 +461,7 @@ func TestBoundaryPanicsDoNotPoisonReusableParents(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, @@ -463,6 +491,7 @@ func TestBoundaryPanicsDoNotPoisonReusableParents(t *testing.T) { func TestSuccessfulNilAPIResourcesAreRejectedSafely(t *testing.T) { var nilPlan *spyPlan + connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return nilPlan, nil }}) @@ -477,14 +506,17 @@ func TestSuccessfulNilAPIResourcesAreRejectedSafely(t *testing.T) { connection = newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + finished := waitExecution(t, connection, execution.ID) if finished.State != wireexecution.StateFailed || finished.Failure == nil || finished.Failure.Category != failure.CategoryInternalRuntime { t.Fatalf("typed-nil session was not rejected: %#v", finished) @@ -518,10 +550,12 @@ func TestAPIResourcesReturnedWithErrorsAreClosedOnce(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) @@ -547,6 +581,7 @@ func TestAPIResourcesReturnedWithErrorsAreClosedOnce(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) @@ -573,6 +608,7 @@ func TestAbandonedDebugSessionCleanupPanicIsSanitized(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) @@ -621,24 +657,30 @@ func TestExecutionUsesPortableFailureFallbacks(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + finished := waitExecution(t, connection, execution.ID) if finished.State != wireexecution.StateFailed || finished.Failure == nil || finished.Failure.Category != test.want { t.Fatalf("unexpected failure: %#v", finished) } + if (finished.Output != nil) != test.wantOutput { t.Fatalf("unexpected output presence: %#v", finished.Output) } + if strings.Contains(finished.Failure.Message, "secret") { t.Fatalf("runtime detail leaked: %#v", finished.Failure) } + runCalls, closeCalls := session.counts() if runCalls != 1 || closeCalls != 1 { t.Fatalf("unexpected session lifecycle: run=%d close=%d", runCalls, closeCalls) @@ -665,10 +707,12 @@ func TestExecutionUsesPortableFailureFallbacks(t *testing.T) { func newTestConnection(t *testing.T, runtime api.Runtime) *testEnvironment { t.Helper() + host, err := newTestHost(runtime, testLimits()) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) @@ -679,10 +723,12 @@ func newTestConnection(t *testing.T, runtime api.Runtime) *testEnvironment { func waitExecution(t *testing.T, connection *testEnvironment, id ExecutionID) executionResult { t.Helper() + execution, err := connection.resources.Execution(context.Background(), id) if err != nil { t.Fatal(err) } + select { case <-execution.done: case <-time.After(5 * time.Second): @@ -723,13 +769,14 @@ func TestWireOwnedExecutionOperationPanicPropagates(t *testing.T) { execution := &Execution{operation: func(context.Context) (api.Output, error) { panic(sentinel) }} + recovered := func() (value any) { defer func() { value = recover() }() execution.run() return nil }() - if recovered != sentinel { + if recovered != sentinel { //nolint:errorlint // Verify Wire-owned panics propagate the original value unchanged. t.Fatalf("Wire-owned panic was changed: %#v", recovered) } diff --git a/server/internal/core/connection.go b/server/internal/core/connection.go index 0f1a016..6b2ca15 100644 --- a/server/internal/core/connection.go +++ b/server/internal/core/connection.go @@ -4,8 +4,9 @@ import ( "context" "errors" - "github.com/MontFerret/wire/server/internal/lifecycle" "github.com/google/uuid" + + "github.com/MontFerret/wire/server/internal/lifecycle" ) // Connection is the logical identity and lifetime established by the Connect @@ -29,14 +30,17 @@ func newConnection(limits ResourceLimits) *Connection { } } +// ID identifies this logical connection independently of its physical transport. func (c *Connection) ID() ConnectionID { return c.id } +// Context is cancelled when logical connection teardown begins. func (c *Connection) Context() context.Context { return c.ctx } +// Resources returns the store owned by this logical connection. func (c *Connection) Resources() *ResourceStore { return c.resources } diff --git a/server/internal/core/connection_registry.go b/server/internal/core/connection_registry.go index a4dbe37..b80e671 100644 --- a/server/internal/core/connection_registry.go +++ b/server/internal/core/connection_registry.go @@ -16,6 +16,7 @@ type ConnectionRegistry struct { closed bool } +// NewConnectionRegistry sets connection capacity and the limits inherited by each connection. func NewConnectionRegistry(maxConnections int, limits ResourceLimits) *ConnectionRegistry { return &ConnectionRegistry{ max: maxConnections, @@ -25,6 +26,7 @@ func NewConnectionRegistry(maxConnections int, limits ResourceLimits) *Connectio } } +// Open admits a logical connection unless shutdown or connection capacity prevents it. func (r *ConnectionRegistry) Open() (*Connection, error) { r.mu.Lock() defer r.mu.Unlock() @@ -43,6 +45,7 @@ func (r *ConnectionRegistry) Open() (*Connection, error) { return connection, nil } +// Get returns an active connection; closing connections are no longer discoverable. func (r *ConnectionRegistry) Get(id ConnectionID) (*Connection, error) { if err := validateID(id, "connection ID"); err != nil { return nil, err @@ -65,6 +68,7 @@ func (r *ConnectionRegistry) beginClose(id ConnectionID) (*Connection, bool, err } r.mu.Lock() + connection := r.active[id] if connection != nil { delete(r.active, id) @@ -90,6 +94,7 @@ func (r *ConnectionRegistry) remove(id ConnectionID, expected *Connection) { if r.closing[id] == expected { delete(r.closing, id) } + r.mu.Unlock() } @@ -104,11 +109,14 @@ func (r *ConnectionRegistry) beginShutdown() []ConnectionID { for id := range r.closing { ids = append(ids, id) } + r.mu.Unlock() return ids } +// CloseConnection commits teardown once and waits using the caller's context. +// Teardown continues if that context is cancelled. func (r *ConnectionRegistry) CloseConnection(ctx context.Context, id ConnectionID) error { connection, started, err := r.beginClose(id) if err != nil { @@ -126,6 +134,8 @@ func (r *ConnectionRegistry) CloseConnection(ctx context.Context, id ConnectionI return connection.waitClose(ctx) } +// Close rejects new connections and starts teardown of all retained connections. +// The caller's context bounds waiting, not ownership of teardown. func (r *ConnectionRegistry) Close(ctx context.Context) error { var result error for _, id := range r.beginShutdown() { diff --git a/server/internal/core/connection_registry_test.go b/server/internal/core/connection_registry_test.go index 15bf038..54607d9 100644 --- a/server/internal/core/connection_registry_test.go +++ b/server/internal/core/connection_registry_test.go @@ -18,6 +18,7 @@ func TestConnectionCapacityIsRetainedThroughCleanupAndShutdownRejectsAdmission(t return nil }} registry := NewConnectionRegistry(1, testLimits().resources()) + connection, err := registry.Open() if err != nil { t.Fatal(err) diff --git a/server/internal/core/context.go b/server/internal/core/context.go index acfdf8a..cc28bdf 100644 --- a/server/internal/core/context.go +++ b/server/internal/core/context.go @@ -6,6 +6,7 @@ import "context" // resource lifetime. Call cancel to detach the lifetime cancellation callback. func OperationContext(parent, lifetime context.Context) (context.Context, context.CancelFunc) { operation, cancel := context.WithCancelCause(parent) + stop := context.AfterFunc(lifetime, func() { cancel(context.Cause(lifetime)) }) if lifetime.Err() != nil { cancel(context.Cause(lifetime)) diff --git a/server/internal/core/context_test.go b/server/internal/core/context_test.go index 21b265f..37bed4b 100644 --- a/server/internal/core/context_test.go +++ b/server/internal/core/context_test.go @@ -37,6 +37,7 @@ func (c *callbackLifetime) AfterFunc(func()) func() bool { func TestOperationContextDetachesLifetimeCallback(t *testing.T) { lifetime := &callbackLifetime{Context: context.Background(), done: make(chan struct{})} + operation, cancel := OperationContext(context.Background(), lifetime) if lifetime.callbacks.Load() != 1 { t.Fatal("operation did not register its lifetime callback") @@ -44,6 +45,7 @@ func TestOperationContextDetachesLifetimeCallback(t *testing.T) { cancel() cancel() + if lifetime.callbacks.Load() != 0 || !errors.Is(operation.Err(), context.Canceled) { t.Fatal("operation cancellation did not detach its lifetime callback") } @@ -59,6 +61,7 @@ func TestOperationContextPreservesCancellationCauses(t *testing.T) { defer cancelLifetime(nil) cause := errors.New("cancellation cause") + if source == "already cancelled lifetime" { cancelLifetime(cause) } @@ -90,6 +93,7 @@ func TestOperationContextPreservesCancellationCauses(t *testing.T) { } want := context.Canceled + if source == "deadline" { want = context.DeadlineExceeded } diff --git a/server/internal/core/debug_boundary_spy_test.go b/server/internal/core/debug_boundary_spy_test.go index 754ac61..1be03e4 100644 --- a/server/internal/core/debug_boundary_spy_test.go +++ b/server/internal/core/debug_boundary_spy_test.go @@ -57,7 +57,7 @@ func (d *boundaryDebugger) Frames() ([]debugger.Frame, error) { return []debugger.Frame{{Name: "main"}}, nil } -func (d *boundaryDebugger) FrameLocals(frame int) ([]debugger.Variable, error) { +func (d *boundaryDebugger) FrameLocals(_ int) ([]debugger.Variable, error) { if err := d.record("frame-locals"); err != nil { return nil, err } @@ -65,7 +65,7 @@ func (d *boundaryDebugger) FrameLocals(frame int) ([]debugger.Variable, error) { return []debugger.Variable{{Name: "frame"}}, nil } -func (d *boundaryDebugger) Variables(reference debugger.ValueReference) ([]debugger.Variable, error) { +func (d *boundaryDebugger) Variables(_ debugger.ValueReference) ([]debugger.Variable, error) { if err := d.record("variables"); err != nil { return nil, err } diff --git a/server/internal/core/debug_boundary_test.go b/server/internal/core/debug_boundary_test.go index 4d9eff7..1c3382d 100644 --- a/server/internal/core/debug_boundary_test.go +++ b/server/internal/core/debug_boundary_test.go @@ -143,6 +143,7 @@ func TestDebugSessionInspectionDetachesHostedSlices(t *testing.T) { hosted := &borrowedInspectionDebugger{frames: frames, locals: locals, values: variables} session := newTestCoreDebugSession(t, hosted, 1) session.state.status = wiredebugger.StateStopped + gotFrames, err := session.Frames(testContext(t)) if err != nil { t.Fatal(err) diff --git a/server/internal/core/debug_event.go b/server/internal/core/debug_event.go index 2585365..67cfba9 100644 --- a/server/internal/core/debug_event.go +++ b/server/internal/core/debug_event.go @@ -2,6 +2,8 @@ package core import wiredebugger "github.com/MontFerret/wire/pkg/debugger" +// DebugSubscription pairs a current snapshot with subsequent ordered events. +// Cancel releases the watcher slot, including after the event channels close. type DebugSubscription struct { Current wiredebugger.Event Events <-chan wiredebugger.Event diff --git a/server/internal/core/debug_session.go b/server/internal/core/debug_session.go index 2f43fc3..f3e6eef 100644 --- a/server/internal/core/debug_session.go +++ b/server/internal/core/debug_session.go @@ -5,6 +5,8 @@ import ( "errors" "sync" + "github.com/google/uuid" + "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" "github.com/MontFerret/api/source" @@ -12,9 +14,9 @@ import ( "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/server/internal/lifecycle" "github.com/MontFerret/wire/server/internal/panicboundary" - "github.com/google/uuid" ) +// DebugSession owns a hosted debugger, its command state, breakpoints, and event subscriptions. type DebugSession struct { // operationMu serializes state-dependent operations and command commits. // The active runtime resume and close paths intentionally do not hold it @@ -51,10 +53,13 @@ func newDebugSession(plan *Plan, hosted debugger.Session) *DebugSession { return session } +// Release closes the hosted debugger and removes it from its plan's resource store. +// Caller cancellation stops waiting without abandoning teardown. func (d *DebugSession) Release(ctx context.Context) error { d.plan.store.mu.Lock() started := d.release.Begin() d.plan.store.mu.Unlock() + if started { go d.settleRelease() } @@ -77,16 +82,19 @@ func (d *DebugSession) settleRelease() { err = d.Close(context.Background()) } +// Close stops the hosted debugger once, retaining the logical handle until Release. func (d *DebugSession) Close(ctx context.Context) error { d.beginClose() return d.close.Wait(ctx) } +// ID identifies this debug session within its logical connection. func (d *DebugSession) ID() DebugSessionID { return d.id } +// Stop closes a nonterminal debugger and returns its terminal snapshot. func (d *DebugSession) Stop(ctx context.Context) (wiredebugger.Snapshot, error) { snapshot := d.Snapshot() if !snapshot.State.Terminal() { @@ -100,6 +108,7 @@ func (d *DebugSession) Stop(ctx context.Context) (wiredebugger.Snapshot, error) return snapshot, nil } +// Pause requests interruption of a running debugger; a later event reports the stop. func (d *DebugSession) Pause(ctx context.Context) (wiredebugger.Snapshot, error) { if err := ctx.Err(); err != nil { return wiredebugger.Snapshot{}, err @@ -128,6 +137,7 @@ func (d *DebugSession) Pause(ctx context.Context) (wiredebugger.Snapshot, error) return d.Snapshot(), nil } +// SetBreakpoint binds to the next executable location in the requested source. func (d *DebugSession) SetBreakpoint( ctx context.Context, location source.Location, @@ -137,6 +147,7 @@ func (d *DebugSession) SetBreakpoint( }) } +// SetBreakpointAt validates and installs a breakpoint in a created or stopped debugger. func (d *DebugSession) SetBreakpointAt( ctx context.Context, location source.Location, @@ -164,6 +175,7 @@ func (d *DebugSession) SetBreakpointAt( d.stateMu.Lock() status := d.state.status d.stateMu.Unlock() + if status != wiredebugger.StateCreated && status != wiredebugger.StateStopped { return debugger.Breakpoint{}, invalidState("breakpoints require a created or stopped debug session", nil) } @@ -192,6 +204,7 @@ func (d *DebugSession) SetBreakpointAt( return value, nil } +// DeleteBreakpoint removes a known breakpoint from a created or stopped debugger. func (d *DebugSession) DeleteBreakpoint(ctx context.Context, breakpointID debugger.BreakpointID) error { if err := ctx.Err(); err != nil { return err @@ -207,6 +220,7 @@ func (d *DebugSession) DeleteBreakpoint(ctx context.Context, breakpointID debugg d.stateMu.Lock() status := d.state.status d.stateMu.Unlock() + if status != wiredebugger.StateCreated && status != wiredebugger.StateStopped { return invalidState("breakpoints require a created or stopped debug session", nil) } @@ -233,26 +247,32 @@ func (d *DebugSession) DeleteBreakpoint(ctx context.Context, breakpointID debugg return nil } +// Start begins a created debugger asynchronously and returns its running snapshot. func (d *DebugSession) Start(ctx context.Context) (wiredebugger.Snapshot, error) { return d.start(ctx, true, d.session.Start) } +// Continue resumes a stopped debugger asynchronously and returns its running snapshot. func (d *DebugSession) Continue(ctx context.Context) (wiredebugger.Snapshot, error) { return d.start(ctx, false, d.session.Continue) } +// StepOver resumes a stopped debugger with the hosted step-over command. func (d *DebugSession) StepOver(ctx context.Context) (wiredebugger.Snapshot, error) { return d.start(ctx, false, d.session.StepOver) } +// StepIn resumes a stopped debugger with the hosted step-in command. func (d *DebugSession) StepIn(ctx context.Context) (wiredebugger.Snapshot, error) { return d.start(ctx, false, d.session.StepIn) } +// StepOut resumes a stopped debugger with the hosted step-out command. func (d *DebugSession) StepOut(ctx context.Context) (wiredebugger.Snapshot, error) { return d.start(ctx, false, d.session.StepOut) } +// Frames returns a detached frame slice while the debugger is stopped. func (d *DebugSession) Frames(ctx context.Context) ([]debugger.Frame, error) { d.operationMu.Lock() defer d.operationMu.Unlock() @@ -273,6 +293,7 @@ func (d *DebugSession) Frames(ctx context.Context) ([]debugger.Frame, error) { return append([]debugger.Frame(nil), values...), nil } +// FrameLocals reads variables in a nonnegative frame index while the debugger is stopped. func (d *DebugSession) FrameLocals(ctx context.Context, frame int) ([]debugger.Variable, error) { if frame < 0 { return nil, invalidRequest("frame index must not be negative") @@ -299,6 +320,7 @@ func (d *DebugSession) FrameLocals(ctx context.Context, frame int) ([]debugger.V return append([]debugger.Variable(nil), values...), nil } +// Variables expands a positive value reference while the debugger is stopped. func (d *DebugSession) Variables( ctx context.Context, reference debugger.ValueReference, @@ -328,6 +350,7 @@ func (d *DebugSession) Variables( return append([]debugger.Variable(nil), values...), nil } +// EvaluateFrame evaluates an expression in a stopped frame with caller and session cancellation. func (d *DebugSession) EvaluateFrame( ctx context.Context, frame int, @@ -365,6 +388,8 @@ func (d *DebugSession) EvaluateFrame( return value, nil } +// Watch reserves a bounded subscription with the current snapshot. +// The caller must cancel the subscription to release its watcher slot. func (d *DebugSession) Watch() (DebugSubscription, error) { subscription, err := d.events.subscribe() if err != nil { @@ -397,6 +422,7 @@ func (d *DebugSession) start( d.stateMu.Lock() expected := wiredebugger.StateStopped + if initial { expected = wiredebugger.StateCreated } @@ -409,6 +435,7 @@ func (d *DebugSession) start( d.state.beginRunning() kind := wiredebugger.EventContinued + if initial { kind = wiredebugger.EventStarted } @@ -452,6 +479,7 @@ func (d *DebugSession) finishCommand(event *debugger.Event, commandErr error) { } terminal := false + if commandErr != nil { if errors.Is(commandErr, context.Canceled) || errors.Is(context.Cause(d.ctx), context.Canceled) { d.state.status = wiredebugger.StateTerminated @@ -465,6 +493,7 @@ func (d *DebugSession) finishCommand(event *debugger.Event, commandErr error) { terminal = true } else { d.state.location = nil + if event.Location != (source.Range{}) { location := event.Location d.state.location = &location @@ -572,6 +601,7 @@ func (d *DebugSession) poisonAfterRuntimePanic(operation string, err error) erro return runtimePanicError(operation, err) } +// Snapshot returns Wire-visible debugger state detached from mutable session storage. func (d *DebugSession) Snapshot() wiredebugger.Snapshot { d.stateMu.Lock() defer d.stateMu.Unlock() diff --git a/server/internal/core/debug_session_test.go b/server/internal/core/debug_session_test.go index 8323c8c..f3e6ac7 100644 --- a/server/internal/core/debug_session_test.go +++ b/server/internal/core/debug_session_test.go @@ -19,6 +19,7 @@ import ( func TestDebugSessionRejectsInvalidCommandWithoutRuntimeOrEvent(t *testing.T) { runtime := &boundaryDebugger{} session := newTestCoreDebugSession(t, runtime, 1) + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -52,19 +53,23 @@ func TestDebugSessionPublishesAndReplaysCreatedAndRunningSnapshots(t *testing.T) return &debugger.Event{Reason: debugger.ReasonEntry}, nil }} session := newTestCoreDebugSession(t, runtime, 1) + created, err := session.Watch() if err != nil { t.Fatal(err) } + if created.Current.Sequence != 1 || created.Current.Kind != wiredebugger.EventCreated || created.Current.Snapshot.State != wiredebugger.StateCreated { t.Fatalf("unexpected created snapshot: %#v", created.Current) } + created.Cancel() if _, err := session.Start(context.Background()); err != nil { t.Fatal(err) } + <-entered running, err := session.Watch() @@ -72,12 +77,14 @@ func TestDebugSessionPublishesAndReplaysCreatedAndRunningSnapshots(t *testing.T) t.Fatal(err) } defer running.Cancel() + if running.Current.Sequence != 2 || running.Current.Kind != wiredebugger.EventStarted || running.Current.Snapshot.State != wiredebugger.StateRunning { t.Fatalf("unexpected running replay: %#v", running.Current) } close(release) + stopped := receiveDebugEvent(t, running.Events) if stopped.Sequence != 3 || stopped.Kind != wiredebugger.EventStopped || stopped.Snapshot.State != wiredebugger.StateStopped { t.Fatalf("unexpected stopped event: %#v", stopped) @@ -104,6 +111,7 @@ func TestDebugWatchDisconnectDoesNotCancelSession(t *testing.T) { } }} session := newTestCoreDebugSession(t, runtime, 1) + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -112,6 +120,7 @@ func TestDebugWatchDisconnectDoesNotCancelSession(t *testing.T) { if _, err := session.Start(context.Background()); err != nil { t.Fatal(err) } + <-entered _ = receiveDebugEvent(t, subscription.Events) subscription.Cancel() @@ -121,14 +130,17 @@ func TestDebugWatchDisconnectDoesNotCancelSession(t *testing.T) { t.Fatalf("watch disconnect settled runtime command; cancelled=%v", wasCancelled) case <-time.After(25 * time.Millisecond): } + if snapshot := session.Snapshot(); snapshot.State != wiredebugger.StateRunning { t.Fatalf("watch disconnect changed debug state: %#v", snapshot) } close(release) + if wasCancelled := <-cancelled; wasCancelled { t.Fatal("watch disconnect cancelled the debug operation") } + _ = waitCoreDebugState(t, session, wiredebugger.StateStopped) closeTestCoreDebugSession(t, session) } @@ -147,12 +159,15 @@ func TestDebugValueReferenceValidationUsesCurrentStoppedState(t *testing.T) { if _, err := session.Variables(context.Background(), 0); !hasCategory(err, ErrorKindInvalidRequest) { t.Fatalf("zero reference did not fail as invalid argument: %v", err) } + if calls.Load() != 0 { t.Fatal("zero reference reached the runtime") } + if _, err := session.Variables(context.Background(), 17); !hasCategory(err, ErrorKindInvalidState) || !errors.Is(err, runtimeErr) { t.Fatalf("stale positive reference did not use InvalidState: %v", err) } + if calls.Load() != 1 { t.Fatalf("positive reference reached the runtime %d times", calls.Load()) } @@ -177,6 +192,7 @@ func TestDebugSessionRuntimeErrorPreservesPortableDiagnostics(t *testing.T) { return &debugger.Event{Reason: debugger.ReasonRuntimeError, Error: errors.Join(errors.New("secret"), values)}, nil }} session := newTestCoreDebugSession(t, runtime, 1) + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -186,7 +202,9 @@ func TestDebugSessionRuntimeErrorPreservesPortableDiagnostics(t *testing.T) { if _, err := session.Start(context.Background()); err != nil { t.Fatal(err) } + _ = receiveDebugEvent(t, subscription.Events) + stopped := receiveDebugEvent(t, subscription.Events) if stopped.Kind != wiredebugger.EventStopped || stopped.Snapshot.State != wiredebugger.StateStopped || stopped.Snapshot.Failure == nil || stopped.Snapshot.Failure.Message != "runtime operation failed" || @@ -212,6 +230,7 @@ func TestDebugSessionFailurePreservesPortableDiagnostics(t *testing.T) { return &debugger.Event{Reason: debugger.ReasonTerminated, Error: values}, nil }} session := newTestCoreDebugSession(t, runtime, 1) + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -221,7 +240,9 @@ func TestDebugSessionFailurePreservesPortableDiagnostics(t *testing.T) { if _, err := session.Start(context.Background()); err != nil { t.Fatal(err) } + _ = receiveDebugEvent(t, subscription.Events) + failed := receiveDebugEvent(t, subscription.Events) if failed.Kind != wiredebugger.EventFailed || failed.Snapshot.State != wiredebugger.StateFailed || failed.Snapshot.Failure == nil || !reflect.DeepEqual(failed.Snapshot.Failure.Diagnostics, values) { @@ -237,6 +258,7 @@ func TestDebugSessionRuntimeFailurePublishesOrderedTerminalState(t *testing.T) { return nil, runtimeErr }} session := newTestCoreDebugSession(t, runtime, 1) + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -249,6 +271,7 @@ func TestDebugSessionRuntimeFailurePublishesOrderedTerminalState(t *testing.T) { } started := receiveDebugEvent(t, subscription.Events) + failed := receiveDebugEvent(t, subscription.Events) if started.Kind != wiredebugger.EventStarted || started.Snapshot.State != wiredebugger.StateRunning || failed.Kind != wiredebugger.EventFailed || failed.Snapshot.State != wiredebugger.StateFailed { @@ -266,6 +289,7 @@ func TestDebugSessionPauseFailurePreservesRunningStateWithoutEvent(t *testing.T) runtime := &spyDebugger{pause: func() error { return pauseErr }} session := newTestCoreDebugSession(t, runtime, 1) session.state.status = wiredebugger.StateRunning + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -292,6 +316,7 @@ func TestDebugSessionPauseFailurePreservesRunningStateWithoutEvent(t *testing.T) func TestDebugSessionCommandPanicPublishesFailureAndClosesRuntime(t *testing.T) { runtime := &boundaryDebugger{panicOn: "start"} session := newTestCoreDebugSession(t, runtime, 1) + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -304,6 +329,7 @@ func TestDebugSessionCommandPanicPublishesFailureAndClosesRuntime(t *testing.T) } started := receiveDebugEvent(t, subscription.Events) + failed := receiveDebugEvent(t, subscription.Events) if started.Kind != wiredebugger.EventStarted || failed.Kind != wiredebugger.EventFailed || failed.Snapshot.State != wiredebugger.StateFailed { t.Fatalf("unexpected panic event order: %#v then %#v", started, failed) @@ -315,6 +341,7 @@ func TestDebugSessionCommandPanicPublishesFailureAndClosesRuntime(t *testing.T) } waitDebuggerCalls(t, runtime, []string{"start", "close"}) + if _, err := session.Continue(context.Background()); !hasCategory(err, ErrorKindInvalidState) { t.Fatalf("poisoned session accepted another command: %v", err) } @@ -371,6 +398,7 @@ func TestDebugSessionSynchronousPanicPoisonsAndClosesRuntime(t *testing.T) { runtime := &boundaryDebugger{panicOn: test.panicOn} session := newTestCoreDebugSession(t, runtime, 1) session.state.status = test.state + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -394,6 +422,7 @@ func TestDebugSessionSynchronousPanicPoisonsAndClosesRuntime(t *testing.T) { } waitDebuggerCalls(t, runtime, []string{test.panicOn, "close"}) + if err := test.call(session); !hasCategory(err, ErrorKindInvalidState) { t.Fatalf("poisoned session accepted another operation: %v", err) } @@ -486,6 +515,7 @@ func TestDebugSessionPauseCanInterruptRunningCommand(t *testing.T) { }, } session := newTestCoreDebugSession(t, runtime, 1) + subscription, err := session.Watch() if err != nil { t.Fatal(err) @@ -495,12 +525,14 @@ func TestDebugSessionPauseCanInterruptRunningCommand(t *testing.T) { if _, err := session.Start(context.Background()); err != nil { t.Fatal(err) } + <-started pauseSnapshot, err := session.Pause(context.Background()) if err != nil { t.Fatal(err) } + if pauseSnapshot.State != wiredebugger.StateRunning { t.Fatalf("pause response observed command completion early: %#v", pauseSnapshot) } @@ -569,12 +601,15 @@ func TestDebugSessionCloseReachesRuntimeDuringBlockedStoppedOperation(t *testing } close(releaseOperation) + if err := <-operationResult; err != nil { t.Fatal(err) } + if err := <-closeResult; err != nil { t.Fatal(err) } + if snapshot := session.Snapshot(); snapshot.State != wiredebugger.StateTerminated { t.Fatalf("close did not commit terminal state: %#v", snapshot) } diff --git a/server/internal/core/debug_state_test.go b/server/internal/core/debug_state_test.go index 6b72939..4703723 100644 --- a/server/internal/core/debug_state_test.go +++ b/server/internal/core/debug_state_test.go @@ -23,6 +23,7 @@ func TestDebugSessionStateBuildsDefensiveSnapshots(t *testing.T) { snapshot := state.snapshot() state.hitIDs[0] = 2 + state.output.Content[0] = '2' if snapshot.HitBreakpointIDs[0] != 1 || string(snapshot.Output.Content) != "1" { t.Fatalf("snapshot retained live state storage: %#v", snapshot) @@ -36,6 +37,7 @@ func TestDebugSessionStateBuildsDefensiveSnapshots(t *testing.T) { retained.Location == nil || *retained.Location != *state.location || retained.Location == state.location || retained.Depth != 2 { t.Fatalf("unexpected debug snapshot: %#v", retained) } + if retained.HitBreakpointIDs[0] != 2 || string(retained.Output.Content) != "2" { t.Fatalf("snapshot did not own mutable values: %#v", retained) } @@ -53,6 +55,7 @@ func TestDebugSessionStateTransitionsPreserveSupportingValues(t *testing.T) { } state.beginRunning() + if state.status != wiredebugger.StateRunning || state.reason != "" || state.location != nil || state.hitIDs != nil || state.depth != 0 || state.failure != nil || state.output == nil { t.Fatalf("unexpected running state: %#v", state) @@ -61,6 +64,7 @@ func TestDebugSessionStateTransitionsPreserveSupportingValues(t *testing.T) { state.hitIDs = []debugger.BreakpointID{2} state.failure = &failure.Failure{Category: failure.CategoryExecution} state.terminate() + if state.status != wiredebugger.StateTerminated || state.reason != "" || state.location != nil || state.depth != 0 || state.failure != nil || len(state.hitIDs) != 1 || state.output == nil { t.Fatalf("unexpected terminated state: %#v", state) diff --git a/server/internal/core/domain_error.go b/server/internal/core/domain_error.go index 46e502e..19f5f94 100644 --- a/server/internal/core/domain_error.go +++ b/server/internal/core/domain_error.go @@ -3,8 +3,10 @@ package core import "github.com/MontFerret/wire/pkg/failure" type ( + // ErrorKind distinguishes lifecycle, input, capacity, and hosted-runtime failures. ErrorKind uint8 + // DomainError carries a classified failure and its private cause for transport mapping. DomainError struct { Kind ErrorKind ResourceID string @@ -13,6 +15,7 @@ type ( } ) +// Domain error kinds distinguish Wire conditions before transport-specific mapping. const ( ErrorKindInvalidRequest ErrorKind = iota + 1 ErrorKindCompilation diff --git a/server/internal/core/errors.go b/server/internal/core/errors.go index c873f65..f0feeba 100644 --- a/server/internal/core/errors.go +++ b/server/internal/core/errors.go @@ -7,6 +7,7 @@ import ( "github.com/MontFerret/wire/pkg/failure" ) +// ErrWatcherLagged reports that a bounded subscription could not retain an event. var ErrWatcherLagged = errors.New("wire watcher lagged") func invalidRequest(message string) error { diff --git a/server/internal/core/event_stream.go b/server/internal/core/event_stream.go index 50c6b73..efe308d 100644 --- a/server/internal/core/event_stream.go +++ b/server/internal/core/event_stream.go @@ -59,6 +59,7 @@ func (s *eventStream[T]) subscribe() (eventSubscription[T], error) { id := s.nextWatcher var current T + if s.hasLatest { current = s.clone(s.latest) } @@ -111,6 +112,7 @@ func (s *eventStream[T]) publish(event T, terminal bool) { s.sequence++ s.latest = s.withSequence(event, s.sequence) s.hasLatest = true + if terminal { s.closed = true } diff --git a/server/internal/core/event_stream_benchmark_test.go b/server/internal/core/event_stream_benchmark_test.go index c956631..58981a8 100644 --- a/server/internal/core/event_stream_benchmark_test.go +++ b/server/internal/core/event_stream_benchmark_test.go @@ -21,10 +21,12 @@ func BenchmarkExecutionEventPublication(b *testing.B) { b.Run("one watcher", func(b *testing.B) { execution := newPublicationBenchmarkExecution() + subscription, err := execution.Watch() if err != nil { b.Fatal(err) } + b.Cleanup(subscription.Cancel) b.ReportAllocs() diff --git a/server/internal/core/event_stream_test.go b/server/internal/core/event_stream_test.go index f5a61f6..dc30285 100644 --- a/server/internal/core/event_stream_test.go +++ b/server/internal/core/event_stream_test.go @@ -25,6 +25,7 @@ func (e streamTestEvent) withSequence(sequence uint64) streamTestEvent { func TestEventStreamPublishesMonotonicEventsAndReplaysLatest(t *testing.T) { stream := newStreamTestEventStream(2) + first, err := stream.subscribe() if err != nil { t.Fatal(err) @@ -42,13 +43,16 @@ func TestEventStreamPublishesMonotonicEventsAndReplaysLatest(t *testing.T) { if received.sequence != 1 || received.values[0] != 1 { t.Fatalf("unexpected first event: %#v", received) } + received.values[0] = 8 stream.publish(streamTestEvent{values: []int{2}}, false) + received = <-first.events if received.sequence != 2 || received.values[0] != 2 { t.Fatalf("unexpected second event: %#v", received) } + received.values[0] = 9 latest, err := stream.subscribe() @@ -64,6 +68,7 @@ func TestEventStreamPublishesMonotonicEventsAndReplaysLatest(t *testing.T) { func TestEventStreamUnsubscribeIsIdempotentAndReleasesCapacity(t *testing.T) { stream := newStreamTestEventStream(1) + subscription, err := stream.subscribe() if err != nil { t.Fatal(err) @@ -75,9 +80,11 @@ func TestEventStreamUnsubscribeIsIdempotentAndReleasesCapacity(t *testing.T) { subscription.cancel() subscription.cancel() + if _, open := <-subscription.events; open { t.Fatal("events channel remained open after unsubscribe") } + if _, open := <-subscription.errors; open { t.Fatal("errors channel remained open after unsubscribe") } @@ -86,11 +93,13 @@ func TestEventStreamUnsubscribeIsIdempotentAndReleasesCapacity(t *testing.T) { if err != nil { t.Fatalf("unsubscribe did not release capacity: %v", err) } + next.cancel() } func TestEventStreamEvictsLaggingWatcherAndRetainsSlotUntilCancel(t *testing.T) { stream := newStreamTestEventStream(1) + subscription, err := stream.subscribe() if err != nil { t.Fatal(err) @@ -103,10 +112,12 @@ func TestEventStreamEvictsLaggingWatcherAndRetainsSlotUntilCancel(t *testing.T) if err := <-subscription.errors; !errors.Is(err, ErrWatcherLagged) { t.Fatalf("unexpected lag error: %v", err) } + count := 0 for range subscription.events { count++ } + if count != watcherBufferSize { t.Fatalf("lagging watcher retained %d buffered events, want %d", count, watcherBufferSize) } @@ -116,31 +127,38 @@ func TestEventStreamEvictsLaggingWatcherAndRetainsSlotUntilCancel(t *testing.T) } subscription.cancel() + next, err := stream.subscribe() if err != nil { t.Fatalf("cancel did not release lagged subscription capacity: %v", err) } + if next.current.sequence != watcherBufferSize+1 { t.Fatalf("unexpected latest sequence after lag: %d", next.current.sequence) } + next.cancel() } func TestEventStreamTerminalPublishAndClose(t *testing.T) { stream := newStreamTestEventStream(2) + subscription, err := stream.subscribe() if err != nil { t.Fatal(err) } stream.publish(streamTestEvent{values: []int{1}}, true) + event := <-subscription.events if event.sequence != 1 || event.values[0] != 1 { t.Fatalf("unexpected terminal event: %#v", event) } + if _, open := <-subscription.events; open { t.Fatal("terminal event did not close the events channel") } + if _, open := <-subscription.errors; open { t.Fatal("terminal event did not close the errors channel") } @@ -149,15 +167,19 @@ func TestEventStreamTerminalPublishAndClose(t *testing.T) { if err != nil { t.Fatal(err) } + if late.current.sequence != 1 || late.current.values[0] != 1 { t.Fatalf("late subscription did not receive the terminal event: %#v", late.current) } + if _, open := <-late.events; open { t.Fatal("late terminal subscription received an open events channel") } + if _, open := <-late.errors; open { t.Fatal("late terminal subscription received an open errors channel") } + if _, err := stream.subscribe(); !errors.Is(err, errEventStreamLimit) { t.Fatalf("terminal subscriptions released capacity before cancel: %v", err) } @@ -167,27 +189,34 @@ func TestEventStreamTerminalPublishAndClose(t *testing.T) { subscription.cancel() closed := newStreamTestEventStream(1) + active, err := closed.subscribe() if err != nil { t.Fatal(err) } + closed.close() closed.close() + if _, open := <-active.events; open { t.Fatal("explicit close left an active events channel open") } + if _, err := closed.subscribe(); !errors.Is(err, errEventStreamLimit) { t.Fatalf("explicit close released capacity before cancel: %v", err) } + active.cancel() lateClosed, err := closed.subscribe() if err != nil { t.Fatalf("cancel did not release explicitly closed capacity: %v", err) } + if _, open := <-lateClosed.events; open { t.Fatal("late explicitly closed subscription received an open events channel") } + lateClosed.cancel() } @@ -207,6 +236,7 @@ func TestEventStreamSupportsConcurrentPublishSubscribeAndCancel(t *testing.T) { go func() { defer wait.Done() + subscription, err := stream.subscribe() if err != nil { subscriptionErrors <- err @@ -237,6 +267,7 @@ func TestEventStreamSupportsConcurrentPublishSubscribeAndCancel(t *testing.T) { func TestResourceWatchersRetainTheirLimitErrors(t *testing.T) { execution := &Execution{events: newEventStream(1, cloneExecutionEvent, sequenceExecutionEvent)} + executionSubscription, err := execution.Watch() if err != nil { t.Fatal(err) @@ -248,6 +279,7 @@ func TestResourceWatchersRetainTheirLimitErrors(t *testing.T) { } session := &DebugSession{events: newEventStream(1, cloneDebugEvent, sequenceDebugEvent)} + debugSubscription, err := session.Watch() if err != nil { t.Fatal(err) diff --git a/server/internal/core/execution.go b/server/internal/core/execution.go index 2d20906..2dfbaca 100644 --- a/server/internal/core/execution.go +++ b/server/internal/core/execution.go @@ -5,14 +5,16 @@ import ( "errors" "sync" + "github.com/google/uuid" + "github.com/MontFerret/api" wireexecution "github.com/MontFerret/wire/pkg/execution" "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/server/internal/lifecycle" "github.com/MontFerret/wire/server/internal/panicboundary" - "github.com/google/uuid" ) +// Execution owns one asynchronous run and retains its terminal result until release. type Execution struct { mu sync.Mutex id ExecutionID @@ -33,6 +35,7 @@ type Execution struct { func newExecution(store *ResourceStore, plan *Plan, session *Session, operation func(context.Context) (api.Output, error), options []api.SessionOption) *Execution { lifetime := store.ctx + if session != nil { lifetime = session.ctx } @@ -56,10 +59,13 @@ func newExecution(store *ResourceStore, plan *Plan, session *Session, operation return execution } +// Release cancels and joins the run, closes its event stream, and removes its handle. +// Caller cancellation stops waiting without abandoning teardown. func (e *Execution) Release(ctx context.Context) error { e.store.mu.Lock() started := e.release.Begin() e.store.mu.Unlock() + if started { go e.settleRelease() } @@ -119,12 +125,14 @@ func (e *Execution) run() { ContentType: output.ContentType, Content: append([]byte(nil), output.Content...), } + var panicErr *panicboundary.Error if errors.As(runErr, &panicErr) { result = nil } category := failure.CategoryInternalRuntime + if runErr != nil && panicErr == nil { category = failure.CategoryExecution } @@ -172,16 +180,19 @@ func (e *Execution) finish(output *api.Output, err error, category failure.Categ } } +// Cancel requests cancellation and returns the current snapshot without waiting for termination. func (e *Execution) Cancel() wireexecution.Snapshot { e.cancel(context.Canceled) return e.Snapshot() } +// ID identifies this run within its logical connection. func (e *Execution) ID() ExecutionID { return e.id } +// Snapshot returns execution state with mutable output and diagnostics detached. func (e *Execution) Snapshot() wireexecution.Snapshot { e.mu.Lock() defer e.mu.Unlock() @@ -189,6 +200,8 @@ func (e *Execution) Snapshot() wireexecution.Snapshot { return e.snapshotLocked() } +// Watch reserves a bounded subscription with the current snapshot. +// The caller must cancel the subscription to release its watcher slot. func (e *Execution) Watch() (ExecutionSubscription, error) { subscription, err := e.events.subscribe() if err != nil { diff --git a/server/internal/core/execution_benchmark_test.go b/server/internal/core/execution_benchmark_test.go index f8cbf4c..2943bf6 100644 --- a/server/internal/core/execution_benchmark_test.go +++ b/server/internal/core/execution_benchmark_test.go @@ -13,28 +13,34 @@ func BenchmarkCancelExecution(b *testing.B) { return api.Output{}, nil }}, nil }} + host, err := newTestHost(&spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}, testLimits()) if err != nil { b.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { b.Fatal(err) } + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { b.Fatal(err) } + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { b.Fatal(err) } + retained, err := connection.resources.Execution(context.Background(), execution.ID) if err != nil { b.Fatal(err) } + <-retained.done b.Cleanup(func() { if closeErr := connection.Close(context.Background()); closeErr != nil { @@ -62,24 +68,29 @@ func BenchmarkRunDurableSession(b *testing.B) { return api.Output{ContentType: "text/plain", Content: []byte("ok")}, nil }}, nil }} + host, err := newTestHost(&spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}, testLimits()) if err != nil { b.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { b.Fatal(err) } + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { b.Fatal(err) } + session, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}) if err != nil { b.Fatal(err) } + b.Cleanup(func() { if closeErr := connection.Close(context.Background()); closeErr != nil { b.Errorf("close connection: %v", closeErr) @@ -93,11 +104,14 @@ func BenchmarkRunDurableSession(b *testing.B) { if err != nil { b.Fatal(err) } + retained, err := connection.resources.Execution(context.Background(), run.ID) if err != nil { b.Fatal(err) } + <-retained.done + if err := connection.ReleaseExecution(context.Background(), run.ID); err != nil { b.Fatal(err) } diff --git a/server/internal/core/execution_event.go b/server/internal/core/execution_event.go index 218d377..731a79f 100644 --- a/server/internal/core/execution_event.go +++ b/server/internal/core/execution_event.go @@ -2,6 +2,8 @@ package core import "github.com/MontFerret/wire/pkg/execution" +// ExecutionSubscription pairs a current snapshot with subsequent ordered events. +// Cancel releases the watcher slot, including after the event channels close. type ExecutionSubscription struct { Current execution.Event Events <-chan execution.Event diff --git a/server/internal/core/lifecycle_boundary_test.go b/server/internal/core/lifecycle_boundary_test.go index aeed7ba..d34f5cf 100644 --- a/server/internal/core/lifecycle_boundary_test.go +++ b/server/internal/core/lifecycle_boundary_test.go @@ -30,10 +30,12 @@ func TestPendingCompileCountsAgainstLimitAndConnectionCloseWaits(t *testing.T) { }} limits := testLimits() limits.MaxPlansPerConnection = 1 + host, err := newTestHost(runtime, limits) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) @@ -45,6 +47,7 @@ func TestPendingCompileCountsAgainstLimitAndConnectionCloseWaits(t *testing.T) { compileResult <- compileErr }() <-started + if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("pending compile did not count against limit: %v", err) } @@ -58,12 +61,15 @@ func TestPendingCompileCountsAgainstLimitAndConnectionCloseWaits(t *testing.T) { } close(release) + if err := <-compileResult; !errors.Is(err, context.Canceled) { t.Fatalf("unexpected compile result: %v", err) } + if err := <-closeResult; err != nil { t.Fatal(err) } + _, _, closeCalls := plan.snapshot() if closeCalls != 1 { t.Fatalf("unpublished plan closed %d times", closeCalls) @@ -80,16 +86,19 @@ func TestPendingDebugCreationCountsAgainstLimitAndConnectionCloseWaits(t *testin }} limits := testLimits() limits.MaxDebugSessionsPerConnection = 1 + host, err := newTestHost(&spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}, limits) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) } + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) @@ -101,15 +110,18 @@ func TestPendingDebugCreationCountsAgainstLimitAndConnectionCloseWaits(t *testin openResult <- openErr }() <-started + if _, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("pending debug creation did not count against limit: %v", err) } closeResult := make(chan error, 1) go func() { closeResult <- connection.Close(context.Background()) }() + if err := <-openResult; !errors.Is(err, context.Canceled) { t.Fatalf("pending debug creation was not cancelled: %v", err) } + if err := <-closeResult; err != nil { t.Fatal(err) } @@ -135,14 +147,17 @@ func TestClosingPlanCountsAgainstLimitUntilCleanupSettles(t *testing.T) { }} limits := testLimits() limits.MaxPlansPerConnection = 1 + host, err := newTestHost(runtime, limits) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) } + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) @@ -151,16 +166,21 @@ func TestClosingPlanCountsAgainstLimitUntilCleanupSettles(t *testing.T) { releaseResult := make(chan error, 1) go func() { releaseResult <- connection.ReleasePlan(context.Background(), compiled.ID) }() <-started + if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("closing plan did not count against limit: %v", err) } + close(release) + if err := <-releaseResult; err != nil { t.Fatal(err) } + if err := connection.ReleasePlan(context.Background(), compiled.ID); !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("released plan did not become stale: %v", err) } + if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}); err != nil { t.Fatalf("settled cleanup did not release the plan slot: %v", err) } @@ -180,6 +200,7 @@ func TestConcurrentPlanReleaseSharesResultAndClosesOnce(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) @@ -195,16 +216,19 @@ func TestConcurrentPlanReleaseSharesResultAndClosesOnce(t *testing.T) { t.Fatalf("concurrent release settled before plan close: %v", err) case <-time.After(50 * time.Millisecond): } + close(release) for i, result := range []<-chan error{first, second} { if err := <-result; !errors.Is(err, closeErr) { t.Fatalf("release %d did not receive retained result: %v", i, err) } } + _, _, closeCalls := plan.snapshot() if closeCalls != 1 { t.Fatalf("plan closed %d times", closeCalls) } + if err := connection.ReleasePlan(context.Background(), compiled.ID); !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("released plan did not become stale: %v", err) } @@ -231,42 +255,53 @@ func TestResourceLimitsAndConnectionIsolationRemainWireOwned(t *testing.T) { limits.MaxPlansPerConnection = 1 limits.MaxExecutionsPerConnection = 1 limits.MaxDebugSessionsPerConnection = 1 + host, err := newTestHost(runtime, limits) if err != nil { t.Fatal(err) } + owner, err := host.OpenConnection() if err != nil { t.Fatal(err) } + other, err := host.OpenConnection() if err != nil { t.Fatal(err) } + if _, err := host.OpenConnection(); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("connection limit was bypassed: %v", err) } + compiled, err := owner.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) } + if _, err := owner.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("plan limit was bypassed: %v", err) } + execution, err := owner.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + if _, err := owner.Execute(context.Background(), executeRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("execution limit was bypassed: %v", err) } + debugSession, err := owner.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + if _, err := owner.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("debug session limit was bypassed: %v", err) } + if _, err := other.Execute(context.Background(), executeRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("plan crossed connection boundary: %v", err) } @@ -274,9 +309,11 @@ func TestResourceLimitsAndConnectionIsolationRemainWireOwned(t *testing.T) { if err := owner.ReleaseDebugSession(testContext(t), debugSession.ID); err != nil { t.Fatal(err) } + if err := owner.ReleaseExecution(testContext(t), execution.ID); err != nil { t.Fatal(err) } + if err := owner.ReleasePlan(testContext(t), compiled.ID); err != nil { t.Fatal(err) } @@ -287,16 +324,20 @@ func TestPlanClosePanicIsSanitizedAndDoesNotRetainResource(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + if err := connection.ReleasePlan(testContext(t), compiled.ID); !hasCategory(err, ErrorKindInternal) || strings.Contains(err.Error(), "secret") { t.Fatalf("plan panic was not sanitized: %v", err) } + if err := connection.ReleasePlan(context.Background(), compiled.ID); !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("panicking plan remained retained: %v", err) } + _, _, closeCalls := plan.snapshot() if closeCalls != 1 { t.Fatalf("panicking plan close attempted %d times", closeCalls) @@ -313,6 +354,7 @@ func TestConnectionCleanupContinuesAfterRuntimeClosePanic(t *testing.T) { return plan, nil }} + host, err := newTestHost(runtime, testLimits()) if err != nil { t.Fatal(err) @@ -334,12 +376,14 @@ func TestConnectionCleanupContinuesAfterRuntimeClosePanic(t *testing.T) { } err = host.CloseConnection(testContext(t), connection.ID()) + var panicErr *panicboundary.Error if !hasCategory(err, ErrorKindInternal) || !errors.As(err, &panicErr) || strings.Contains(err.Error(), "secret") { t.Fatalf("cleanup panic was not retained and sanitized: %v", err) } _, _, firstCloses := first.snapshot() + _, _, secondCloses := second.snapshot() if firstCloses != 1 || secondCloses != 1 { t.Fatalf("cleanup did not continue across panic: first=%d second=%d", firstCloses, secondCloses) @@ -361,16 +405,19 @@ func TestConcurrentConnectionCloseSharesResultAndThenBecomesStale(t *testing.T) return nil }} + host, err := newTestHost(&spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}, testLimits()) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) } + if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}); err != nil { t.Fatal(err) } @@ -385,18 +432,22 @@ func TestConcurrentConnectionCloseSharesResultAndThenBecomesStale(t *testing.T) t.Fatalf("concurrent close settled before cleanup: %v", err) case <-time.After(50 * time.Millisecond): } + close(release) for i, result := range []<-chan error{first, second} { if err := <-result; err != nil { t.Fatalf("connection close %d failed: %v", i, err) } } + if err := host.CloseConnection(context.Background(), connection.ID()); !hasCategory(err, ErrorKindConnectionNotFound) { t.Fatalf("closed connection did not become stale: %v", err) } + host.connections.mu.RLock() closing := len(host.connections.closing) host.connections.mu.RUnlock() + if closing != 0 { t.Fatalf("host retained %d settled connection closes", closing) } @@ -419,39 +470,48 @@ func TestConnectionCloseCancelsExecutionAndReleasesWireResources(t *testing.T) { runtime := &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }} + host, err := newTestHost(runtime, testLimits()) if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) } + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN BLOCK()"}}) if err != nil { t.Fatal(err) } + if _, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}); err != nil { t.Fatal(err) } + <-started if err := host.Close(testContext(t)); err != nil { t.Fatal(err) } + select { case <-finished: case <-time.After(5 * time.Second): t.Fatal("connection close did not cancel execution") } + runCalls, sessionCloseCalls := session.counts() if runCalls != 1 || sessionCloseCalls != 1 { t.Fatalf("unexpected session lifecycle: run=%d close=%d", runCalls, sessionCloseCalls) } + _, _, planCloseCalls := plan.snapshot() if planCloseCalls != 1 { t.Fatalf("connection cleanup closed plan %d times", planCloseCalls) } + _, _, runtimeCloseCalls := runtime.snapshot() if runtimeCloseCalls != 0 { t.Fatalf("connection cleanup closed borrowed runtime %d times", runtimeCloseCalls) @@ -471,22 +531,27 @@ func TestSessionClosePanicIsContainedAndAttemptedOnce(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + finished := waitExecution(t, connection, execution.ID) if finished.State != wireexecution.StateFailed || finished.Failure == nil || finished.Failure.Category != failure.CategoryInternalRuntime { t.Fatalf("close panic was not contained: %#v", finished) } + _, closeCalls := session.counts() if closeCalls != 1 { t.Fatalf("panicking session close attempted %d times", closeCalls) } + if err := connection.ReleaseExecution(testContext(t), execution.ID); err != nil { t.Fatal(err) } @@ -500,14 +565,17 @@ func TestDebugClosePanicTerminatesWatcherAndBecomesStale(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) } + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + subscription, err := connection.WatchDebug(opened.ID) if err != nil { t.Fatal(err) @@ -517,6 +585,7 @@ func TestDebugClosePanicTerminatesWatcherAndBecomesStale(t *testing.T) { if err := connection.ReleaseDebugSession(testContext(t), opened.ID); !hasCategory(err, ErrorKindInternal) || strings.Contains(err.Error(), "secret") { t.Fatalf("debug close panic was not sanitized: %v", err) } + select { case event := <-subscription.Events: if event.Kind != wiredebugger.EventTerminated { @@ -525,9 +594,11 @@ func TestDebugClosePanicTerminatesWatcherAndBecomesStale(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("debug close panic stranded watcher") } + if err := connection.ReleaseDebugSession(context.Background(), opened.ID); !hasCategory(err, ErrorKindDebugSessionNotFound) { t.Fatalf("released debug session did not become stale: %v", err) } + if closeCalls := debugSession.closes(); closeCalls != 1 { t.Fatalf("panicking debug close attempted %d times", closeCalls) } @@ -576,15 +647,19 @@ func TestPlanReleaseSettlesChildrenBeforeClosingAPIPlan(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) } + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + <-executionStarted + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) @@ -593,15 +668,19 @@ func TestPlanReleaseSettlesChildrenBeforeClosingAPIPlan(t *testing.T) { if err := connection.ReleasePlan(testContext(t), compiled.ID); err != nil { t.Fatal(err) } + orderMu.Lock() settledOrder := append([]string(nil), order...) orderMu.Unlock() + if !reflect.DeepEqual(settledOrder, []string{"execution", "debug", "plan"}) { t.Fatalf("unexpected cleanup order: %#v", settledOrder) } + if err := connection.ReleaseExecution(context.Background(), execution.ID); !hasCategory(err, ErrorKindExecutionNotFound) { t.Fatalf("released execution did not become stale: %v", err) } + if err := connection.ReleaseDebugSession(context.Background(), opened.ID); !hasCategory(err, ErrorKindDebugSessionNotFound) { t.Fatalf("released debug session did not become stale: %v", err) } @@ -653,10 +732,12 @@ func TestDebugUsesUnifiedSessionAndPreservesWireState(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Name: "debug.fql", Content: "RETURN 7"}, Debuggable: true}) if err != nil { t.Fatal(err) } + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{ PlanID: compiled.ID, Parameters: map[string]any{"input": int64(7)}, @@ -665,11 +746,14 @@ func TestDebugUsesUnifiedSessionAndPreservesWireState(t *testing.T) { if err != nil { t.Fatal(err) } + requested := source.Location{Position: source.Position{Line: 1, Column: 2}, SourceName: "debug.fql"} + breakpoint, err := connection.SetBreakpoint(context.Background(), opened.ID, requested) if err != nil { t.Fatal(err) } + if !breakpoint.Bound || breakpoint.ID != 1 || breakpoint.PointID != 41 || breakpoint.FunctionID != 42 || breakpoint.RequestedLocation != requested || breakpoint.Location.Location != requested || breakpoint.Location.Span != (source.Span{Start: 0, End: 1}) { t.Fatalf("core did not preserve API breakpoint: %#v", breakpoint) @@ -678,43 +762,55 @@ func TestDebugUsesUnifiedSessionAndPreservesWireState(t *testing.T) { if _, err := connection.StartDebug(context.Background(), opened.ID); err != nil { t.Fatal(err) } + stopped := waitDebugState(t, connection, opened.ID, wiredebugger.StateStopped) + wantRange := source.Range{Location: requested, Span: source.Span{Start: 3, End: 8}} if stopped.StopReason != debugger.ReasonBreakpoint || stopped.Location == nil || *stopped.Location != wantRange || stopped.Depth != 4 || !reflect.DeepEqual(stopped.HitBreakpointIDs, []debugger.BreakpointID{1}) { t.Fatalf("unexpected stopped state: %#v", stopped) } + hitIDs[0] = 90 stopped.HitBreakpointIDs[0] = 91 + retainedStopped := waitDebugState(t, connection, opened.ID, wiredebugger.StateStopped) if !reflect.DeepEqual(retainedStopped.HitBreakpointIDs, []debugger.BreakpointID{1}) { t.Fatalf("debug snapshot did not retain an owned hit-ID slice: %#v", retainedStopped) } + frames, err := connection.Frames(context.Background(), opened.ID) if err != nil || !reflect.DeepEqual(frames, debugSession.frames) { t.Fatalf("core did not preserve API frames: %#v, %v", frames, err) } + locals, err := connection.FrameLocals(context.Background(), opened.ID, 0) if err != nil || !reflect.DeepEqual(locals, debugSession.locals) { t.Fatalf("core did not preserve API variables: %#v, %v", locals, err) } + variables, err := connection.Variables(context.Background(), opened.ID, debugger.ValueReference(23)) if err != nil || !reflect.DeepEqual(variables, debugSession.locals) { t.Fatalf("core did not preserve expanded API variables: %#v, %v", variables, err) } + evaluated, err := connection.EvaluateFrame(context.Background(), opened.ID, 0, "input") if err != nil || evaluated != (debugger.Value{Type: "string", Display: "wire"}) { t.Fatalf("core did not preserve evaluated API value: %#v, %v", evaluated, err) } + if _, err := connection.ContinueDebug(context.Background(), opened.ID); err != nil { t.Fatal(err) } + completed := waitDebugState(t, connection, opened.ID, wiredebugger.StateCompleted) if completed.Output == nil || completed.Output.ContentType != "application/json" || string(completed.Output.Content) != "7" { t.Fatalf("unexpected debug output: %#v", completed.Output) } + debugContent[0] = '8' completed.Output.Content[0] = '9' + retainedCompleted := waitDebugState(t, connection, opened.ID, wiredebugger.StateCompleted) if retainedCompleted.Output == nil || string(retainedCompleted.Output.Content) != "7" { t.Fatalf("debug snapshot did not retain owned output bytes: %#v", retainedCompleted.Output) @@ -724,9 +820,11 @@ func TestDebugUsesUnifiedSessionAndPreservesWireState(t *testing.T) { if len(debugOptions) != 1 || !reflect.DeepEqual(debugOptions[0].params, map[string]any{"input": int64(7)}) || debugOptions[0].contentType != "application/json" { t.Fatalf("unexpected debug session options: %#v", debugOptions) } + if err := connection.ReleasePlan(testContext(t), compiled.ID); err != nil { t.Fatal(err) } + if closeCalls := debugSession.closes(); closeCalls != 1 { t.Fatalf("completed debug session closed %d times", closeCalls) } @@ -775,6 +873,7 @@ func TestDebugSessionCloseIsRetainedAcrossTerminalAndRelease(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, @@ -782,19 +881,23 @@ func TestDebugSessionCloseIsRetainedAcrossTerminalAndRelease(t *testing.T) { if err != nil { t.Fatal(err) } + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + if _, err := connection.StartDebug(context.Background(), opened.ID); err != nil { t.Fatal(err) } + waitDebugState(t, connection, opened.ID, test.wantState) releaseErr := connection.ReleaseDebugSession(testContext(t), opened.ID) if !errors.Is(releaseErr, test.closeError) { t.Fatalf("unexpected retained close result: %v", releaseErr) } + if closeCalls := runtimeDebugger.closes(); closeCalls != 1 { t.Fatalf("runtime debug session closed %d times", closeCalls) } @@ -810,12 +913,15 @@ func TestDebugSessionStopAndParentCascadeCloseOnce(t *testing.T) { if _, err := connection.StopDebug(testContext(t), opened.ID); err != nil { t.Fatal(err) } + if err := connection.ReleaseDebugSession(testContext(t), opened.ID); err != nil { t.Fatal(err) } + if closeCalls := runtimeDebugger.closes(); closeCalls != 1 { t.Fatalf("stopped runtime debug session closed %d times", closeCalls) } + if err := connection.ReleasePlan(testContext(t), compiled.ID); err != nil { t.Fatal(err) } @@ -838,20 +944,26 @@ func TestDebugSessionStopAndParentCascadeCloseOnce(t *testing.T) { return errors.New("debug command did not return before close") } }} + connection, compiled, opened := openTestDebugSession(t, runtimeDebugger) if _, err := connection.StartDebug(context.Background(), opened.ID); err != nil { t.Fatal(err) } + <-started + if _, err := connection.StopDebug(testContext(t), opened.ID); err != nil { t.Fatal(err) } + if err := connection.ReleaseDebugSession(testContext(t), opened.ID); err != nil { t.Fatal(err) } + if closeCalls := runtimeDebugger.closes(); closeCalls != 1 { t.Fatalf("cancelled runtime debug session closed %d times", closeCalls) } + if err := connection.ReleasePlan(testContext(t), compiled.ID); err != nil { t.Fatal(err) } @@ -864,6 +976,7 @@ func TestDebugSessionStopAndParentCascadeCloseOnce(t *testing.T) { if err := connection.ReleasePlan(testContext(t), compiled.ID); err != nil { t.Fatal(err) } + if closeCalls := runtimeDebugger.closes(); closeCalls != 1 { t.Fatalf("cascaded runtime debug session closed %d times", closeCalls) } @@ -878,6 +991,7 @@ func openTestDebugSession(t *testing.T, runtimeDebugger debugger.Session) (*test connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, @@ -885,6 +999,7 @@ func openTestDebugSession(t *testing.T, runtimeDebugger debugger.Session) (*test if err != nil { t.Fatal(err) } + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) @@ -901,10 +1016,12 @@ func waitDebugState(t *testing.T, connection *testEnvironment, id DebugSessionID if err != nil { t.Fatal(err) } + snapshot := session.Snapshot() if snapshot.State == state { return debugResult{ID: id, Snapshot: snapshot} } + time.Sleep(time.Millisecond) } diff --git a/server/internal/core/plan.go b/server/internal/core/plan.go index 6d1b8eb..3204e68 100644 --- a/server/internal/core/plan.go +++ b/server/internal/core/plan.go @@ -11,6 +11,7 @@ import ( "github.com/MontFerret/wire/server/internal/panicboundary" ) +// Plan owns a compiled hosted plan and the sessions and executions created from it. type Plan struct { id PlanID store *ResourceStore @@ -25,14 +26,17 @@ type Plan struct { release lifecycle.Close } +// ID identifies this compiled plan within its logical connection. func (p *Plan) ID() PlanID { return p.id } +// Params returns a copy of the compiled plan's parameter names. func (p *Plan) Params() []string { return append([]string(nil), p.parameters...) } +// NewSession creates and registers a durable hosted session owned by this plan. func (p *Plan) NewSession(ctx context.Context, options ...api.SessionOption) (*Session, error) { if err := p.store.operationError(ctx); err != nil { return nil, err @@ -50,6 +54,7 @@ func (p *Plan) NewSession(ctx context.Context, options ...api.SessionOption) (*S }) if err != nil { var closeErr error + if !isNil(hosted) { closeErr = closeAPISession(hosted) } @@ -77,6 +82,7 @@ func (p *Plan) NewSession(ctx context.Context, options ...api.SessionOption) (*S return created, nil } +// Execute registers an asynchronous run using a temporary hosted session. func (p *Plan) Execute(ctx context.Context, options ...api.SessionOption) (*Execution, error) { if err := p.store.operationError(ctx); err != nil { return nil, err @@ -102,6 +108,7 @@ func (p *Plan) Execute(ctx context.Context, options ...api.SessionOption) (*Exec return created, nil } +// NewDebugSession creates and registers a hosted debugger for a debuggable plan. func (p *Plan) NewDebugSession(ctx context.Context, options ...api.SessionOption) (*DebugSession, error) { if err := p.store.operationError(ctx); err != nil { return nil, err @@ -123,6 +130,7 @@ func (p *Plan) NewDebugSession(ctx context.Context, options ...api.SessionOption }) if err != nil { var closeErr error + if !isNil(hosted) { closeErr = closeAPIDebugSession(hosted) } @@ -144,10 +152,13 @@ func (p *Plan) NewDebugSession(ctx context.Context, options ...api.SessionOption return created, nil } +// Release closes descendants before releasing the hosted plan and its registry entry. +// Caller cancellation stops waiting without abandoning teardown. func (p *Plan) Release(ctx context.Context) error { p.store.mu.Lock() started := p.release.Begin() p.store.mu.Unlock() + if started { go p.settleRelease() } diff --git a/server/internal/core/plan_lifecycle_test.go b/server/internal/core/plan_lifecycle_test.go index 2f4bd9c..22c276d 100644 --- a/server/internal/core/plan_lifecycle_test.go +++ b/server/internal/core/plan_lifecycle_test.go @@ -48,6 +48,7 @@ func TestPlanReleaseSettlesAbandonedSessionBeforeReclaimingCapacity(t *testing.T limits := testLimits().resources() limits.Sessions = 1 registry := NewConnectionRegistry(1, limits) + connection, err := registry.Open() if err != nil { t.Fatal(err) @@ -58,6 +59,7 @@ func TestPlanReleaseSettlesAbandonedSessionBeforeReclaimingCapacity(t *testing.T t.Error(err) } }) + plan, err := CompilePlan(ctx, runtime, connection.Resources(), api.Source{Name: "parent", Content: "RETURN 1"}, false) if err != nil { t.Fatal(err) diff --git a/server/internal/core/plan_spy_test.go b/server/internal/core/plan_spy_test.go index 4cefaea..2ed2a3b 100644 --- a/server/internal/core/plan_spy_test.go +++ b/server/internal/core/plan_spy_test.go @@ -23,6 +23,7 @@ type spyPlan struct { func (p *spyPlan) Params() []string { p.mu.Lock() defer p.mu.Unlock() + if p.paramsCall != nil { return p.paramsCall() } diff --git a/server/internal/core/registry_lifecycle_test.go b/server/internal/core/registry_lifecycle_test.go index 5d01452..f5a4f3f 100644 --- a/server/internal/core/registry_lifecycle_test.go +++ b/server/internal/core/registry_lifecycle_test.go @@ -25,6 +25,7 @@ func TestConnectionCloseIsIdempotentAndRejectsNewResources(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, @@ -107,6 +108,7 @@ func TestPlanReleaseWaitsForInFlightDebugCreation(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, @@ -114,6 +116,7 @@ func TestPlanReleaseWaitsForInFlightDebugCreation(t *testing.T) { if err != nil { t.Fatal(err) } + retained, err := connection.resources.Plan(context.Background(), compiled.ID) if err != nil { t.Fatal(err) @@ -133,6 +136,7 @@ func TestPlanReleaseWaitsForInFlightDebugCreation(t *testing.T) { if _, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("closing plan accepted an execution: %v", err) } + if _, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("closing plan accepted another debug session: %v", err) } @@ -144,6 +148,7 @@ func TestPlanReleaseWaitsForInFlightDebugCreation(t *testing.T) { } close(finishConstructor) + if err := <-openResult; !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("in-flight debug construction committed after plan release: %v", err) } @@ -155,6 +160,7 @@ func TestPlanReleaseWaitsForInFlightDebugCreation(t *testing.T) { orderMu.Lock() settledOrder := append([]string(nil), order...) orderMu.Unlock() + if !reflect.DeepEqual(settledOrder, []string{"debug", "plan"}) { t.Fatalf("unexpected cleanup order: %#v", settledOrder) } @@ -202,18 +208,22 @@ func TestPlanReleaseWaitsForChildrenAlreadyClosing(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + retained, err := connection.resources.Plan(context.Background(), compiled.ID) if err != nil { t.Fatal(err) } + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + <-runStarted childResult := make(chan error, 1) @@ -230,9 +240,11 @@ func TestPlanReleaseWaitsForChildrenAlreadyClosing(t *testing.T) { } close(finishChildClose) + if err := <-childResult; err != nil { t.Fatal(err) } + if err := <-planResult; err != nil { t.Fatal(err) } @@ -240,6 +252,7 @@ func TestPlanReleaseWaitsForChildrenAlreadyClosing(t *testing.T) { orderMu.Lock() settledOrder := append([]string(nil), order...) orderMu.Unlock() + if !reflect.DeepEqual(settledOrder, []string{"execution", "plan"}) { t.Fatalf("unexpected cleanup order: %#v", settledOrder) } @@ -277,6 +290,7 @@ func TestPlanReleaseWaitsForChildrenAlreadyClosing(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, @@ -284,10 +298,12 @@ func TestPlanReleaseWaitsForChildrenAlreadyClosing(t *testing.T) { if err != nil { t.Fatal(err) } + retained, err := connection.resources.Plan(context.Background(), compiled.ID) if err != nil { t.Fatal(err) } + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) @@ -307,9 +323,11 @@ func TestPlanReleaseWaitsForChildrenAlreadyClosing(t *testing.T) { } close(finishChildClose) + if err := <-childResult; err != nil { t.Fatal(err) } + if err := <-planResult; err != nil { t.Fatal(err) } @@ -317,6 +335,7 @@ func TestPlanReleaseWaitsForChildrenAlreadyClosing(t *testing.T) { orderMu.Lock() settledOrder := append([]string(nil), order...) orderMu.Unlock() + if !reflect.DeepEqual(settledOrder, []string{"debug", "plan"}) { t.Fatalf("unexpected cleanup order: %#v", settledOrder) } @@ -348,14 +367,17 @@ func TestConcurrentChildReleaseSharesCleanup(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + <-runStarted first := make(chan error, 1) @@ -433,20 +455,25 @@ func TestExecutionTerminalStateSurvivesCancellationOrdering(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + started, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + <-runStarted if _, err := connection.CancelExecution(started.ID); err != nil { t.Fatal(err) } + close(finishRun) + settled := waitExecution(t, connection, started.ID) if settled.State != execution.StateCancelled { t.Fatalf("cancellation did not retain the terminal state: %#v", settled) @@ -462,14 +489,17 @@ func TestExecutionTerminalStateSurvivesCancellationOrdering(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + started, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } + settled := waitExecution(t, connection, started.ID) if settled.State != execution.StateCompleted { t.Fatalf("execution did not complete: %#v", settled) @@ -479,6 +509,7 @@ func TestExecutionTerminalStateSurvivesCancellationOrdering(t *testing.T) { if err != nil { t.Fatal(err) } + if afterCancel.State != execution.StateCompleted { t.Fatalf("late cancellation changed terminal state: %#v", afterCancel) } diff --git a/server/internal/core/resource_id.go b/server/internal/core/resource_id.go index 341204c..ca620e6 100644 --- a/server/internal/core/resource_id.go +++ b/server/internal/core/resource_id.go @@ -7,10 +7,15 @@ import ( ) type ( - ConnectionID string - PlanID string - SessionID string - ExecutionID string + // ConnectionID is the opaque registry key for a logical connection. + ConnectionID string + // PlanID is the opaque registry key for a compiled plan. + PlanID string + // SessionID is the opaque registry key for a durable session. + SessionID string + // ExecutionID is the opaque registry key for an execution. + ExecutionID string + // DebugSessionID is the opaque registry key for a debug session. DebugSessionID string ) diff --git a/server/internal/core/resource_store.go b/server/internal/core/resource_store.go index 97dc24c..981be50 100644 --- a/server/internal/core/resource_store.go +++ b/server/internal/core/resource_store.go @@ -63,6 +63,7 @@ func (r *ResourceStore) operationError(ctx context.Context) error { r.mu.Lock() err := r.checkOpen(nil) r.mu.Unlock() + if err != nil { return err } @@ -99,6 +100,7 @@ func (r *ResourceStore) beginCreation(kind resourceKind, plan *Plan) error { r.pending[kind]++ r.creating.Add(1) + if plan != nil { plan.creating.Add(1) } @@ -133,6 +135,7 @@ func (r *ResourceStore) checkOpen(plan *Plan) error { return nil } +// Plan resolves a live plan in this connection and rejects handles being released. func (r *ResourceStore) Plan(ctx context.Context, id PlanID) (*Plan, error) { if err := ctx.Err(); err != nil { return nil, err @@ -153,6 +156,7 @@ func (r *ResourceStore) Plan(ctx context.Context, id PlanID) (*Plan, error) { return resource, nil } +// ReleasePlan tears down a known plan and its descendants, joining any release in progress. func (r *ResourceStore) ReleasePlan(ctx context.Context, id PlanID) error { if err := validateID(id, "plan ID"); err != nil { return err @@ -161,6 +165,7 @@ func (r *ResourceStore) ReleasePlan(ctx context.Context, id PlanID) error { r.mu.Lock() resource := r.plans[id] r.mu.Unlock() + if resource == nil { return notFound(ErrorKindPlanNotFound, string(id)) } @@ -168,6 +173,7 @@ func (r *ResourceStore) ReleasePlan(ctx context.Context, id PlanID) error { return resource.Release(ctx) } +// Session resolves a live session in this connection and rejects handles being released. func (r *ResourceStore) Session(ctx context.Context, id SessionID) (*Session, error) { if err := ctx.Err(); err != nil { return nil, err @@ -188,6 +194,7 @@ func (r *ResourceStore) Session(ctx context.Context, id SessionID) (*Session, er return resource, nil } +// ReleaseSession tears down a known session and its execution, joining any release in progress. func (r *ResourceStore) ReleaseSession(ctx context.Context, id SessionID) error { if err := validateID(id, "session ID"); err != nil { return err @@ -196,6 +203,7 @@ func (r *ResourceStore) ReleaseSession(ctx context.Context, id SessionID) error r.mu.Lock() resource := r.sessions[id] r.mu.Unlock() + if resource == nil { return notFound(ErrorKindSessionNotFound, string(id)) } @@ -203,6 +211,7 @@ func (r *ResourceStore) ReleaseSession(ctx context.Context, id SessionID) error return resource.Release(ctx) } +// Execution resolves a live execution in this connection and rejects handles being released. func (r *ResourceStore) Execution(ctx context.Context, id ExecutionID) (*Execution, error) { if err := ctx.Err(); err != nil { return nil, err @@ -223,6 +232,7 @@ func (r *ResourceStore) Execution(ctx context.Context, id ExecutionID) (*Executi return resource, nil } +// ReleaseExecution cancels and reclaims a known execution, joining any release in progress. func (r *ResourceStore) ReleaseExecution(ctx context.Context, id ExecutionID) error { if err := validateID(id, "execution ID"); err != nil { return err @@ -231,6 +241,7 @@ func (r *ResourceStore) ReleaseExecution(ctx context.Context, id ExecutionID) er r.mu.Lock() resource := r.executions[id] r.mu.Unlock() + if resource == nil { return notFound(ErrorKindExecutionNotFound, string(id)) } @@ -238,6 +249,7 @@ func (r *ResourceStore) ReleaseExecution(ctx context.Context, id ExecutionID) er return resource.Release(ctx) } +// DebugSession resolves a live debugger in this connection and rejects handles being released. func (r *ResourceStore) DebugSession(ctx context.Context, id DebugSessionID) (*DebugSession, error) { if err := ctx.Err(); err != nil { return nil, err @@ -258,6 +270,7 @@ func (r *ResourceStore) DebugSession(ctx context.Context, id DebugSessionID) (*D return resource, nil } +// ReleaseDebugSession reclaims a known debugger, joining any release in progress. func (r *ResourceStore) ReleaseDebugSession(ctx context.Context, id DebugSessionID) error { if err := validateID(id, "debug session ID"); err != nil { return err @@ -266,6 +279,7 @@ func (r *ResourceStore) ReleaseDebugSession(ctx context.Context, id DebugSession r.mu.Lock() resource := r.debugSessions[id] r.mu.Unlock() + if resource == nil { return notFound(ErrorKindDebugSessionNotFound, string(id)) } @@ -273,11 +287,14 @@ func (r *ResourceStore) ReleaseDebugSession(ctx context.Context, id DebugSession return resource.Release(ctx) } +// Close rejects new resource creation and starts teardown once. +// The caller's context bounds waiting while the store retains cleanup ownership. func (r *ResourceStore) Close(ctx context.Context) error { r.mu.Lock() started := r.close.Begin() r.closing = true r.mu.Unlock() + if started { go r.settleClose() } @@ -416,6 +433,7 @@ func (r *ResourceStore) registerExecution(ctx context.Context, e *Execution) err } r.pending[executionResource]-- + r.executions[e.id] = e if e.plan != nil { e.plan.executions[e.id] = e @@ -429,6 +447,7 @@ func (r *ResourceStore) removeExecution(e *Execution) { defer r.mu.Unlock() delete(r.executions, e.id) + if e.plan != nil { delete(e.plan.executions, e.id) } diff --git a/server/internal/core/runtime_execution_test.go b/server/internal/core/runtime_execution_test.go index 09ef48e..0f27fff 100644 --- a/server/internal/core/runtime_execution_test.go +++ b/server/internal/core/runtime_execution_test.go @@ -16,6 +16,7 @@ func TestRunUsesBorrowedRuntimeWithoutPlan(t *testing.T) { return api.Output{ContentType: options.contentType, Content: []byte("direct")}, nil }} connection := newTestConnection(t, hosted) + run, err := connection.Run(context.Background(), runRequest{ Source: api.Source{Name: "direct.fql", Content: "RETURN @input"}, Parameters: map[string]any{"input": int64(7)}, @@ -25,7 +26,7 @@ func TestRunUsesBorrowedRuntimeWithoutPlan(t *testing.T) { t.Fatal(err) } - if run.Snapshot.State != wireexecution.StateRunning { + if run.State != wireexecution.StateRunning { t.Fatalf("direct Run did not return the initial running snapshot: %+v", run) } @@ -44,6 +45,7 @@ func TestRunUsesBorrowedRuntimeWithoutPlan(t *testing.T) { options := make([]sessionOptions, len(hosted.runOptions)) copy(options, hosted.runOptions) hosted.mu.Unlock() + if !reflect.DeepEqual(sources, []api.Source{{Name: "direct.fql", Content: "RETURN @input"}}) || len(options) != 1 || options[0].contentType != "text/plain" || !reflect.DeepEqual(options[0].params, map[string]any{"input": int64(7)}) { @@ -60,12 +62,14 @@ func TestRunPanicIsContainedAsInternalFailure(t *testing.T) { panic("runtime secret") }} connection := newTestConnection(t, hosted) + run, err := connection.Run(context.Background(), runRequest{ Source: api.Source{Content: "RETURN 1"}, }) if err != nil { t.Fatal(err) } + terminal := waitExecution(t, connection, run.ID) if terminal.State != wireexecution.StateFailed || terminal.Output != nil || terminal.Failure == nil || terminal.Failure.Category != failure.CategoryInternalRuntime || @@ -76,6 +80,7 @@ func TestRunPanicIsContainedAsInternalFailure(t *testing.T) { if err := connection.ReleaseExecution(testContext(t), run.ID); err != nil { t.Fatal(err) } + _, _, closeCalls := hosted.snapshot() if closeCalls != 0 { t.Fatalf("direct execution closed borrowed Runtime %d times", closeCalls) @@ -87,6 +92,7 @@ func TestRunRejectsCancelledAndInvalidRequestsBeforeAllocation(t *testing.T) { connection := newTestConnection(t, hosted) cancelled, cancel := context.WithCancel(context.Background()) cancel() + if _, err := connection.Run(cancelled, runRequest{Source: api.Source{Content: "RETURN 1"}}); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled direct run was admitted: %v", err) } diff --git a/server/internal/core/runtime_spy_test.go b/server/internal/core/runtime_spy_test.go index 8316a28..4724849 100644 --- a/server/internal/core/runtime_spy_test.go +++ b/server/internal/core/runtime_spy_test.go @@ -30,6 +30,7 @@ func (r *spyRuntime) Run(ctx context.Context, src api.Source, options ...api.Ses r.runOptions = append(r.runOptions, configured.clone()) run := r.run r.mu.Unlock() + if run == nil { return api.Output{}, nil } diff --git a/server/internal/core/session.go b/server/internal/core/session.go index 1ae3b84..220e03c 100644 --- a/server/internal/core/session.go +++ b/server/internal/core/session.go @@ -6,10 +6,11 @@ import ( "sync" "sync/atomic" + "github.com/google/uuid" + "github.com/MontFerret/api" "github.com/MontFerret/wire/server/internal/lifecycle" "github.com/MontFerret/wire/server/internal/panicboundary" - "github.com/google/uuid" ) // Session owns one durable hosted session. Its execution slot remains occupied @@ -39,10 +40,12 @@ func newSession(plan *Plan, hosted api.Session) *Session { } } +// ID identifies this durable session within its logical connection. func (s *Session) ID() SessionID { return s.id } +// Execute starts a run only when the session's previous execution has been released. func (s *Session) Execute(ctx context.Context) (*Execution, error) { if err := s.plan.store.operationError(ctx); err != nil { return nil, err @@ -100,6 +103,7 @@ func (s *Session) run(ctx context.Context) (api.Output, error) { output, err := panicboundary.Call(func() (api.Output, error) { return s.session.Run(ctx) }) + var panicErr *panicboundary.Error if errors.As(err, &panicErr) { s.poisoned.Store(true) @@ -108,15 +112,19 @@ func (s *Session) run(ctx context.Context) (api.Output, error) { return output, runtimePanicError("run runtime session", err) } +// Release cancels the session, releases its execution, and closes the hosted session. +// Caller cancellation stops waiting without abandoning teardown. func (s *Session) Release(ctx context.Context) error { r := s.plan.store r.mu.Lock() + started := s.release.Begin() if started { s.cancel(context.Canceled) } r.mu.Unlock() + if started { go s.settleRelease() } @@ -141,6 +149,7 @@ func (s *Session) settleRelease() { r.mu.Lock() execution := s.active r.mu.Unlock() + if execution != nil { err = execution.Release(context.Background()) } diff --git a/server/internal/core/session_lifecycle_test.go b/server/internal/core/session_lifecycle_test.go index a9439aa..23b4d7f 100644 --- a/server/internal/core/session_lifecycle_test.go +++ b/server/internal/core/session_lifecycle_test.go @@ -25,6 +25,7 @@ func TestDurableSessionRunsSequentiallyOnOneHostedSession(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN @input"}}) if err != nil { t.Fatal(err) @@ -45,7 +46,7 @@ func TestDurableSessionRunsSequentiallyOnOneHostedSession(t *testing.T) { t.Fatal(err) } - if run.Snapshot.State != wireexecution.StateRunning { + if run.State != wireexecution.StateRunning { t.Fatalf("Session.Run did not return the initial running snapshot: %+v", run) } @@ -54,6 +55,7 @@ func TestDurableSessionRunsSequentiallyOnOneHostedSession(t *testing.T) { string(terminal.Output.Content) != `{"ok":true}` { t.Fatalf("unexpected terminal execution: %#v", terminal) } + if err := connection.ReleaseExecution(testContext(t), run.ID); err != nil { t.Fatal(err) } @@ -91,10 +93,12 @@ func TestDurableSessionRejectsOverlappingRunsUntilExecutionRelease(t *testing.T) return api.Output{}, ctx.Err() }} connection, _, created := openTestSession(t, runtimeSession) + first, err := connection.RunSession(context.Background(), created) if err != nil { t.Fatal(err) } + <-started if _, err := connection.RunSession(context.Background(), created); !hasCategory(err, ErrorKindInvalidState) { @@ -104,6 +108,7 @@ func TestDurableSessionRejectsOverlappingRunsUntilExecutionRelease(t *testing.T) if err := connection.ReleaseExecution(testContext(t), first.ID); err != nil { t.Fatal(err) } + second, err := connection.RunSession(context.Background(), created) if err != nil { t.Fatalf("session was not reusable after execution release: %v", err) @@ -136,10 +141,12 @@ func TestDurableSessionReleaseCancelsRunBeforeExactlyOnceClose(t *testing.T) { return nil }, } + connection, compiled, created := openTestSession(t, runtimeSession) if _, err := connection.RunSession(context.Background(), created); err != nil { t.Fatal(err) } + <-started if err := connection.ReleasePlan(testContext(t), compiled.ID); err != nil { @@ -153,6 +160,7 @@ func TestDurableSessionReleaseCancelsRunBeforeExactlyOnceClose(t *testing.T) { orderMu.Lock() settledOrder := append([]string(nil), order...) orderMu.Unlock() + if !reflect.DeepEqual(settledOrder, []string{"run", "close"}) { t.Fatalf("session cleanup was not descendants-first: %#v", settledOrder) } @@ -177,6 +185,7 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { return nil }}, nil }} + host, err := newTestHost(&spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}, fixtureLimits{ @@ -191,10 +200,12 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { if err != nil { t.Fatal(err) } + connection, err := host.OpenConnection() if err != nil { t.Fatal(err) } + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) @@ -216,7 +227,9 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { if _, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("pending session did not count against limit: %v", err) } + close(finishConstructor) + created := <-creation if created.err != nil { t.Fatal(created.err) @@ -229,13 +242,16 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { if _, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}); hasCategory(err, ErrorKindResourceExhausted) { break } + time.Sleep(time.Millisecond) } if _, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("closing session did not count against limit: %v", err) } + close(finishClose) + if err := <-release; err != nil { t.Fatal(err) } @@ -258,10 +274,12 @@ func openTestSession(t *testing.T, runtimeSession api.Session) (*testEnvironment connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } + created, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) @@ -277,6 +295,7 @@ func TestSessionCreationFailureDoesNotLeakLimit(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) @@ -294,10 +313,12 @@ func TestDurableSessionIsNotReusedAfterRuntimePanic(t *testing.T) { panic("runtime defect") }} connection, _, created := openTestSession(t, runtimeSession) + run, err := connection.RunSession(context.Background(), created) if err != nil { t.Fatal(err) } + terminal := waitExecution(t, connection, run.ID) if terminal.State != wireexecution.StateFailed || terminal.Failure == nil { t.Fatalf("runtime panic did not fail the execution: %#v", terminal) @@ -324,6 +345,7 @@ func TestDurableSessionClosePanicSettlesReleaseAndParentCleanup(t *testing.T) { runtimeSession := &spySession{close: func() error { panic("session close secret") }} + connection, compiled, created := openTestSession(t, runtimeSession) if err := connection.ReleaseSession(testContext(t), created); !hasCategory(err, ErrorKindInternal) { t.Fatalf("hosted close panic was not retained: %v", err) diff --git a/server/internal/core/test_environment_test.go b/server/internal/core/test_environment_test.go index e7fd4a7..c309bf0 100644 --- a/server/internal/core/test_environment_test.go +++ b/server/internal/core/test_environment_test.go @@ -23,6 +23,7 @@ func (e *testEnvironment) Compile(ctx context.Context, input compileRequest) (pl defer cancel() var options []api.PlanOption + if input.HasOptimizationLevel { options = append(options, api.WithOptimizationLevel(input.OptimizationLevel)) } @@ -171,6 +172,7 @@ func (e *testEnvironment) OpenDebugSession(ctx context.Context, input debugReque func (e *testEnvironment) debugSession(ctx context.Context, id DebugSessionID) (context.Context, context.CancelFunc, *DebugSession, error) { operation, cancel := e.operation(ctx) + session, err := e.resources.DebugSession(operation, id) if err != nil { cancel() diff --git a/server/internal/core/test_requests_test.go b/server/internal/core/test_requests_test.go index e4e64a8..89d57be 100644 --- a/server/internal/core/test_requests_test.go +++ b/server/internal/core/test_requests_test.go @@ -55,6 +55,7 @@ type ( func apiSessionOptions(parameters map[string]any, contentType string) []api.SessionOption { options := []api.SessionOption{api.WithParams(cloneParameters(parameters))} + if contentType != "" { options = append(options, api.WithOutputContentType(contentType)) } diff --git a/server/internal/grpcserver/compile_options.go b/server/internal/grpcserver/compile_options.go index 34d3008..18427c7 100644 --- a/server/internal/grpcserver/compile_options.go +++ b/server/internal/grpcserver/compile_options.go @@ -8,6 +8,7 @@ import ( func optimizationLevel(options *wirev1.CompileOptions) (api.OptimizationLevel, bool, error) { value := wirev1.OptimizationLevel_OPTIMIZATION_LEVEL_UNSPECIFIED + if options != nil { value = options.GetOptimizationLevel() } diff --git a/server/internal/grpcserver/debug_conversion.go b/server/internal/grpcserver/debug_conversion.go index b1109d2..a6bfbab 100644 --- a/server/internal/grpcserver/debug_conversion.go +++ b/server/internal/grpcserver/debug_conversion.go @@ -19,6 +19,7 @@ func debugSession(id core.DebugSessionID, value wiredebugger.Snapshot) (*wirev1. } var location *wirev1.Range + if value.Location != nil { location, err = sourceRange(*value.Location) if err != nil { @@ -204,6 +205,7 @@ func breakpointBindingMode(value debugger.BreakpointBindingMode) (wirev1.Breakpo func breakpointOptions(value *wirev1.BreakpointOptions) (debugger.BreakpointOptions, error) { mode := wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_UNSPECIFIED + if value != nil { mode = value.GetBindingMode() } diff --git a/server/internal/grpcserver/debug_conversion_test.go b/server/internal/grpcserver/debug_conversion_test.go index c2adac1..f66e865 100644 --- a/server/internal/grpcserver/debug_conversion_test.go +++ b/server/internal/grpcserver/debug_conversion_test.go @@ -17,6 +17,7 @@ func TestUnifiedDebuggerTypesPreservePortableProtocolFields(t *testing.T) { Location: source.Location{Position: source.Position{Line: 4, Column: 2}, SourceName: "debug.fql"}, Span: source.Span{Start: 10, End: 20}, } + convertedBreakpoint, err := breakpoint(debugger.Breakpoint{ Location: resolved, RequestedLocation: requested, @@ -29,6 +30,7 @@ func TestUnifiedDebuggerTypesPreservePortableProtocolFields(t *testing.T) { if err != nil { t.Fatal(err) } + if convertedBreakpoint.GetId() != 7 || convertedBreakpoint.GetRequestedLocation().GetSourceName() != "debug.fql" || convertedBreakpoint.GetRequestedLocation().GetPosition().GetLine() != 3 || convertedBreakpoint.GetLocation().GetLocation().GetPosition().GetLine() != 4 || @@ -43,6 +45,7 @@ func TestUnifiedDebuggerTypesPreservePortableProtocolFields(t *testing.T) { if err != nil { t.Fatal(err) } + if unboundBreakpoint.GetRequestedLocation().GetSourceName() != "debug.fql" || unboundBreakpoint.GetLocation() != nil || unboundBreakpoint.GetBound() || unboundBreakpoint.GetBindingMode() != wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_SOURCE { @@ -53,6 +56,7 @@ func TestUnifiedDebuggerTypesPreservePortableProtocolFields(t *testing.T) { if err != nil { t.Fatal(err) } + if convertedFrame.GetName() != "main" || convertedFrame.GetLocation().GetPosition().GetLine() != 4 || convertedFrame.GetFunctionId() != 11 { t.Fatalf("unexpected frame transport projection: %#v", convertedFrame) @@ -64,6 +68,7 @@ func TestUnifiedDebuggerTypesPreservePortableProtocolFields(t *testing.T) { if err != nil { t.Fatal(err) } + if convertedVariable.GetName() != "input" || convertedVariable.GetValue().GetReference() != 13 || !convertedVariable.GetMutable() || !convertedVariable.GetParameter() { t.Fatalf("unexpected variable transport projection: %#v", convertedVariable) @@ -79,6 +84,7 @@ func TestUnifiedDebuggerTypesPreservePortableProtocolFields(t *testing.T) { if err != nil { t.Fatal(err) } + if convertedSession.GetStopReason() != wirev1.DebugStopReason_DEBUG_STOP_REASON_BREAKPOINT || convertedSession.GetLocation().GetLocation().GetPosition().GetLine() != 4 || convertedSession.GetLocation().GetSpan().GetStart() != 10 || convertedSession.GetDepth() != 3 || diff --git a/server/internal/grpcserver/debug_service.go b/server/internal/grpcserver/debug_service.go index 99aaf56..6bf941b 100644 --- a/server/internal/grpcserver/debug_service.go +++ b/server/internal/grpcserver/debug_service.go @@ -15,6 +15,7 @@ type DebugService struct { var _ wirev1.DebugServiceServer = (*DebugService)(nil) +// CreateDebugSession decodes options and creates a debugger under the requested plan. func (s *DebugService) CreateDebugSession( ctx context.Context, request *wirev1.CreateDebugSessionRequest, @@ -49,6 +50,7 @@ func (s *DebugService) CreateDebugSession( return &wirev1.CreateDebugSessionResponse{Session: converted}, nil } +// ReleaseDebugSession reclaims a debugger through its logical connection. func (s *DebugService) ReleaseDebugSession( ctx context.Context, request *wirev1.ReleaseDebugSessionRequest, diff --git a/server/internal/grpcserver/debug_service_commands.go b/server/internal/grpcserver/debug_service_commands.go index 16d5837..524bd67 100644 --- a/server/internal/grpcserver/debug_service_commands.go +++ b/server/internal/grpcserver/debug_service_commands.go @@ -28,6 +28,7 @@ func (s *DebugService) debugCommand( return operation, cancel, session, nil } +// Start acknowledges an initial debugger resume; WatchDebug carries later stops. func (s *DebugService) Start(ctx context.Context, request *wirev1.StartRequest) (*wirev1.StartResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { @@ -43,6 +44,7 @@ func (s *DebugService) Start(ctx context.Context, request *wirev1.StartRequest) return &wirev1.StartResponse{}, nil } +// Continue acknowledges a debugger resume; WatchDebug carries later stops. func (s *DebugService) Continue(ctx context.Context, request *wirev1.ContinueRequest) (*wirev1.ContinueResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { @@ -58,6 +60,7 @@ func (s *DebugService) Continue(ctx context.Context, request *wirev1.ContinueReq return &wirev1.ContinueResponse{}, nil } +// Pause requests interruption; WatchDebug reports the resulting state transition. func (s *DebugService) Pause(ctx context.Context, request *wirev1.PauseRequest) (*wirev1.PauseResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { @@ -73,6 +76,7 @@ func (s *DebugService) Pause(ctx context.Context, request *wirev1.PauseRequest) return &wirev1.PauseResponse{}, nil } +// StepOver dispatches a step-over command; WatchDebug carries its result. func (s *DebugService) StepOver(ctx context.Context, request *wirev1.StepOverRequest) (*wirev1.StepOverResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { @@ -88,6 +92,7 @@ func (s *DebugService) StepOver(ctx context.Context, request *wirev1.StepOverReq return &wirev1.StepOverResponse{}, nil } +// StepIn dispatches a step-in command; WatchDebug carries its result. func (s *DebugService) StepIn(ctx context.Context, request *wirev1.StepInRequest) (*wirev1.StepInResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { @@ -103,6 +108,7 @@ func (s *DebugService) StepIn(ctx context.Context, request *wirev1.StepInRequest return &wirev1.StepInResponse{}, nil } +// StepOut dispatches a step-out command; WatchDebug carries its result. func (s *DebugService) StepOut(ctx context.Context, request *wirev1.StepOutRequest) (*wirev1.StepOutResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { @@ -118,6 +124,7 @@ func (s *DebugService) StepOut(ctx context.Context, request *wirev1.StepOutReque return &wirev1.StepOutResponse{}, nil } +// Terminate stops the debugger while retaining its handle until explicit release. func (s *DebugService) Terminate(ctx context.Context, request *wirev1.TerminateRequest) (*wirev1.TerminateResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { @@ -133,6 +140,7 @@ func (s *DebugService) Terminate(ctx context.Context, request *wirev1.TerminateR return &wirev1.TerminateResponse{}, nil } +// SetBreakpoint validates protocol locations and options before installing a breakpoint. func (s *DebugService) SetBreakpoint(ctx context.Context, request *wirev1.SetBreakpointRequest) (*wirev1.SetBreakpointResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { @@ -164,6 +172,7 @@ func (s *DebugService) SetBreakpoint(ctx context.Context, request *wirev1.SetBre return &wirev1.SetBreakpointResponse{Breakpoint: converted}, nil } +// DeleteBreakpoint validates the protocol ID before removing the session's breakpoint. func (s *DebugService) DeleteBreakpoint(ctx context.Context, request *wirev1.DeleteBreakpointRequest) (*wirev1.DeleteBreakpointResponse, error) { operation, cancel, session, err := s.debugCommand(ctx, request.GetConnectionId(), request.GetDebugSessionId()) if err != nil { diff --git a/server/internal/grpcserver/debug_service_events.go b/server/internal/grpcserver/debug_service_events.go index 24d0a79..7f7974c 100644 --- a/server/internal/grpcserver/debug_service_events.go +++ b/server/internal/grpcserver/debug_service_events.go @@ -5,6 +5,8 @@ import ( "github.com/MontFerret/wire/server/internal/core" ) +// WatchDebug sends the current snapshot followed by ordered debugger transitions. +// Stream completion releases the bounded subscription. func (s *DebugService) WatchDebug(request *wirev1.WatchDebugRequest, stream wirev1.DebugService_WatchDebugServer) error { operation, resources, cancel, err := prepareOperation(stream.Context(), s.connections, request.GetConnectionId()) if err != nil { diff --git a/server/internal/grpcserver/debug_service_inspection.go b/server/internal/grpcserver/debug_service_inspection.go index 640bb0e..c1fc13b 100644 --- a/server/internal/grpcserver/debug_service_inspection.go +++ b/server/internal/grpcserver/debug_service_inspection.go @@ -8,6 +8,7 @@ import ( "github.com/MontFerret/wire/server/internal/core" ) +// Frames converts stopped-debugger frames, rejecting unrepresentable runtime values. func (s *DebugService) Frames(ctx context.Context, request *wirev1.FramesRequest) (*wirev1.FramesResponse, error) { operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { @@ -39,6 +40,7 @@ func (s *DebugService) Frames(ctx context.Context, request *wirev1.FramesRequest return &wirev1.FramesResponse{Frames: result}, nil } +// FrameLocals converts variables from the requested stopped frame. func (s *DebugService) FrameLocals(ctx context.Context, request *wirev1.FrameLocalsRequest) (*wirev1.FrameLocalsResponse, error) { operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { @@ -65,6 +67,7 @@ func (s *DebugService) FrameLocals(ctx context.Context, request *wirev1.FrameLoc return &wirev1.FrameLocalsResponse{Variables: result}, nil } +// Variables validates a protocol reference before expanding and converting its children. func (s *DebugService) Variables(ctx context.Context, request *wirev1.VariablesRequest) (*wirev1.VariablesResponse, error) { operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { @@ -96,6 +99,7 @@ func (s *DebugService) Variables(ctx context.Context, request *wirev1.VariablesR return &wirev1.VariablesResponse{Variables: result}, nil } +// EvaluateFrame evaluates in a stopped frame and converts the resulting debugger value. func (s *DebugService) EvaluateFrame(ctx context.Context, request *wirev1.EvaluateFrameRequest) (*wirev1.EvaluateFrameResponse, error) { operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { diff --git a/server/internal/grpcserver/errors.go b/server/internal/grpcserver/errors.go index f19acb6..234b247 100644 --- a/server/internal/grpcserver/errors.go +++ b/server/internal/grpcserver/errors.go @@ -5,10 +5,11 @@ import ( "errors" "fmt" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - "github.com/MontFerret/wire/server/internal/core" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "github.com/MontFerret/wire/server/internal/core" ) func rpcError(err error) error { @@ -71,6 +72,7 @@ func rpcError(err error) error { } category := wirev1.ErrorCategory_ERROR_CATEGORY_UNSPECIFIED + if value := domain.Category(); value != 0 { category, conversionErr = failureCategory(value) if conversionErr != nil { @@ -92,6 +94,7 @@ func statusWithDiagnostics( diagnosticSet *wirev1.DiagnosticSet, ) error { base := status.New(code, message) + if category == wirev1.ErrorCategory_ERROR_CATEGORY_UNSPECIFIED { return base.Err() } diff --git a/server/internal/grpcserver/errors_test.go b/server/internal/grpcserver/errors_test.go index fa719dd..431e034 100644 --- a/server/internal/grpcserver/errors_test.go +++ b/server/internal/grpcserver/errors_test.go @@ -3,10 +3,11 @@ package grpcserver import ( "testing" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - "github.com/MontFerret/wire/server/internal/core" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "github.com/MontFerret/wire/server/internal/core" ) func TestRPCErrorUsesGRPCStatusAndMinimalWireCategory(t *testing.T) { @@ -45,9 +46,11 @@ func TestRPCErrorUsesGRPCStatusAndMinimalWireCategory(t *testing.T) { for _, value := range converted.Details() { if typed, detailOK := value.(*wirev1.ErrorDetail); detailOK { detail = typed + break } } + if test.category == wirev1.ErrorCategory_ERROR_CATEGORY_UNSPECIFIED { if detail != nil { t.Fatalf("gRPC-native failure carried redundant detail: %#v", detail) diff --git a/server/internal/grpcserver/execution_service.go b/server/internal/grpcserver/execution_service.go index b70e9cd..c07f9b3 100644 --- a/server/internal/grpcserver/execution_service.go +++ b/server/internal/grpcserver/execution_service.go @@ -16,6 +16,7 @@ type ExecutionService struct { var _ wirev1.ExecutionServiceServer = (*ExecutionService)(nil) +// Execute starts a plan run with decoded options and returns its initial protocol snapshot. func (s *ExecutionService) Execute(ctx context.Context, request *wirev1.ExecuteRequest) (*wirev1.ExecuteResponse, error) { operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { @@ -47,6 +48,7 @@ func (s *ExecutionService) Execute(ctx context.Context, request *wirev1.ExecuteR return &wirev1.ExecuteResponse{Execution: converted}, nil } +// RunSession starts a run on a durable session and returns its execution handle. func (s *ExecutionService) RunSession( ctx context.Context, request *wirev1.RunSessionRequest, @@ -76,6 +78,7 @@ func (s *ExecutionService) RunSession( return &wirev1.RunSessionResponse{Execution: converted}, nil } +// CancelExecution requests cancellation without waiting for the run to settle. func (s *ExecutionService) CancelExecution(ctx context.Context, request *wirev1.CancelExecutionRequest) (*wirev1.CancelExecutionResponse, error) { operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { @@ -94,6 +97,7 @@ func (s *ExecutionService) CancelExecution(ctx context.Context, request *wirev1. return &wirev1.CancelExecutionResponse{}, nil } +// ReleaseExecution cancels and reclaims the run through its logical connection. func (s *ExecutionService) ReleaseExecution(ctx context.Context, request *wirev1.ReleaseExecutionRequest) (*wirev1.ReleaseExecutionResponse, error) { operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { @@ -109,6 +113,8 @@ func (s *ExecutionService) ReleaseExecution(ctx context.Context, request *wirev1 return &wirev1.ReleaseExecutionResponse{}, nil } +// WatchExecution sends the current snapshot followed by ordered events. +// Stream completion releases the bounded subscription. func (s *ExecutionService) WatchExecution(request *wirev1.WatchExecutionRequest, stream wirev1.ExecutionService_WatchExecutionServer) error { operation, resources, cancel, err := prepareOperation(stream.Context(), s.connections, request.GetConnectionId()) if err != nil { diff --git a/server/internal/grpcserver/operation_context_test.go b/server/internal/grpcserver/operation_context_test.go index 556f172..695a1cc 100644 --- a/server/internal/grpcserver/operation_context_test.go +++ b/server/internal/grpcserver/operation_context_test.go @@ -6,11 +6,12 @@ import ( "testing" "time" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - "github.com/MontFerret/wire/server/internal/core" "github.com/google/uuid" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "github.com/MontFerret/wire/server/internal/core" ) func TestOperationContextRejectsInvalidAndUnknownConnections(t *testing.T) { @@ -38,10 +39,12 @@ func TestOperationContextCombinesLifetimesAndPreservesValues(t *testing.T) { for _, lifetime := range []string{"request", "connection", "operation"} { t.Run(lifetime, func(t *testing.T) { registry := core.NewConnectionRegistry(1, core.ResourceLimits{}) + connection, err := registry.Open() if err != nil { t.Fatal(err) } + t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -55,6 +58,7 @@ func TestOperationContextCombinesLifetimesAndPreservesValues(t *testing.T) { request, cancelRequest := context.WithTimeout(context.WithValue(context.Background(), contextKey{}, "retained"), 5*time.Second) defer cancelRequest() id := &wirev1.ConnectionId{Value: string(connection.ID())} + operation, resources, cancel, err := prepareOperation(request, registry, id) if err != nil { t.Fatal(err) @@ -63,6 +67,7 @@ func TestOperationContextCombinesLifetimesAndPreservesValues(t *testing.T) { defer cancel() deadline, _ := request.Deadline() + operationDeadline, present := operation.Deadline() if resources != connection.Resources() || operation.Value(contextKey{}) != "retained" || !present || !operationDeadline.Equal(deadline) { t.Fatal("operation lost its connection, request value, or deadline") diff --git a/server/internal/grpcserver/plan_service.go b/server/internal/grpcserver/plan_service.go index 7d82afd..b0b96f6 100644 --- a/server/internal/grpcserver/plan_service.go +++ b/server/internal/grpcserver/plan_service.go @@ -17,6 +17,7 @@ type PlanService struct { var _ wirev1.PlanServiceServer = (*PlanService)(nil) +// Compile decodes source and options into a connection-owned normal plan. func (s *PlanService) Compile(ctx context.Context, request *wirev1.CompileRequest) (*wirev1.CompileResponse, error) { compiled, err := s.compile(ctx, request.GetConnectionId(), request.GetSource(), request.GetOptions(), false) if err != nil { @@ -26,6 +27,7 @@ func (s *PlanService) Compile(ctx context.Context, request *wirev1.CompileReques return &wirev1.CompileResponse{Plan: compiled}, nil } +// CompileDebug decodes source and options into a connection-owned debuggable plan. func (s *PlanService) CompileDebug(ctx context.Context, request *wirev1.CompileDebugRequest) (*wirev1.CompileDebugResponse, error) { compiled, err := s.compile(ctx, request.GetConnectionId(), request.GetSource(), request.GetOptions(), true) if err != nil { @@ -62,6 +64,7 @@ func (s *PlanService) compile( return plan(compiled), nil } +// ReleasePlan resolves ownership through the request's connection before reclaiming the plan. func (s *PlanService) ReleasePlan(ctx context.Context, request *wirev1.ReleasePlanRequest) (*wirev1.ReleasePlanResponse, error) { operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { diff --git a/server/internal/grpcserver/plan_service_test.go b/server/internal/grpcserver/plan_service_test.go index ca7b609..afac98e 100644 --- a/server/internal/grpcserver/plan_service_test.go +++ b/server/internal/grpcserver/plan_service_test.go @@ -34,13 +34,13 @@ func TestOptimizationLevelMapsPortableValuesAndPreservesRuntimeDefault(t *testin if got != test.want || present != test.present { t.Fatalf("optimization = (%v, %v), want (%v, %v)", got, present, test.want, test.present) } - }) } } func TestOptimizationLevelRejectsUnknownValue(t *testing.T) { _, _, err := optimizationLevel(compileOptions(wirev1.OptimizationLevel(99))) + var domain *core.DomainError if !errors.As(err, &domain) || domain.Kind != core.ErrorKindInvalidRequest { t.Fatalf("unexpected invalid optimization result: %v", err) diff --git a/server/internal/grpcserver/recovery.go b/server/internal/grpcserver/recovery.go index 9c09650..30d5bd9 100644 --- a/server/internal/grpcserver/recovery.go +++ b/server/internal/grpcserver/recovery.go @@ -3,10 +3,12 @@ package grpcserver import ( "context" - "github.com/MontFerret/wire/server/internal/core" "google.golang.org/grpc" + + "github.com/MontFerret/wire/server/internal/core" ) +// UnaryRecoveryInterceptor replaces recovered handler panics with sanitized internal statuses. func UnaryRecoveryInterceptor( ctx context.Context, request any, @@ -23,6 +25,7 @@ func UnaryRecoveryInterceptor( return handler(ctx, request) } +// StreamRecoveryInterceptor replaces recovered stream-handler panics with sanitized internal statuses. func StreamRecoveryInterceptor( server any, stream grpc.ServerStream, diff --git a/server/internal/grpcserver/recovery_test.go b/server/internal/grpcserver/recovery_test.go index 0029f19..8db2692 100644 --- a/server/internal/grpcserver/recovery_test.go +++ b/server/internal/grpcserver/recovery_test.go @@ -5,9 +5,10 @@ import ( "strings" "testing" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) func TestUnaryRecoverySanitizesPanicValues(t *testing.T) { @@ -17,10 +18,12 @@ func TestUnaryRecoverySanitizesPanicValues(t *testing.T) { if status.Code(err) != codes.Internal || strings.Contains(err.Error(), "secret") { t.Fatalf("panic was not sanitized: %v", err) } + grpcStatus := status.Convert(err) if len(grpcStatus.Details()) != 1 { t.Fatalf("missing structured error detail: %v", err) } + detail, ok := grpcStatus.Details()[0].(*wirev1.ErrorDetail) if !ok || detail.GetCategory() != wirev1.ErrorCategory_ERROR_CATEGORY_INTERNAL_RUNTIME_FAILURE { t.Fatalf("unexpected error detail: %#v", grpcStatus.Details()) diff --git a/server/internal/grpcserver/runtime_service.go b/server/internal/grpcserver/runtime_service.go index facda39..827267a 100644 --- a/server/internal/grpcserver/runtime_service.go +++ b/server/internal/grpcserver/runtime_service.go @@ -19,6 +19,7 @@ type RuntimeService struct { var _ wirev1.RuntimeServiceServer = (*RuntimeService)(nil) +// Connect keeps a logical connection alive for the stream and reclaims it when the stream ends. func (s *RuntimeService) Connect(_ *wirev1.ConnectRequest, stream wirev1.RuntimeService_ConnectServer) error { connection, err := s.connections.Open() if err != nil { @@ -47,6 +48,7 @@ func (s *RuntimeService) Connect(_ *wirev1.ConnectRequest, stream wirev1.Runtime } } +// CloseConnection tears down the logical connection identified by the request. func (s *RuntimeService) CloseConnection(ctx context.Context, request *wirev1.CloseConnectionRequest) (*wirev1.CloseConnectionResponse, error) { err := s.connections.CloseConnection(ctx, core.ConnectionID(request.GetConnectionId().GetValue())) if err != nil { @@ -56,6 +58,7 @@ func (s *RuntimeService) CloseConnection(ctx context.Context, request *wirev1.Cl return &wirev1.CloseConnectionResponse{}, nil } +// Run decodes session options and registers a direct hosted-runtime execution. func (s *RuntimeService) Run( ctx context.Context, request *wirev1.RunRequest, diff --git a/server/internal/grpcserver/semantic_conversion_test.go b/server/internal/grpcserver/semantic_conversion_test.go index 1b4c321..0e341eb 100644 --- a/server/internal/grpcserver/semantic_conversion_test.go +++ b/server/internal/grpcserver/semantic_conversion_test.go @@ -31,6 +31,7 @@ func TestExecutionStateMapsEverySharedValue(t *testing.T) { if err != nil { t.Fatal(err) } + if got != test.want { t.Errorf("executionState(%v) = %v, want %v", test.shared, got, test.want) } @@ -56,6 +57,7 @@ func TestDebugStateAndEventKindMapEverySharedValue(t *testing.T) { if err != nil { t.Fatal(err) } + if got != test.want { t.Errorf("debugState(%v) = %v, want %v", test.shared, got, test.want) } @@ -78,6 +80,7 @@ func TestDebugStateAndEventKindMapEverySharedValue(t *testing.T) { if err != nil { t.Fatal(err) } + if got != test.want { t.Errorf("debugEventKind(%v) = %v, want %v", test.shared, got, test.want) } @@ -112,6 +115,7 @@ func TestFailureCategoryMapsEverySharedValue(t *testing.T) { if err != nil { t.Fatal(err) } + if got != test.want { t.Errorf("failureCategory(%v) = %v, want %v", test.shared, got, test.want) } @@ -159,8 +163,9 @@ func TestSharedEventsPreserveFieldsAndDetachMutableData(t *testing.T) { content[0] = 'X' hitIDs[0] = 99 - location.Location.SourceName = "changed.fql" + location.SourceName = "changed.fql" diagnosticSet[0].Annotations[0].Message = "changed" + if converted.GetSequence() != 11 || converted.GetKind() != wirev1.DebugEventKind_DEBUG_EVENT_KIND_STOPPED || converted.GetSession().GetStopReason() != wirev1.DebugStopReason_DEBUG_STOP_REASON_BREAKPOINT || converted.GetSession().GetLocation().GetLocation().GetSourceName() != "query.fql" || diff --git a/server/internal/grpcserver/server.go b/server/internal/grpcserver/server.go index 45f6cd7..159254f 100644 --- a/server/internal/grpcserver/server.go +++ b/server/internal/grpcserver/server.go @@ -1,10 +1,12 @@ +// Package grpcserver translates Wire RPCs into core operations and sanitized protocol responses. package grpcserver import ( + "google.golang.org/grpc" + "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/server/internal/core" - "google.golang.org/grpc" ) // Server composes the protocol services over shared core resource owners. @@ -16,6 +18,7 @@ type Server struct { debug *DebugService } +// New composes RPC services over the caller's runtime and logical connection registry. func New(runtime api.Runtime, info Handshake, connections *core.ConnectionRegistry) *Server { return &Server{ runtime: &RuntimeService{runtime: runtime, info: info, connections: connections}, @@ -26,6 +29,7 @@ func New(runtime api.Runtime, info Handshake, connections *core.ConnectionRegist } } +// Register installs all five Wire services on the supplied gRPC registrar. func (s *Server) Register(registrar grpc.ServiceRegistrar) { wirev1.RegisterRuntimeServiceServer(registrar, s.runtime) wirev1.RegisterPlanServiceServer(registrar, s.plans) diff --git a/server/internal/grpcserver/server_test.go b/server/internal/grpcserver/server_test.go index 3b2d8ff..5d09e9a 100644 --- a/server/internal/grpcserver/server_test.go +++ b/server/internal/grpcserver/server_test.go @@ -4,8 +4,9 @@ import ( "reflect" "testing" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "google.golang.org/grpc" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) func TestServerRegistersDedicatedProtocolServices(t *testing.T) { diff --git a/server/internal/grpcserver/session_options.go b/server/internal/grpcserver/session_options.go index baf0c50..88eeedc 100644 --- a/server/internal/grpcserver/session_options.go +++ b/server/internal/grpcserver/session_options.go @@ -13,6 +13,7 @@ func decodeSessionOptions(parameters *wirev1.Parameters, contentType string) ([] } options := []api.SessionOption{api.WithParams(values)} + if contentType != "" { options = append(options, api.WithOutputContentType(contentType)) } diff --git a/server/internal/grpcserver/session_service.go b/server/internal/grpcserver/session_service.go index 4de34e0..e7caccf 100644 --- a/server/internal/grpcserver/session_service.go +++ b/server/internal/grpcserver/session_service.go @@ -15,6 +15,7 @@ type SessionService struct { var _ wirev1.SessionServiceServer = (*SessionService)(nil) +// CreateSession applies decoded options to a durable session under the requested plan. func (s *SessionService) CreateSession( ctx context.Context, request *wirev1.CreateSessionRequest, @@ -46,6 +47,7 @@ func (s *SessionService) CreateSession( }}, nil } +// ReleaseSession reclaims a durable session through its logical connection. func (s *SessionService) ReleaseSession( ctx context.Context, request *wirev1.ReleaseSessionRequest, diff --git a/server/internal/grpcserver/value.go b/server/internal/grpcserver/value.go index 5a96826..d9d83f0 100644 --- a/server/internal/grpcserver/value.go +++ b/server/internal/grpcserver/value.go @@ -4,14 +4,16 @@ import ( "fmt" "math" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "google.golang.org/protobuf/types/known/structpb" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) const maxValueDepth = 64 func decodeParameters(input *wirev1.Parameters) (map[string]any, error) { result := make(map[string]any) + if input == nil { return result, nil } @@ -66,12 +68,14 @@ func decodeValue(input *wirev1.Value, depth int) (any, error) { if value.ArrayValue == nil { return nil, fmt.Errorf("array value is required") } + items := make([]any, len(value.ArrayValue.GetValues())) for i, item := range value.ArrayValue.GetValues() { converted, err := decodeValue(item, depth+1) if err != nil { return nil, fmt.Errorf("array item %d: %w", i, err) } + items[i] = converted } diff --git a/server/internal/grpcserver/value_test.go b/server/internal/grpcserver/value_test.go index f6eab52..2998971 100644 --- a/server/internal/grpcserver/value_test.go +++ b/server/internal/grpcserver/value_test.go @@ -42,6 +42,7 @@ func TestDecodeValueRejectsExcessiveNesting(t *testing.T) { for range maxValueDepth { value = &wirev1.Value{Value: &wirev1.Value_ArrayValue{ArrayValue: &wirev1.ArrayValue{Values: []*wirev1.Value{value}}}} } + _, err := decodeValue(value, 0) if err == nil || !strings.Contains(err.Error(), "nesting") { t.Fatalf("unexpected error: %v", err) diff --git a/server/internal/lifecycle/close.go b/server/internal/lifecycle/close.go index 301bfac..048f066 100644 --- a/server/internal/lifecycle/close.go +++ b/server/internal/lifecycle/close.go @@ -1,3 +1,4 @@ +// Package lifecycle coordinates retained teardown results and independently cancellable waiters. package lifecycle import ( @@ -58,12 +59,14 @@ func (c *Close) Wait(ctx context.Context) error { c.mu.Lock() if !c.started { c.mu.Unlock() + return nil } if c.done == nil { err := c.err c.mu.Unlock() + return err } diff --git a/server/internal/lifecycle/close_test.go b/server/internal/lifecycle/close_test.go index 4e34e79..db8732d 100644 --- a/server/internal/lifecycle/close_test.go +++ b/server/internal/lifecycle/close_test.go @@ -14,12 +14,14 @@ func TestCloseRetainsOneResultWithoutBindingCleanupToAWaiter(t *testing.T) { cancelled, cancel := context.WithCancel(context.Background()) cancel() + if err := closeState.Wait(cancelled); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled waiter did not leave independently: %v", err) } want := errors.New("retained result") closeState.Finish(want) + if err := closeState.Wait(context.Background()); !errors.Is(err, want) { t.Fatalf("cleanup result was not retained: %v", err) } diff --git a/server/internal/panicboundary/panicboundary_test.go b/server/internal/panicboundary/panicboundary_test.go index 2347e0c..4a2332a 100644 --- a/server/internal/panicboundary/panicboundary_test.go +++ b/server/internal/panicboundary/panicboundary_test.go @@ -27,7 +27,7 @@ func TestCallReturnsNormalErrorUnchanged(t *testing.T) { t.Fatalf("value = %d, want zero", value) } - if err != sentinel { + if err != sentinel { //nolint:errorlint // Verify exact preservation of returned errors and recovered panic values. t.Fatalf("error identity was not retained: %v", err) } } @@ -60,6 +60,7 @@ func TestCallConvertsPanicToTypedError(t *testing.T) { } wrapped := fmt.Errorf("call runtime: %w", err) + var wrappedPanicErr *Error if !errors.As(wrapped, &wrappedPanicErr) || wrappedPanicErr != panicErr { t.Fatalf("wrapped panic error was not discoverable: %v", wrapped) @@ -73,7 +74,7 @@ func TestDoReturnsNormalErrorUnchanged(t *testing.T) { t.Fatalf("normal success returned error: %v", err) } - if err := Do(func() error { return sentinel }); err != sentinel { + if err := Do(func() error { return sentinel }); err != sentinel { //nolint:errorlint // Verify exact preservation of returned errors and recovered panic values. t.Fatalf("error identity was not retained: %v", err) } } @@ -88,7 +89,7 @@ func TestDoDoesNotUnwrapErrorValuedPanic(t *testing.T) { t.Fatalf("panic did not produce *Error: %T", err) } - if panicErr.Value != context.Canceled { + if panicErr.Value != context.Canceled { //nolint:errorlint // Verify exact preservation of returned errors and recovered panic values. t.Fatalf("panic value = %#v, want context.Canceled", panicErr.Value) } diff --git a/server/protocol_contract_test.go b/server/protocol_contract_test.go index dc52fb6..16d5a77 100644 --- a/server/protocol_contract_test.go +++ b/server/protocol_contract_test.go @@ -6,8 +6,9 @@ import ( "strings" "testing" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "google.golang.org/protobuf/reflect/protoreflect" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { @@ -116,15 +117,18 @@ func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { t.Errorf("%s.%s is missing", test.message.FullName(), name) } } + for _, number := range test.numbers { if !test.message.ReservedRanges().Has(number) { t.Errorf("%s does not reserve field %d", test.message.FullName(), number) } } + for _, name := range test.reserved { if !test.message.ReservedNames().Has(name) { t.Errorf("%s does not reserve field name %s", test.message.FullName(), name) } + if test.message.Fields().ByName(name) != nil { t.Errorf("%s still declares removed field %s", test.message.FullName(), name) } @@ -144,6 +148,7 @@ func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { t.Errorf("ErrorCategory does not reserve value %d", number) } } + for _, name := range []protoreflect.Name{ "ERROR_CATEGORY_INVALID_REQUEST", "ERROR_CATEGORY_UNSUPPORTED_CAPABILITY", @@ -177,6 +182,7 @@ func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { t.Errorf("Diagnostic.%s does not use field %d", name, number) } } + annotation := wirev1.File_ferret_wire_v1_runtime_proto.Messages().ByName("DiagnosticAnnotation") for name, number := range map[protoreflect.Name]protoreflect.FieldNumber{ "range": 1, "message": 2, "primary": 3, @@ -185,10 +191,12 @@ func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { t.Errorf("DiagnosticAnnotation.%s does not use field %d", name, number) } } + diagnosticSet := wirev1.File_ferret_wire_v1_runtime_proto.Messages().ByName("DiagnosticSet") if field := diagnosticSet.Fields().ByName("diagnostics"); field == nil || field.Number() != 1 { t.Error("DiagnosticSet.diagnostics does not use field 1") } + failure := wirev1.File_ferret_wire_v1_runtime_proto.Messages().ByName("Failure") if field := failure.Fields().ByName("diagnostic_set"); field == nil || field.Number() != 4 { t.Error("Failure.diagnostic_set does not use field 4") @@ -198,6 +206,7 @@ func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { if created := debugEventKind.Values().ByName("DEBUG_EVENT_KIND_CREATED"); created == nil || created.Number() != 7 { t.Error("DebugEventKind does not expose CREATED at value 7") } + breakpointMode := wirev1.File_ferret_wire_v1_debug_proto.Enums().ByName("BreakpointBindingMode") if next := breakpointMode.Values().ByName("BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_SOURCE"); next == nil || next.Number() != 1 { t.Error("BreakpointBindingMode does not expose source-neutral default at value 1") @@ -212,18 +221,22 @@ func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { for _, operation := range []string{"Continue", "Pause", "StepOver", "StepIn", "StepOut"} { request := debugMessages.ByName(protoreflect.Name(operation + "Request")) response := debugMessages.ByName(protoreflect.Name(operation + "Response")) + if request == nil || !request.ReservedRanges().Has(1) || !request.ReservedNames().Has("command") { t.Errorf("%sRequest does not reserve the removed command envelope", operation) } + if response == nil || !response.ReservedRanges().Has(1) || !response.ReservedNames().Has("session") { t.Errorf("%sResponse does not reserve the removed session snapshot", operation) } } + for _, removed := range []protoreflect.Name{"NextRequest", "NextResponse", "StepRequest", "StepResponse", "OutRequest", "OutResponse"} { if debugMessages.ByName(removed) != nil { t.Errorf("DebugService still declares removed envelope %s", removed) } } + setBreakpoint := debugMessages.ByName("SetBreakpointRequest") if setBreakpoint == nil || !setBreakpoint.ReservedRanges().Has(3) { t.Error("SetBreakpointRequest does not reserve the old SourceLocation field tag") @@ -259,31 +272,37 @@ func TestProtocolDescriptorsReserveRemovedV1Surface(t *testing.T) { if runExecution == nil || runExecution.Number() != 1 || runExecution.Message().FullName() != "ferret.wire.v1.Execution" { t.Error("RunResponse does not preserve the Execution response") } + planMethods := wirev1.File_ferret_wire_v1_plan_proto.Services().ByName("PlanService").Methods() if planMethods.ByName("Compile") == nil || planMethods.ByName("CompileDebug") == nil { t.Error("PlanService does not expose distinct normal and debug compilation") } + sessionMethods := wirev1.File_ferret_wire_v1_session_proto.Services().ByName("SessionService").Methods() for _, required := range []protoreflect.Name{"CreateSession", "ReleaseSession"} { if sessionMethods.ByName(required) == nil { t.Errorf("SessionService is missing RPC %s", required) } } + executionMethods := wirev1.File_ferret_wire_v1_execution_proto.Services().ByName("ExecutionService").Methods() if executionMethods.Len() != 5 { t.Error("ExecutionService retained a direct Runtime invocation RPC") } + for _, required := range []protoreflect.Name{"Execute", "RunSession"} { if executionMethods.ByName(required) == nil { t.Errorf("ExecutionService is missing RPC %s", required) } } + debugMethods := wirev1.File_ferret_wire_v1_debug_proto.Services().ByName("DebugService").Methods() for _, required := range []protoreflect.Name{"StepOver", "StepIn", "StepOut"} { if debugMethods.ByName(required) == nil { t.Errorf("DebugService is missing RPC %s", required) } } + for _, removed := range []protoreflect.Name{"OpenDebugSession", "StartDebug", "StopDebug", "Next", "Step", "Out"} { if debugMethods.ByName(removed) != nil { t.Errorf("DebugService still exposes removed RPC %s", removed) @@ -300,6 +319,7 @@ func TestProtocolSourcesContainNoNativeMetadataOrFakeCapabilities(t *testing.T) "enum Capability", "enum ResourceKind", } + files, err := filepath.Glob(filepath.Join("..", "proto", "ferret", "wire", "v1", "*.proto")) if err != nil { t.Fatal(err) diff --git a/server/protocol_ownership_test.go b/server/protocol_ownership_test.go index 18aecf5..d94e818 100644 --- a/server/protocol_ownership_test.go +++ b/server/protocol_ownership_test.go @@ -4,12 +4,13 @@ import ( "context" "testing" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/server" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestProtocolConnectionsHaveIndependentStoresAndLimits(t *testing.T) { @@ -45,6 +46,7 @@ func TestProtocolConnectionsHaveIndependentStoresAndLimits(t *testing.T) { for i := range owners { connectCtx, cancel := context.WithCancel(ctx) defer cancel() + stream, err := runtimes.Connect(connectCtx, &wirev1.ConnectRequest{}) if err != nil { t.Fatal(err) @@ -63,45 +65,53 @@ func TestProtocolConnectionsHaveIndependentStoresAndLimits(t *testing.T) { } }() compile := &wirev1.CompileDebugRequest{ConnectionId: owner.connection, Source: &wirev1.Source{Content: "RETURN 1"}} + plan, err := plans.CompileDebug(ctx, compile) if err != nil { t.Fatalf("connection %d cannot allocate its own plan: %v", i, err) } owner.plan = plan.GetPlan().GetId() + if _, err := plans.CompileDebug(ctx, compile); status.Code(err) != codes.ResourceExhausted { t.Fatalf("plan limit: %v", err) } createSession := &wirev1.CreateSessionRequest{ConnectionId: owner.connection, PlanId: owner.plan} + session, err := sessions.CreateSession(ctx, createSession) if err != nil { t.Fatalf("connection %d cannot allocate its own session: %v", i, err) } owner.session = session.GetSession().GetId() + if _, err := sessions.CreateSession(ctx, createSession); status.Code(err) != codes.ResourceExhausted { t.Fatalf("session limit: %v", err) } run := &wirev1.RunSessionRequest{ConnectionId: owner.connection, SessionId: owner.session} + execution, err := executions.RunSession(ctx, run) if err != nil { t.Fatalf("connection %d cannot allocate its own execution: %v", i, err) } owner.execution = execution.GetExecution().GetId() + if _, err := executions.RunSession(ctx, run); status.Code(err) != codes.ResourceExhausted { t.Fatalf("execution limit: %v", err) } createDebug := &wirev1.CreateDebugSessionRequest{ConnectionId: owner.connection, PlanId: owner.plan} + debug, err := debuggers.CreateDebugSession(ctx, createDebug) if err != nil { t.Fatalf("connection %d cannot allocate its own debugger: %v", i, err) } owner.debug = debug.GetSession().GetId() + if _, err := debuggers.CreateDebugSession(ctx, createDebug); status.Code(err) != codes.ResourceExhausted { t.Fatalf("debugger limit: %v", err) } @@ -109,6 +119,7 @@ func TestProtocolConnectionsHaveIndependentStoresAndLimits(t *testing.T) { releaseChildren := func(connection *wirev1.ConnectionId, target resources) { t.Helper() + if _, err := sessions.ReleaseSession(ctx, &wirev1.ReleaseSessionRequest{ConnectionId: connection, SessionId: target.session}); status.Code(err) != codes.NotFound { t.Fatalf("foreign or stale session was accessible: %v", err) } @@ -122,6 +133,7 @@ func TestProtocolConnectionsHaveIndependentStoresAndLimits(t *testing.T) { } } releaseChildren(owners[1].connection, owners[0]) + if _, err := plans.ReleasePlan(ctx, &wirev1.ReleasePlanRequest{ConnectionId: owners[1].connection, PlanId: owners[0].plan}); status.Code(err) != codes.NotFound { t.Fatalf("foreign plan was accessible: %v", err) } @@ -131,6 +143,7 @@ func TestProtocolConnectionsHaveIndependentStoresAndLimits(t *testing.T) { } releaseChildren(owners[0].connection, owners[0]) + if _, err := executions.ReleaseExecution(ctx, &wirev1.ReleaseExecutionRequest{ConnectionId: owners[1].connection, ExecutionId: owners[1].execution}); err != nil { t.Fatalf("other connection lost its execution: %v", err) } diff --git a/server/protocol_resource_test.go b/server/protocol_resource_test.go index b90c2fe..d555903 100644 --- a/server/protocol_resource_test.go +++ b/server/protocol_resource_test.go @@ -60,6 +60,7 @@ func TestProtocolResourceOperationsRemainAvailable(t *testing.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) @@ -77,6 +78,7 @@ func TestProtocolResourceOperationsRemainAvailable(t *testing.T) { } }() 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) @@ -84,6 +86,7 @@ func TestProtocolResourceOperationsRemainAvailable(t *testing.T) { 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}}}}, @@ -118,6 +121,7 @@ func TestProtocolResourceOperationsRemainAvailable(t *testing.T) { } debugRPC := wirev1.NewDebugServiceClient(env.conn) + debugCreated, err := debugRPC.CreateDebugSession(ctx, &wirev1.CreateDebugSessionRequest{ConnectionId: connectionID, PlanId: planID}) if err != nil { t.Fatal(err) diff --git a/server/runtime_adapter_benchmark_test.go b/server/runtime_adapter_benchmark_test.go index b22faac..47acf77 100644 --- a/server/runtime_adapter_benchmark_test.go +++ b/server/runtime_adapter_benchmark_test.go @@ -10,10 +10,12 @@ import ( func BenchmarkRuntimeAdapterDurableSession(b *testing.B) { env := newIntegrationEnv(b, &contractRuntime{}) + remote, err := client.New(testContext(b), env.conn) if err != nil { b.Fatal(err) } + b.Cleanup(func() { if err := remote.Close(); err != nil { b.Error(err) @@ -29,6 +31,7 @@ func BenchmarkRuntimeAdapterDurableSession(b *testing.B) { if err != nil { b.Fatal(err) } + b.Cleanup(func() { if err := errors.Join(session.Close(), plan.Close()); err != nil { b.Error(err) diff --git a/server/runtime_adapter_plan_fake_test.go b/server/runtime_adapter_plan_fake_test.go index 074137e..fb1c4da 100644 --- a/server/runtime_adapter_plan_fake_test.go +++ b/server/runtime_adapter_plan_fake_test.go @@ -36,6 +36,7 @@ func (p *contractPlan) NewSession(ctx context.Context, options ...api.SessionOpt p.sessionOptions = append(p.sessionOptions, configured.clone()) create := p.newSession p.mu.Unlock() + if create == nil { return &contractSession{}, nil } @@ -53,6 +54,7 @@ func (p *contractPlan) NewDebugSession(ctx context.Context, options ...api.Sessi p.debugOptions = append(p.debugOptions, configured.clone()) create := p.newDebugSession p.mu.Unlock() + if create == nil { return nil, errors.New("debug session is not configured") } diff --git a/server/runtime_adapter_runtime_fake_test.go b/server/runtime_adapter_runtime_fake_test.go index f64cb3c..0f27128 100644 --- a/server/runtime_adapter_runtime_fake_test.go +++ b/server/runtime_adapter_runtime_fake_test.go @@ -30,6 +30,7 @@ func (r *contractRuntime) Run(ctx context.Context, src api.Source, options ...ap r.runOptions = append(r.runOptions, configured.clone()) run := r.run r.mu.Unlock() + if run == nil { return api.Output{}, nil } @@ -68,6 +69,7 @@ func (r *contractRuntime) compilePlan( r.compileLevels = append(r.compileLevels, *configured) compile := r.compile r.mu.Unlock() + if compile == nil { return &contractPlan{}, nil } diff --git a/server/runtime_adapter_session_fake_test.go b/server/runtime_adapter_session_fake_test.go index 8603452..572fe5d 100644 --- a/server/runtime_adapter_session_fake_test.go +++ b/server/runtime_adapter_session_fake_test.go @@ -20,6 +20,7 @@ func (s *contractSession) Run(ctx context.Context) (api.Output, error) { call := s.runCalls run := s.run s.mu.Unlock() + if run == nil { return api.Output{}, nil } diff --git a/server/runtime_optimization_presence_test.go b/server/runtime_optimization_presence_test.go index 003ec10..4c1d262 100644 --- a/server/runtime_optimization_presence_test.go +++ b/server/runtime_optimization_presence_test.go @@ -35,6 +35,7 @@ func TestClientOptimizationPresenceRoundTrip(t *testing.T) { } compile := env.client.Compile + if debug { compile = env.client.CompileDebug } @@ -71,6 +72,7 @@ func TestCompileOptionsApplyOnceBeforeDispatch(t *testing.T) { hosted := &contractRuntime{} env := newIntegrationEnv(t, hosted) gate := &allocationResponseGate{ClientConnInterface: env.conn, calls: make(map[string]int)} + remote, err := client.New(testContext(t), gate) if err != nil { t.Fatal(err) @@ -83,6 +85,7 @@ func TestCompileOptionsApplyOnceBeforeDispatch(t *testing.T) { }) compile := func(ctx context.Context, options []api.PlanOption) error { compile := remote.Compile + if debug { compile = remote.CompileDebug } diff --git a/server/server.go b/server/server.go index 7133262..5459e3d 100644 --- a/server/server.go +++ b/server/server.go @@ -7,11 +7,12 @@ import ( "sync" "time" + "google.golang.org/grpc" + "github.com/MontFerret/api" "github.com/MontFerret/wire/server/internal/core" "github.com/MontFerret/wire/server/internal/grpcserver" "github.com/MontFerret/wire/server/internal/lifecycle" - "google.golang.org/grpc" ) // Server hosts Ferret Wire over a caller-supplied listener. It borrows the @@ -83,8 +84,10 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error { s.serveMu.Lock() if s.serving { s.serveMu.Unlock() + return errors.New("Wire server is already serving") } + s.serving = true s.serveMu.Unlock() @@ -99,6 +102,7 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error { err := s.grpcServer.Serve(listener) close(watchDone) + if errors.Is(err, grpc.ErrServerStopped) || ctx.Err() != nil { err = nil } diff --git a/server/server_limits_test.go b/server/server_limits_test.go index 5dde174..0089fb4 100644 --- a/server/server_limits_test.go +++ b/server/server_limits_test.go @@ -40,6 +40,7 @@ func TestServerLimitsRequireEveryValueToBePositive(t *testing.T) { t.Run(name, func(t *testing.T) { limits := server.DefaultLimits() invalidate(&limits) + if _, err := server.NewServer(runtime, server.WithLimits(limits)); err == nil { t.Fatal("NewServer accepted a non-positive limit") } @@ -47,6 +48,7 @@ func TestServerLimitsRequireEveryValueToBePositive(t *testing.T) { } limits := server.DefaultLimits() + limits.MaxConnections = 3 if _, err := server.NewServer(runtime, server.WithLimits(limits)); err != nil { t.Fatalf("NewServer rejected a complete positive override: %v", err) diff --git a/test/integration/allocation_fixture_test.go b/test/integration/allocation_fixture_test.go index c1a0367..380e49b 100644 --- a/test/integration/allocation_fixture_test.go +++ b/test/integration/allocation_fixture_test.go @@ -44,6 +44,7 @@ func newRuntimeAllocationFixture(t *testing.T, operation allocationOperation) *r h := harness.New(t) f := &runtimeAllocationFixture{t: t, h: h, gate: h.Faults(), record: h.RuntimeSpy().Recorder(), remote: h.Runtime(), operation: operation} var err error + f.other, err = h.OpenRuntime() if err != nil { t.Fatal(err) diff --git a/test/integration/allocation_race_test.go b/test/integration/allocation_race_test.go index ad89561..dd9e09f 100644 --- a/test/integration/allocation_race_test.go +++ b/test/integration/allocation_race_test.go @@ -87,6 +87,7 @@ func TestRuntimeAllocationNormalCloseReleasesOnce(t *testing.T) { for _, operation := range allocationOperations() { t.Run(operation.name, func(t *testing.T) { f := newRuntimeAllocationFixture(t, operation) + closeHandle, err := f.allocate(harness.Context(t), nil) if err != nil { t.Fatal(err) diff --git a/test/integration/allocation_test.go b/test/integration/allocation_test.go index 577072b..f83c8b8 100644 --- a/test/integration/allocation_test.go +++ b/test/integration/allocation_test.go @@ -6,11 +6,12 @@ import ( "slices" "testing" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api" "github.com/MontFerret/wire/client" "github.com/MontFerret/wire/test/integration/harness" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestRuntimeLostAllocationReclaimsNearestParentAndPreservesSiblings(t *testing.T) { @@ -26,6 +27,7 @@ func TestRuntimeLostAllocationReclaimsNearestParentAndPreservesSiblings(t *testi if operation.name != "session run" { var err error + parent, err = f.remote.Compile(harness.Context(t), api.Source{Content: "RETURN 2"}) if err != nil { t.Fatal(err) @@ -33,6 +35,7 @@ func TestRuntimeLostAllocationReclaimsNearestParentAndPreservesSiblings(t *testi } var err error + sibling, err = parent.NewSession(harness.Context(t)) if err != nil { t.Fatal(err) @@ -47,6 +50,7 @@ func TestRuntimeLostAllocationReclaimsNearestParentAndPreservesSiblings(t *testi }() f.awaitCommitted() f.reply.Deliver() + err := f.awaitResult(result) if err == nil { t.Fatal("lost or malformed allocation response succeeded") @@ -111,9 +115,11 @@ func TestRuntimeCancelledKnownAllocationPreservesParentsOnReleaseFailure(t *test for _, acknowledged := range []bool{false, true} { t.Run(operation.name+map[bool]string{false: "/failed delivery", true: "/lost acknowledgement"}[acknowledged], func(t *testing.T) { f := newRuntimeAllocationFixture(t, operation) + siblingPlan := f.plan if siblingPlan == nil { var err error + siblingPlan, err = f.remote.Compile(harness.Context(t), api.Source{Content: "RETURN 2"}) if err != nil { t.Fatal(err) @@ -144,6 +150,7 @@ func TestRuntimeCancelledKnownAllocationPreservesParentsOnReleaseFailure(t *test f.awaitCommitted() cancel() f.reply.Deliver() + err = f.awaitResult(result) if !errors.Is(err, context.Canceled) || !errors.Is(err, releaseErr) { t.Fatalf("cancellation or handle release error was lost: %v", err) @@ -182,6 +189,7 @@ func TestRuntimeCancelledKnownAllocationPreservesParentsOnReleaseFailure(t *test func TestRuntimeLostExecutionTriesPlanBeforeRuntime(t *testing.T) { operation := allocationOperations()[4] f := newRuntimeAllocationFixture(t, operation) + siblingPlan, err := f.remote.Compile(harness.Context(t), api.Source{Content: "RETURN 2"}) if err != nil { t.Fatal(err) @@ -209,6 +217,7 @@ func TestRuntimeLostExecutionTriesPlanBeforeRuntime(t *testing.T) { methods := f.gate.Sequence() sessionRelease := slices.Index(methods, harness.ReleaseSession) + planRelease := slices.Index(methods, harness.ReleasePlan) if sessionRelease < 0 || planRelease <= sessionRelease || f.gate.Count(harness.ReleasePlan) != 1 || @@ -221,6 +230,7 @@ func TestRuntimeLostExecutionTriesPlanBeforeRuntime(t *testing.T) { } f.assertNarrowParentClosed() + planCloses := f.record.Snapshot().Count(f.record.Snapshot().OfKind("plan")[0].ID, "Close") if planCloses != 1 { t.Fatalf("Plan fallback returned before hosted Plan cleanup: closes=%d", planCloses) @@ -264,6 +274,7 @@ func TestRuntimeLostAllocationEscalatesFailedParentCleanup(t *testing.T) { }() f.awaitCommitted() f.reply.Deliver() + err := f.awaitResult(result) if !errors.Is(err, parentErr) || (operation.name == "session run" && !errors.Is(err, planErr)) || diff --git a/test/integration/cancellation_test.go b/test/integration/cancellation_test.go index 0288c73..cef9ebe 100644 --- a/test/integration/cancellation_test.go +++ b/test/integration/cancellation_test.go @@ -5,12 +5,13 @@ import ( "errors" "testing" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" "github.com/MontFerret/wire/server" "github.com/MontFerret/wire/test/integration/harness" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestCancellationReachesHostedOperations(t *testing.T) { @@ -110,6 +111,7 @@ func TestCancellationReachesHostedOperations(t *testing.T) { go func() { result <- run() }() harness.Await(t, block.Started) cancel() + err := harness.Await(t, result) if !errors.Is(err, context.Canceled) && status.Code(err) != codes.Canceled { t.Fatalf("caller cancellation=%v", err) @@ -181,6 +183,7 @@ func TestCompileCancellationPreservesDetachedAllocation(t *testing.T) { } snapshot := h.RuntimeSpy().Recorder().Snapshot() + plans := snapshot.OfKind("plan") if len(plans) != 1 || snapshot.Count(plans[0].ID, "Close") != 1 || h.Faults().Count(harness.CloseRuntime) != 0 { t.Fatalf("cancelled allocation was not reclaimed narrowly: %+v", snapshot) diff --git a/test/integration/connection_failure_test.go b/test/integration/connection_failure_test.go index f59856c..5e229ff 100644 --- a/test/integration/connection_failure_test.go +++ b/test/integration/connection_failure_test.go @@ -5,10 +5,11 @@ import ( "fmt" "testing" - "github.com/MontFerret/wire/client" - "github.com/MontFerret/wire/pkg/failure" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/MontFerret/wire/client" + "github.com/MontFerret/wire/pkg/failure" ) // connectionLossRPCError supplies public client metadata and a transport status diff --git a/test/integration/connection_test.go b/test/integration/connection_test.go index eae874a..1b4e9ac 100644 --- a/test/integration/connection_test.go +++ b/test/integration/connection_test.go @@ -6,17 +6,19 @@ import ( "io" "testing" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" "github.com/MontFerret/wire/client" "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/test/integration/harness" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestUnavailableServer(t *testing.T) { h := harness.New(t, harness.WithUnavailableServer()) + runtime, err := h.OpenRuntime() if runtime != nil || status.Code(err) != codes.Unavailable { t.Fatalf("unavailable handshake: runtime=%T err=%v", runtime, err) @@ -194,8 +196,9 @@ func TestWatchTerminationReturnsError(t *testing.T) { } h.Faults().EndWatch(operation, watchErr, block.Started) + err := run() - if err == nil || (watchErr == io.EOF && !errors.Is(err, io.EOF)) || (watchErr != io.EOF && status.Code(err) != codes.Unavailable) { + if err == nil || (watchErr == io.EOF && !errors.Is(err, io.EOF)) || (watchErr != io.EOF && status.Code(err) != codes.Unavailable) { //nolint:errorlint // Distinguish the exact EOF fixture from injected transport failures. t.Fatalf("watch failure=%v", err) } @@ -217,6 +220,7 @@ func TestDebuggerCommandFailure(t *testing.T) { h := harness.New(t, harness.WithBehavior(harness.RuntimeBehavior{Plan: harness.PlanBehavior{Debugger: harness.DebuggerBehavior{Command: func(context.Context, string, int) (*debugger.Event, error) { return nil, errors.New("private command failure") }}}})) + plan, err := h.Runtime().CompileDebug(h.Context(), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) @@ -228,6 +232,7 @@ func TestDebuggerCommandFailure(t *testing.T) { } _, err = session.Start(h.Context()) + var remote *failure.Failure if !errors.As(err, &remote) || remote.Category != failure.CategoryInternalRuntime || remote.Message != "runtime operation failed" { t.Fatalf("debug command failure=%v", err) diff --git a/test/integration/debugger_test.go b/test/integration/debugger_test.go index 339b7f1..86fc23f 100644 --- a/test/integration/debugger_test.go +++ b/test/integration/debugger_test.go @@ -47,6 +47,7 @@ func TestDebuggerRoundTrip(t *testing.T) { return nil }, }}})) + plan, err := h.Runtime().CompileDebug(h.Context(), api.Source{Name: "debug.fql", Content: "RETURN @input"}) if err != nil { t.Fatal(err) diff --git a/test/integration/errors_test.go b/test/integration/errors_test.go index b64a345..2486afb 100644 --- a/test/integration/errors_test.go +++ b/test/integration/errors_test.go @@ -9,6 +9,9 @@ import ( "testing" "time" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" "github.com/MontFerret/api/diagnostics" @@ -17,8 +20,6 @@ import ( "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/server" "github.com/MontFerret/wire/test/integration/harness" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestDiagnosticsAndFailureClassification(t *testing.T) { @@ -144,6 +145,7 @@ func TestErrorFamilies(t *testing.T) { }) t.Run("not found and invalid debug state", func(t *testing.T) { h := harness.New(t) + plan, err := h.Runtime().CompileDebug(h.Context(), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) @@ -155,6 +157,7 @@ func TestErrorFamilies(t *testing.T) { } err = session.DeleteBreakpoint(987) + var remote *client.Error if !errors.As(err, &remote) || remote.Category != failure.CategoryBreakpointNotFound || status.Code(err) != codes.NotFound { t.Fatalf("not-found classification=%v", err) @@ -214,6 +217,7 @@ func TestConstructorPanicPreservesParent(t *testing.T) { return nil }}})) + plan, err := h.Runtime().Compile(h.Context(), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) @@ -286,6 +290,7 @@ func TestPanicContainmentAndResourcePoisoning(t *testing.T) { } h.RuntimeSpy().Recorder().Wait(t, "panicked debugger closes", func(s harness.Snapshot) bool { return s.Count(s.OfKind("debugger")[0].ID, "Close") == 1 }) + snapshot := h.RuntimeSpy().Recorder().Snapshot() if snapshot.Count(snapshot.OfKind("debugger")[0].ID, "Frames") != 1 { t.Fatal("a poisoned hosted debugger received another inspection call") diff --git a/test/integration/harness/coordination.go b/test/integration/harness/coordination.go index e657a9c..74b8a1d 100644 --- a/test/integration/harness/coordination.go +++ b/test/integration/harness/coordination.go @@ -17,6 +17,7 @@ type Block struct { once sync.Once } +// NewBlock creates a single-invocation barrier that test cleanup always releases. func NewBlock(t testing.TB) *Block { block := &Block{Started: make(chan struct{}), Cancelled: make(chan struct{}), Finished: make(chan struct{}), release: make(chan struct{})} t.Cleanup(block.Release) @@ -24,6 +25,7 @@ func NewBlock(t testing.TB) *Block { return block } +// Wait announces entry and waits for release or cancellation, then announces settlement. func (b *Block) Wait(ctx context.Context) error { close(b.Started) defer close(b.Finished) @@ -38,10 +40,12 @@ func (b *Block) Wait(ctx context.Context) error { } } +// Release unblocks the invocation and is safe to call repeatedly. func (b *Block) Release() { b.once.Do(func() { close(b.release) }) } +// Await receives a coordinated result or fails the test after ten seconds. func Await[T any](t testing.TB, channel <-chan T) T { t.Helper() timer := time.NewTimer(10 * time.Second) @@ -59,6 +63,7 @@ func Await[T any](t testing.TB, channel <-chan T) T { } } +// Context creates a ten-second operation context cancelled by test cleanup. func Context(t testing.TB) context.Context { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/test/integration/harness/debugger.go b/test/integration/harness/debugger.go index 8e8a712..e6c8ca7 100644 --- a/test/integration/harness/debugger.go +++ b/test/integration/harness/debugger.go @@ -11,6 +11,7 @@ import ( ) type ( + // DebuggerBehavior configures command, inspection, evaluation, and cleanup hooks. DebuggerBehavior struct { Command func(context.Context, string, int) (*debugger.Event, error) Pause func() error @@ -19,11 +20,13 @@ type ( Close func() error } + // BreakpointRequest records both location and binding options for transport assertions. BreakpointRequest struct { Location source.Location Options debugger.BreakpointOptions } + // DebuggerSpy records hosted debugger operations and maintains deterministic breakpoint fixtures. DebuggerSpy struct { id int recorder *Recorder @@ -50,35 +53,42 @@ func (d *DebuggerSpy) command(ctx context.Context, method string) (*debugger.Eve reason := debugger.ReasonStep - if method == "Start" { + switch method { + case "Start": reason = debugger.ReasonEntry - } else if method == "Continue" { + case "Continue": reason = debugger.ReasonCompleted } return &debugger.Event{Reason: reason}, nil } +// Start records an initial command, defaulting to an entry stop when no hook is set. func (d *DebuggerSpy) Start(ctx context.Context) (*debugger.Event, error) { return d.command(ctx, "Start") } +// Continue records a resume command, defaulting to completion when no hook is set. func (d *DebuggerSpy) Continue(ctx context.Context) (*debugger.Event, error) { return d.command(ctx, "Continue") } +// StepOver records a step-over command, defaulting to a step stop when no hook is set. func (d *DebuggerSpy) StepOver(ctx context.Context) (*debugger.Event, error) { return d.command(ctx, "StepOver") } +// StepIn records a step-in command, defaulting to a step stop when no hook is set. func (d *DebuggerSpy) StepIn(ctx context.Context) (*debugger.Event, error) { return d.command(ctx, "StepIn") } +// StepOut records a step-out command, defaulting to a step stop when no hook is set. func (d *DebuggerSpy) StepOut(ctx context.Context) (*debugger.Event, error) { return d.command(ctx, "StepOut") } +// Pause records the request before invoking the optional pause hook. func (d *DebuggerSpy) Pause() error { d.recorder.record(Call{Resource: d.id, Method: "Pause"}) @@ -89,10 +99,12 @@ func (d *DebuggerSpy) Pause() error { return nil } +// SetBreakpoint records a breakpoint request with default options. func (d *DebuggerSpy) SetBreakpoint(location source.Location) (debugger.Breakpoint, error) { return d.SetBreakpointAt(location, debugger.BreakpointOptions{}) } +// SetBreakpointAt records location and options and assigns deterministic binding metadata. func (d *DebuggerSpy) SetBreakpointAt(location source.Location, options debugger.BreakpointOptions) (debugger.Breakpoint, error) { d.recorder.record(Call{Resource: d.id, Method: "SetBreakpointAt", Argument: BreakpointRequest{Location: location, Options: options}}) d.mu.Lock() @@ -105,6 +117,7 @@ func (d *DebuggerSpy) SetBreakpointAt(location source.Location, options debugger return value, nil } +// DeleteBreakpoint records the ID and removes it from the fixture's breakpoint set. func (d *DebuggerSpy) DeleteBreakpoint(id debugger.BreakpointID) error { d.recorder.record(Call{Resource: d.id, Method: "DeleteBreakpoint", Argument: id}) d.mu.Lock() @@ -114,6 +127,7 @@ func (d *DebuggerSpy) DeleteBreakpoint(id debugger.BreakpointID) error { return nil } +// Breakpoints returns the fixture's bindings sorted by ID for deterministic assertions. func (d *DebuggerSpy) Breakpoints() []debugger.Breakpoint { d.recorder.record(Call{Resource: d.id, Method: "Breakpoints"}) d.mu.Lock() @@ -130,6 +144,7 @@ func (d *DebuggerSpy) Breakpoints() []debugger.Breakpoint { return result } +// Frames records inspection and returns two distinguishable frames after the inspection hook. func (d *DebuggerSpy) Frames() ([]debugger.Frame, error) { d.recorder.record(Call{Resource: d.id, Method: "Frames"}) @@ -145,16 +160,19 @@ func (d *DebuggerSpy) Frames() ([]debugger.Frame, error) { }, nil } +// Locals uses frame zero so tests can distinguish default-frame access. func (d *DebuggerSpy) Locals() ([]debugger.Variable, error) { return d.FrameLocals(0) } +// FrameLocals records the frame index and returns a distinguishable variable fixture. func (d *DebuggerSpy) FrameLocals(frame int) ([]debugger.Variable, error) { d.recorder.record(Call{Resource: d.id, Method: "FrameLocals", Index: frame}) return []debugger.Variable{{Name: fmt.Sprintf("local-%d", frame), Value: debugger.Value{Type: "object", Display: "{...}", Reference: 9}, Mutable: true, Param: frame == 1}}, nil } +// Variables expands the fixture reference and rejects unknown references. func (d *DebuggerSpy) Variables(reference debugger.ValueReference) ([]debugger.Variable, error) { d.recorder.record(Call{Resource: d.id, Method: "Variables", Argument: reference}) @@ -165,10 +183,12 @@ func (d *DebuggerSpy) Variables(reference debugger.ValueReference) ([]debugger.V return []debugger.Variable{{Name: "child", Value: debugger.Value{Type: "string", Display: "value"}}}, nil } +// Evaluate uses frame zero so tests can distinguish default-frame evaluation. func (d *DebuggerSpy) Evaluate(ctx context.Context, expression string) (debugger.Value, error) { return d.EvaluateFrame(ctx, 0, expression) } +// EvaluateFrame records frame and expression around the hook or deterministic fallback value. func (d *DebuggerSpy) EvaluateFrame(ctx context.Context, frame int, expression string) (debugger.Value, error) { d.recorder.record(Call{Resource: d.id, Method: "EvaluateFrame", Index: frame, Argument: expression}) defer d.recorder.record(Call{Resource: d.id, Method: "EvaluateFrameFinished"}) @@ -180,6 +200,7 @@ func (d *DebuggerSpy) EvaluateFrame(ctx context.Context, frame int, expression s return debugger.Value{Type: "string", Display: fmt.Sprintf("frame-%d:%s", frame, expression)}, nil } +// Close records entry and settlement around the configured cleanup hook. func (d *DebuggerSpy) Close() error { d.recorder.record(Call{Resource: d.id, Method: "Close"}) defer d.recorder.record(Call{Resource: d.id, Method: "CloseFinished"}) diff --git a/test/integration/harness/failure.go b/test/integration/harness/failure.go index 730ed48..8ca9889 100644 --- a/test/integration/harness/failure.go +++ b/test/integration/harness/failure.go @@ -4,18 +4,22 @@ import ( "context" "sync" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" + + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" ) type ( // Operation names faults without exposing protobuf services to contract tests. Operation string - Outcome string + // Outcome selects how a committed unary response is delivered or lost. + Outcome string + + // ResponseGate exposes server commitment and holds client delivery until explicitly released. ResponseGate struct { Committed chan struct{} deliver chan struct{} @@ -49,6 +53,7 @@ type ( } ) +// Operations identify intercepted RPCs; outcomes describe delivery after server commitment. const ( Compile Operation = "compile" CompileDebug Operation = "compile debug" @@ -118,6 +123,7 @@ func newFaults(connection grpc.ClientConnInterface) *Faults { } } +// Arm holds the next matching unary response after the real server has committed it. func (f *Faults) Arm(operation Operation, outcome Outcome) *ResponseGate { f.mu.Lock() defer f.mu.Unlock() @@ -129,10 +135,12 @@ func (f *Faults) Arm(operation Operation, outcome Outcome) *ResponseGate { return gate } +// Deliver releases a held response with its configured outcome; repeated calls are safe. func (g *ResponseGate) Deliver() { g.once.Do(func() { close(g.deliver) }) } +// Invoke records unary call order and applies configured failures around real dispatch. func (f *Faults) Invoke(ctx context.Context, method string, request, response any, options ...grpc.CallOption) error { operation := operationFor(method) f.mu.Lock() @@ -181,6 +189,7 @@ func (f *Faults) Invoke(ctx context.Context, method string, request, response an return nil } +// NewStream forwards the call and optionally injects a receive failure after its first snapshot. func (f *Faults) NewStream(ctx context.Context, description *grpc.StreamDesc, method string, options ...grpc.CallOption) (grpc.ClientStream, error) { f.mu.Lock() failure, injected := f.watchFailures[operationFor(method)] @@ -192,6 +201,7 @@ func (f *Faults) NewStream(ctx context.Context, description *grpc.StreamDesc, me } streamCtx, cancel := context.WithCancel(ctx) + stream, err := f.ClientConnInterface.NewStream(streamCtx, description, method, options...) if err != nil { cancel() @@ -227,12 +237,14 @@ func (s *failingStream) RecvMsg(message any) error { return err } +// Fail injects an error before dispatch for matching unary calls. func (f *Faults) Fail(operation Operation, err error) { f.mu.Lock() f.failures[operation] = err f.mu.Unlock() } +// FailResponse injects an error after a matching unary call succeeds on the server. func (f *Faults) FailResponse(operation Operation, err error) { f.mu.Lock() f.responseFailures[operation] = err @@ -247,6 +259,7 @@ func (f *Faults) EndWatch(operation Operation, err error, after <-chan struct{}) f.mu.Unlock() } +// Count reports recorded unary invocations for the given operation. func (f *Faults) Count(operation Operation) int { count := 0 @@ -259,6 +272,7 @@ func (f *Faults) Count(operation Operation) int { return count } +// Sequence returns a copy of unary invocation order for cleanup-order assertions. func (f *Faults) Sequence() []Operation { f.mu.Lock() defer f.mu.Unlock() diff --git a/test/integration/harness/harness.go b/test/integration/harness/harness.go index 27236fb..d13598e 100644 --- a/test/integration/harness/harness.go +++ b/test/integration/harness/harness.go @@ -10,17 +10,19 @@ import ( "testing" "time" - "github.com/MontFerret/api" - "github.com/MontFerret/wire/client" - "github.com/MontFerret/wire/server" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + + "github.com/MontFerret/api" + "github.com/MontFerret/wire/client" + "github.com/MontFerret/wire/server" ) type ( + // Option configures a fixture before its server and transport are created. Option func(*configuration) configuration struct { @@ -49,14 +51,17 @@ type ( } ) +// WithBehavior configures the default hosted runtime spy before the server starts. func WithBehavior(behavior RuntimeBehavior) Option { return func(c *configuration) { c.behavior = behavior } } +// WithRuntime supplies a borrowed hosted implementation in place of the default spy. func WithRuntime(runtime api.Runtime) Option { return func(c *configuration) { c.runtime = runtime } } +// WithServerOptions appends options applied when constructing the public server. func WithServerOptions(options ...server.Option) Option { return func(c *configuration) { c.serverOptions = append(c.serverOptions, options...) } } @@ -66,6 +71,7 @@ func WithUnavailableServer() Option { return func(c *configuration) { c.unavailable = true } } +// New starts a public client/server fixture and registers ordered resource and transport cleanup. func New(t testing.TB, options ...Option) *Harness { t.Helper() var configured configuration @@ -76,6 +82,7 @@ func New(t testing.TB, options ...Option) *Harness { h := &Harness{t: t, ctx: Context(t)} t.Cleanup(h.cleanup) + hosted := configured.runtime if hosted == nil { h.spy = NewRuntimeSpy(configured.behavior) @@ -85,6 +92,7 @@ func New(t testing.TB, options ...Option) *Harness { } var err error + h.server, err = server.NewServer(hosted, configured.serverOptions...) if err != nil { t.Fatal(err) @@ -124,22 +132,27 @@ func New(t testing.TB, options ...Option) *Harness { return h } +// Runtime returns the initial remote runtime, or nil for an unavailable-server fixture. func (h *Harness) Runtime() api.Runtime { return h.runtime } +// RuntimeSpy returns the hosted spy, or nil when a different implementation was supplied. func (h *Harness) RuntimeSpy() *RuntimeSpy { return h.spy } +// Context returns the fixture's bounded context for test operations. func (h *Harness) Context() context.Context { return h.ctx } +// Faults returns the injector shared by this fixture's client connections. func (h *Harness) Faults() *Faults { return h.faults } +// OpenRuntime opens another logical runtime on the shared transport and registers its cleanup. func (h *Harness) OpenRuntime() (api.Runtime, error) { runtime, err := client.New(h.ctx, h.faults) if err != nil { @@ -153,12 +166,14 @@ func (h *Harness) OpenRuntime() (api.Runtime, error) { return runtime, nil } +// ExpectCleanupError permits a matching cleanup cause without hiding other joined failures. func (h *Harness) ExpectCleanupError(err error) { h.mu.Lock() h.expected = append(h.expected, err) h.mu.Unlock() } +// Shutdown stops the public server with a bounded wait and permits resulting disconnect errors. func (h *Harness) Shutdown() error { h.mu.Lock() h.stopped = true @@ -169,6 +184,7 @@ func (h *Harness) Shutdown() error { return h.server.Shutdown(ctx) } +// CloseTransport closes the caller-owned gRPC connection once to exercise disconnection. func (h *Harness) CloseTransport() error { h.mu.Lock() diff --git a/test/integration/harness/options.go b/test/integration/harness/options.go index 621ee5f..d935137 100644 --- a/test/integration/harness/options.go +++ b/test/integration/harness/options.go @@ -3,23 +3,27 @@ package harness import "github.com/MontFerret/api" type ( + // CompileOptions preserves optimization-level presence separately from its value. CompileOptions struct { Level api.OptimizationLevel HasLevel bool } + // SessionOptions records parameters and output format supplied through API options. SessionOptions struct { Params map[string]any ContentType string } ) +// SetOptimizationLevel records both the value and explicit option presence. func (o *CompileOptions) SetOptimizationLevel(level api.OptimizationLevel) error { o.Level, o.HasLevel = level, true return nil } +// SetParam records one supplied parameter without changing its value type. func (o *SessionOptions) SetParam(name string, value any) error { if o.Params == nil { o.Params = make(map[string]any) @@ -30,6 +34,7 @@ func (o *SessionOptions) SetParam(name string, value any) error { return nil } +// SetParams merges the supplied parameters into the recorded option state. func (o *SessionOptions) SetParams(values map[string]any) error { for name, value := range values { if err := o.SetParam(name, value); err != nil { @@ -40,6 +45,7 @@ func (o *SessionOptions) SetParams(values map[string]any) error { return nil } +// SetOutputContentType records the requested output format verbatim. func (o *SessionOptions) SetOutputContentType(value string) error { o.ContentType = value diff --git a/test/integration/harness/plan.go b/test/integration/harness/plan.go index b091870..7da9317 100644 --- a/test/integration/harness/plan.go +++ b/test/integration/harness/plan.go @@ -8,6 +8,7 @@ import ( ) type ( + // PlanBehavior configures child creation, option observation, and plan cleanup hooks. PlanBehavior struct { Params []string NewSession func(context.Context, SessionOptions) error @@ -17,6 +18,7 @@ type ( Close func() error } + // PlanSpy records hosted plan calls and creates observable child resources. PlanSpy struct { id int recorder *Recorder @@ -26,12 +28,14 @@ type ( var _ api.Plan = (*PlanSpy)(nil) +// Params records inspection and returns a copy of the configured parameter names. func (p *PlanSpy) Params() []string { p.recorder.record(Call{Resource: p.id, Method: "Params"}) return append([]string(nil), p.behavior.Params...) } +// NewSession records applied options and creates a child spy after the creation hook succeeds. func (p *PlanSpy) NewSession(ctx context.Context, options ...api.SessionOption) (api.Session, error) { configured, err := applyOptions(options) if err != nil { @@ -54,6 +58,7 @@ func (p *PlanSpy) NewSession(ctx context.Context, options ...api.SessionOption) return &SessionSpy{id: p.recorder.create("session", p.id), recorder: p.recorder, behavior: behavior}, nil } +// NewDebugSession records applied options and creates a child debugger after the hook succeeds. func (p *PlanSpy) NewDebugSession(ctx context.Context, options ...api.SessionOption) (debugger.Session, error) { configured, err := applyOptions(options) if err != nil { @@ -71,6 +76,7 @@ func (p *PlanSpy) NewDebugSession(ctx context.Context, options ...api.SessionOpt return newDebuggerSpy(p.recorder, p.id, p.behavior.Debugger), nil } +// Close records entry and settlement around the configured cleanup hook. func (p *PlanSpy) Close() error { p.recorder.record(Call{Resource: p.id, Method: "Close"}) defer p.recorder.record(Call{Resource: p.id, Method: "CloseFinished"}) diff --git a/test/integration/harness/recorder.go b/test/integration/harness/recorder.go index c65442f..33fb520 100644 --- a/test/integration/harness/recorder.go +++ b/test/integration/harness/recorder.go @@ -27,6 +27,7 @@ type ( Index int } + // Snapshot captures the resources and API calls observed by a Recorder. Snapshot struct { Resources []Resource Calls []Call @@ -90,6 +91,7 @@ func (r *Recorder) snapshot() Snapshot { return result } +// Snapshot copies observed resource and call slices and their recorded session options. func (r *Recorder) Snapshot() Snapshot { r.mu.Lock() defer r.mu.Unlock() @@ -97,6 +99,7 @@ func (r *Recorder) Snapshot() Snapshot { return r.snapshot() } +// Count reports invocations of a method on one hosted resource. func (s Snapshot) Count(id int, method string) int { count := 0 @@ -109,6 +112,7 @@ func (s Snapshot) Count(id int, method string) int { return count } +// OfKind selects hosted resources of a kind in creation order. func (s Snapshot) OfKind(kind string) []Resource { var result []Resource @@ -121,6 +125,7 @@ func (s Snapshot) OfKind(kind string) []Resource { return result } +// Wait checks observations after each recorder change and reports a bounded timeout. func (r *Recorder) Wait(t testing.TB, description string, predicate func(Snapshot) bool) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -145,6 +150,8 @@ func (r *Recorder) Wait(t testing.TB, description string, predicate func(Snapsho } } +// AssertClosed waits for hosted work to settle and checks exactly-once descendant cleanup. +// Borrowed runtimes must remain open. func (r *Recorder) AssertClosed(t testing.TB) { t.Helper() r.Wait(t, "hosted resource reclamation", func(s Snapshot) bool { diff --git a/test/integration/harness/runtime.go b/test/integration/harness/runtime.go index 3aaddfd..b89eeb9 100644 --- a/test/integration/harness/runtime.go +++ b/test/integration/harness/runtime.go @@ -14,6 +14,7 @@ type ( Plan PlanBehavior } + // RuntimeSpy records hosted API calls and the ownership tree of its child resources. RuntimeSpy struct { id int recorder *Recorder @@ -23,20 +24,24 @@ type ( var _ api.Runtime = (*RuntimeSpy)(nil) +// NewRuntimeSpy creates a hosted runtime with a fresh resource and call recorder. func NewRuntimeSpy(behavior RuntimeBehavior) *RuntimeSpy { recorder := newRecorder() return &RuntimeSpy{id: recorder.create("runtime", 0), recorder: recorder, behavior: behavior} } +// Recorder exposes observations shared by the runtime and every child spy. func (r *RuntimeSpy) Recorder() *Recorder { return r.recorder } +// ID is the recorder's hosted-resource identity, independent of Wire protocol handles. func (r *RuntimeSpy) ID() int { return r.id } +// Run records source and applied options before invoking the configured direct-run hook. func (r *RuntimeSpy) Run(ctx context.Context, src api.Source, options ...api.SessionOption) (api.Output, error) { configured, err := applyOptions(options) if err != nil { @@ -53,10 +58,12 @@ func (r *RuntimeSpy) Run(ctx context.Context, src api.Source, options ...api.Ses return api.Output{}, nil } +// Compile records normal compilation and returns a child plan after the hook succeeds. func (r *RuntimeSpy) Compile(ctx context.Context, src api.Source, options ...api.PlanOption) (api.Plan, error) { return r.compile(ctx, src, false, options) } +// CompileDebug records debug compilation and returns a child plan after the hook succeeds. func (r *RuntimeSpy) CompileDebug(ctx context.Context, src api.Source, options ...api.PlanOption) (api.Plan, error) { return r.compile(ctx, src, true, options) } @@ -90,6 +97,7 @@ func (r *RuntimeSpy) compile(ctx context.Context, src api.Source, debug bool, op return &PlanSpy{id: r.recorder.create("plan", r.id), recorder: r.recorder, behavior: r.behavior.Plan}, nil } +// Close records calls so tests can detect accidental closure of the borrowed runtime. func (r *RuntimeSpy) Close() error { r.recorder.record(Call{Resource: r.id, Method: "Close"}) diff --git a/test/integration/harness/session.go b/test/integration/harness/session.go index 5eb3614..2b810ff 100644 --- a/test/integration/harness/session.go +++ b/test/integration/harness/session.go @@ -7,11 +7,13 @@ import ( ) type ( + // SessionBehavior configures run and cleanup hooks for a durable hosted session. SessionBehavior struct { Run func(context.Context, int) (api.Output, error) Close func() error } + // SessionSpy records repeated runs and cleanup of one hosted session. SessionSpy struct { id int recorder *Recorder @@ -21,6 +23,7 @@ type ( var _ api.Session = (*SessionSpy)(nil) +// Run records entry and settlement, passing the invocation count to the configured hook. func (s *SessionSpy) Run(ctx context.Context) (api.Output, error) { call := s.recorder.record(Call{Resource: s.id, Method: "Run"}) defer s.recorder.record(Call{Resource: s.id, Method: "RunFinished"}) @@ -32,6 +35,7 @@ func (s *SessionSpy) Run(ctx context.Context) (api.Output, error) { return api.Output{}, nil } +// Close records entry and settlement around the configured cleanup hook. func (s *SessionSpy) Close() error { s.recorder.record(Call{Resource: s.id, Method: "Close"}) defer s.recorder.record(Call{Resource: s.id, Method: "CloseFinished"}) diff --git a/test/integration/lifecycle_test.go b/test/integration/lifecycle_test.go index 6a5efab..ccb1d96 100644 --- a/test/integration/lifecycle_test.go +++ b/test/integration/lifecycle_test.go @@ -42,6 +42,7 @@ func TestRecursiveCloseReclaimsActiveDescendants(t *testing.T) { }}, }, })) + other, err := h.OpenRuntime() if err != nil { t.Fatal(err) @@ -107,9 +108,10 @@ func TestRecursiveCloseReclaimsActiveDescendants(t *testing.T) { closeOwner := session.Close - if owner == "plan" { + switch owner { + case "plan": closeOwner = plan.Close - } else if owner == "runtime" { + case "runtime": closeOwner = h.Runtime().Close } @@ -190,6 +192,7 @@ func TestConcurrentSiblingSessionsRemainIndependent(t *testing.T) { return api.Output{ContentType: "text/plain", Content: []byte(fmt.Sprint(index))}, blocks[index].Wait(ctx) }} }}})) + plan, err := h.Runtime().Compile(h.Context(), api.Source{Content: "RETURN @index"}) if err != nil { t.Fatal(err) @@ -233,6 +236,7 @@ func TestConcurrentSiblingSessionsRemainIndependent(t *testing.T) { harness.Await(t, blocks[0].Cancelled) blocks[1].Release() + second := harness.Await(t, results[1]) if second.err != nil || string(second.output.Content) != "1" { t.Fatalf("unrelated active sibling affected: %+v", second) @@ -260,6 +264,7 @@ func TestConcurrentPlanSessionCreation(t *testing.T) { return nil } }}})) + plan, err := h.Runtime().Compile(h.Context(), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) diff --git a/test/integration/plan_test.go b/test/integration/plan_test.go index 5382f38..2d698b4 100644 --- a/test/integration/plan_test.go +++ b/test/integration/plan_test.go @@ -33,6 +33,7 @@ func TestCompileRoundTrip(t *testing.T) { } src := api.Source{Name: "folder/query.fql", Content: "RETURN @input + @other\n"} + plan, err := compile(h.Context(), src, test.options...) if err != nil { t.Fatal(err) @@ -163,6 +164,7 @@ func TestReusablePlanAndDurableSessions(t *testing.T) { return api.Output{ContentType: options.ContentType, Content: []byte(options.Params["input"].(string))}, nil }} }}})) + plan, err := h.Runtime().Compile(h.Context(), api.Source{Content: "RETURN @input"}) if err != nil { t.Fatal(err) @@ -187,6 +189,7 @@ func TestReusablePlanAndDurableSessions(t *testing.T) { } snapshot := h.RuntimeSpy().Recorder().Snapshot() + plans, hostedSessions := snapshot.OfKind("plan"), snapshot.OfKind("session") if len(plans) != 1 || len(hostedSessions) != 3 || snapshot.Count(h.RuntimeSpy().ID(), "Compile") != 1 { t.Fatalf("plan or session recreated: %+v", snapshot) diff --git a/test/integration/release_test.go b/test/integration/release_test.go index 03c0433..5700981 100644 --- a/test/integration/release_test.go +++ b/test/integration/release_test.go @@ -4,10 +4,11 @@ import ( "errors" "testing" - "github.com/MontFerret/api" - "github.com/MontFerret/wire/test/integration/harness" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/MontFerret/api" + "github.com/MontFerret/wire/test/integration/harness" ) func TestRuntimeCompletedExecutionReleaseFailurePreservesParents(t *testing.T) { @@ -73,6 +74,7 @@ func TestKnownResourceCloseFailurePreservesSiblings(t *testing.T) { for _, acknowledged := range []bool{false, true} { t.Run(operation.name+map[bool]string{false: "/undelivered", true: "/lost acknowledgement"}[acknowledged], func(t *testing.T) { f := newRuntimeAllocationFixture(t, operation) + siblingPlan, err := f.remote.Compile(f.h.Context(), api.Source{Content: "RETURN 2"}) if err != nil { t.Fatal(err) diff --git a/test/integration/runtime_test.go b/test/integration/runtime_test.go index ab24417..1d6db42 100644 --- a/test/integration/runtime_test.go +++ b/test/integration/runtime_test.go @@ -171,6 +171,7 @@ func TestSessionOptionsRoundTrip(t *testing.T) { func TestRuntimeCloseBorrowsTransportAndHostedRuntime(t *testing.T) { h := harness.New(t) + other, err := h.OpenRuntime() if err != nil { t.Fatal(err) diff --git a/test/integration/session_test.go b/test/integration/session_test.go index 78b78c3..337f44d 100644 --- a/test/integration/session_test.go +++ b/test/integration/session_test.go @@ -7,13 +7,14 @@ import ( "sync" "testing" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/MontFerret/api" "github.com/MontFerret/wire/client" "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/server" "github.com/MontFerret/wire/test/integration/harness" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestSessionRejectsOverlapAndReopensAfterRelease(t *testing.T) { @@ -27,6 +28,7 @@ func TestSessionRejectsOverlapAndReopensAfterRelease(t *testing.T) { return api.Output{ContentType: "text/plain", Content: []byte("reused")}, nil }} }}})) + plan, err := h.Runtime().Compile(h.Context(), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) @@ -46,6 +48,7 @@ func TestSessionRejectsOverlapAndReopensAfterRelease(t *testing.T) { }() harness.Await(t, block.Started) _, err = session.Run(h.Context()) + var remote *client.Error if !errors.As(err, &remote) || remote.Category != failure.CategoryInvalidState || status.Code(err) != codes.FailedPrecondition { t.Fatalf("overlapping Run=%v", err) @@ -59,12 +62,14 @@ func TestSessionRejectsOverlapAndReopensAfterRelease(t *testing.T) { harness.Await(t, block.Cancelled) harness.Await(t, block.Finished) + output, err := session.Run(h.Context()) if err != nil || string(output.Content) != "reused" { t.Fatalf("durable session not reusable: %+v %v", output, err) } snapshot := h.RuntimeSpy().Recorder().Snapshot() + sessions := snapshot.OfKind("session") if len(sessions) != 1 || snapshot.Count(sessions[0].ID, "Run") != 2 || snapshot.Count(sessions[0].ID, "Close") != 0 { t.Fatalf("hosted session lifecycle=%+v", snapshot) @@ -86,6 +91,7 @@ func TestSessionCompletionRacesCancellationWithoutDuplicateCleanup(t *testing.T) return api.Output{}, nil }} }}})) + plan, err := h.Runtime().Compile(h.Context(), api.Source{Content: "RETURN 1"}) if err != nil { t.Fatal(err) @@ -129,6 +135,7 @@ func TestSessionCompletionRacesCancellationWithoutDuplicateCleanup(t *testing.T) } snapshot := h.RuntimeSpy().Recorder().Snapshot() + id := snapshot.OfKind("session")[0].ID if snapshot.Count(id, "Run") != 2 || snapshot.Count(id, "Close") != 0 { t.Fatalf("durable session changed: %+v", snapshot)