diff --git a/AGENTS.md b/AGENTS.md index 345fa93..5d9993d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,16 @@ contained implementation panic is not a normal API error, and a stateful runtime resource whose operation panics must not be reused unless its owner can prove that reuse is safe. +Connection-scoped resource stores own lookup and quota accounting. Plans and +sessions own descendant creation and release; Connection remains the logical +lifetime boundary. Keep admission and parent links under the store mutex, and +never hold a resource-state lock while acquiring it. Hosted calls and cleanup +waits run outside the store lock. See the architecture document for details. + +The server accepts `api.Runtime` directly and owns its optional `RuntimeIdentity` +configuration type. Do not reintroduce runtime aliases, operation managers, +controller facades, or dependency-carrying context wrappers. + ## Generated code Protobuf definitions and Buf configuration are source. Files under @@ -72,7 +82,7 @@ changes are suspicious and require explanation. The `server` package, the `client` package, the shared `pkg/execution`, `pkg/debugger`, and `pkg/failure` packages, and the versioned protobuf service are API-sensitive. The module root intentionally has no Go compatibility -package. Follow [Client Handles](docs/client.md) for the facade ownership and +package. Follow [Client Handles](docs/client.md) for the API adapter ownership and lifecycle contract. Export only externally required symbols, keep logical connection and resource diff --git a/README.md b/README.md index 151e7fb..d0fae28 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ See [Wire Protocol](docs/protocol.md) for every RPC/message/enum, lifecycle and The host chooses and configures both the runtime implementation and endpoint. This function accepts caller-owned values and does not close either one: ```go -func serveRuntime(ctx context.Context, hostRuntime server.Runtime, listener net.Listener) error { - wireServer, err := server.NewServer(hostRuntime, server.WithRuntimeIdentity(execution.Identity{ +func serveRuntime(ctx context.Context, hostRuntime api.Runtime, listener net.Listener) error { + wireServer, err := server.NewServer(hostRuntime, server.WithRuntimeIdentity(server.RuntimeIdentity{ Name: "my-app", Version: "1.0.0", InstanceID: "worker-1", })) if err != nil { @@ -65,9 +65,13 @@ func serveRuntime(ctx context.Context, hostRuntime server.Runtime, listener net. } ``` -`server.Runtime` aliases the canonical `api.Runtime` interface. The alias lets -host-facing function signatures use the server package without changing runtime -ownership or requiring an adapter. +`NewServer` accepts the canonical `api.Runtime` directly. `server.RuntimeIdentity` +is optional host-supplied handshake metadata. + +For existing hosts, replace `server.Runtime` with `api.Runtime` and +`execution.Identity` with `server.RuntimeIdentity`. The old alias and identity +type were removed without compatibility shims; protocol and ownership behavior +are unchanged. For an application-private Unix socket, the caller creates `net.Listen("unix", socket)`, applies appropriate directory and socket permissions, and closes both the listener and runtime after the Wire server has shut down. diff --git a/docs/architecture.md b/docs/architecture.md index 6472763..7e07b87 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ host → server → server/internal ──┘→ Unified API → runtime impleme | FQL, runtime, output encoding, and debugger semantics | Unified API and runtime implementation | | Runtime construction, configuration, policies, and application state | Host application | | Versioned RPC contract | Protobuf definitions | -| Shared execution, debugger, identity, and failure semantics | `pkg/execution`, `pkg/debugger`, and `pkg/failure` | +| Shared execution, debugger, and failure semantics | `pkg/execution`, `pkg/debugger`, and `pkg/failure` | | RPC adaptation | `server/internal/grpcserver` | | Logical connections and resources | `server/internal/core` | | Public server lifecycle | `server` package | @@ -41,7 +41,8 @@ private within the owning client package. The caller supplies and owns the physical transport. Runtime and resource `Close` methods release logical resources with bounded detached cleanup. -`server.Runtime` continues to alias `api.Runtime`; host ownership is unchanged. +`server.NewServer` accepts `api.Runtime` directly. Optional host identity is +`server.RuntimeIdentity`, supplied through `WithRuntimeIdentity`. ## gRPC service composition @@ -49,21 +50,26 @@ The caller supplies and owns the physical transport. Runtime and resource It contains only those service instances and owns no RPC handlers. Each service embeds its corresponding generated service base and adapts one protocol domain. -| Service | Core dependencies beyond request-context preparation | +| Service | Invocation and ownership | | --- | --- | -| RuntimeService | RuntimeInfo, ConnectionRegistry, Executor, Lifecycle | -| PlanService | Compiler, Lifecycle | -| SessionService | Executor, Lifecycle | -| ExecutionService | Executor, Lifecycle | -| DebugService | Debugger, Lifecycle | - -A shared private `operationContextFactory` owns only the connection registry. -It resolves the logical connection, maps lookup errors, and constructs the core -operation context; each handler cancels that context when it finishes. -Services retain their own resource lookup, validation, and domain adaptation. -Stateless conversion, error mapping, recovery, and subscription functions remain -shared transport infrastructure. DebugService groups its cohesive lifecycle, -commands, inspection, and events across focused files. +| RuntimeService | Opens/closes logical connections; calls `core.Run` with the borrowed runtime and connection store | +| PlanService | Calls `core.CompilePlan` with the borrowed runtime and connection store; releases plans through that store | +| SessionService | Resolves a Plan in the connection store and calls its `NewSession` | +| ExecutionService | Resolves a Plan or Session for execution creation; resolves Execution for watches, cancellation, and release | +| DebugService | Resolves a Plan for debugger creation; resolves DebugSession for commands, inspection, watches, and release | + +`prepareOperation` resolves the connection and returns its resource store plus +an ordinary `context.Context`. The context preserves request values and deadlines +and joins connection cancellation; each handler cancels it to detach the lifetime +callback. There is no dependency-carrying operation context. + +Services convert protobuf sources and options to canonical API types before +calling core. Source/diagnostic, option/value, output, execution, debugger, and +failure conversions are grouped at the transport boundary. Handshake metadata +belongs to transport configuration, not resource management. Domain errors +supply shared Wire categories; gRPC owns status mapping and uses the same +category serialization as terminal failures. Canonical diagnostic extraction +is shared within server error handling. ## Execution and host boundaries @@ -201,22 +207,25 @@ Wire connection └── debug sessions ``` -Internally, `Connection` owns only its opaque ID, cancellation context, open or -closing state, and admission of in-flight operations. Server-scoped -`ConnectionRegistry`, `PlanRegistry`, `SessionRegistry`, `ExecutionRegistry`, -and `DebugSessionRegistry` instances own storage, indexes, and capacity -accounting. Every resource records its owning connection ID. Plan children also -record the Plan ID, and normal-session executions record their Session ID. An -ID lookup always includes the requesting connection, so knowledge of another -connection's ID never grants access. - -The server-scoped `Compiler`, `Executor`, and `Debugger` components own resource -creation. `Compiler` uses `api.Runtime` for compilation and `Executor` uses it -only for the explicit direct-runtime path; Plan execution and debugging use the -`api.Plan` obtained from `PlanRegistry`. `Lifecycle` owns cleanup spanning -resource types. Individual resources retain their own state machines, runtime -handles, watches, and local close invariants. A per-operation Wire `Context` -combines the unary or stream context with the resolved logical connection. +`ConnectionRegistry` is the only server-wide resource index. It owns connection +capacity, active/closing membership, and shutdown admission. `Connection` owns +its ID, cancellation context, retained close result, and one `ResourceStore`. + +The store contains typed maps for plans, normal sessions, executions, and debug +sessions. IDs resolve only in the requesting connection's store; there are no +global resource maps or owner-ID indexes. Plans hold their child collections, +normal sessions hold their active execution, and children retain direct parent +references. Resources remain in the store while closing and are removed only +when cleanup settles. + +`CompilePlan` and `Run` take the borrowed `api.Runtime` and store explicitly. +They own root allocation; neither introduces another runtime wrapper. A Plan +owns its hosted `api.Plan`, parameter metadata, child creation, and descendant +cleanup. A normal Session owns its hosted `api.Session`, poisoning state, and +execution admission. Execution owns asynchronous work, snapshots, cancellation, +and watches. DebugSession directly owns its hosted `debugger.Session`, command +state, breakpoint bookkeeping, watches, and close. No operation managers, +debugger controller, or cross-resource lifecycle manager intervene. The client adapter uses the same ownership tree to reclaim allocations whose responses are lost. Unknown Session IDs invalidate their Plan; unknown @@ -233,71 +242,43 @@ narrow cleanup preserves siblings outside its subtree and never closes the borrowed physical transport. See [Client Handles](client.md) for the cancellation contract. -```text -Compiler ──► api.Runtime -Compiler ──► PlanRegistry ◄── Executor ──► api.Runtime.Run - ◄── Debugger -Executor ──► SessionRegistry -Executor ──► ExecutionRegistry -Debugger ──► DebugSessionRegistry -Lifecycle ──► all five resource registries - -ConnectionRegistry ──► Connection ◄── operation Context -``` - -The arrows show dependencies: components depend on registries, registries do -not depend on components, and `Connection` has no dependency on either. - -`Execution` and `DebugSession` retain their lifecycle and state-machine -semantics while delegating reusable subscription mechanics to a package-private -generic event stream. The stream owns sequence allocation, latest-event replay, -bounded watcher buffers, subscription accounting, fan-out, lag eviction, and -channel shutdown; it has no knowledge of execution or debugger event meaning. -`DebugSession` groups its current stop/result values in one cohesive state value -and orchestrates a session-local breakpoint set, event stream, and -`DebugController`. The controller exclusively owns and operates the Unified API -`debugger.Session`; it contains only runtime-facing commands, inspection, -breakpoint mutation, and idempotent close. The breakpoint set owns only the -Wire-side limit and successful breakpoint records. The aggregate owns command -eligibility, lifecycle and cancellation, breakpoint policy, serialization, and -semantic event construction. - -```text -DebugSession -├── debugSessionState -├── breakpointSet -├── eventStream[debugger.Event] -└── DebugController - └── debugger.Session -``` - -Creation uses reserve, create, and commit phases. Pending capacity is reserved -before calling the Unified API, registry locks are released for runtime calls, -and publication is committed only while the connection and parent plan still -accept children. A normal Session calls `api.Plan.NewSession` once, owns that -hosted session until release, and admits one Execution at a time. Plan release -gates new children, waits for in-flight child constructors, releases direct -executions, normal sessions and their executions, and debug sessions, and only -then closes the Unified API plan. - -Each registry owns its collection lock, each resource owns its state lock, and -the event stream owns the lock protecting subscriptions and publication. -`DebugSession` has a state mutex that protects only snapshots and transitions, -plus a dedicated operation mutex that serializes stopped-state commands, -inspection, breakpoint bookkeeping, pause requests, and command completion. -The breakpoint set is accessed only under that operation mutex and therefore -has no redundant lock. No debug-session state lock is held while invoking the -Unified API. The nested normal-run publication order is Plan registry, Plan, -Session registry, Session, then Execution registry. Connection shutdown first -closes operation admission -and waits for admitted creation to settle. Release paths never hold registry -locks while waiting for constructors, children, or Unified API cleanup. - -When the Connect stream terminates, cleanup rejects new operations and cancels -in-flight creation, waits for creation to settle, cancels and releases -executions, closes normal and debug sessions, releases plans, and terminates -owned state and goroutines. Parent and connection traversal uses registry owner, -plan, and session indexes rather than nested resource collections. +`Execution` and `DebugSession` share a private event stream that owns sequence +allocation, latest-event replay, bounded buffers, subscription accounting, lag +eviction, and channel shutdown. It has no execution/debugger semantics. +DebugSession also retains a cohesive state value and breakpoint set; the set +owns the Wire limit and successful breakpoint records. + +Creation reserves capacity before invoking the hosted API. Pending, published, +and closing resources all count toward the connection's limit. Publication +checks request cancellation, connection admission, and live ancestors under +the store mutex. Failure or abandonment closes returned hosted resources before +releasing the pending reservation. Constructors return real resource handles; +shared snapshots carry no ownership identity. + +The store mutex protects maps, reservations, parent links, and allocation/release +admission. Creation gates are incremented under that mutex before release can +start waiting. Connection cancellation shares this admission lock. Resource +state locks must never be held while acquiring the store mutex. Hosted calls, +recursive release, and cleanup waits run without it. + +Release belongs to each resource. Plan release gates new descendants and waits +for admitted constructors, releases executions, normal sessions, and debug +sessions, then closes its hosted plan. Session release cancels its lifetime, +waits for execution publication, releases its execution, then closes its hosted +session. The execution slot remains occupied until release finishes, even after +a terminal result. Execution/debugger release detaches storage only after local +cleanup settles. All removals also update direct parent links. + +Connection teardown cancels in-flight work, closes store admission, waits for +pending creation, and settles executions, sessions, debuggers, and plans. Server +shutdown rejects new connections and closes the existing connections. Neither +path closes the borrowed runtime. + +DebugSession has separate operation and state mutexes. Its operation mutex +serializes stopped-state commands, inspection, breakpoint bookkeeping, pause, +and command completion. The breakpoint set uses that mutex without adding a +redundant lock. The state mutex protects snapshots and transitions and never +spans a hosted API call. Release is committed teardown. Concurrent callers observing the same in-flight release wait for its retained result. After teardown finishes, the resource ID @@ -310,10 +291,10 @@ Every stateful resource has explicit synchronization, cancellation, ownership, and termination. Context cancellation propagates into Unified API operations. Debug inspection cannot wait through a resume and then inspect a later stop. An asynchronous resume releases the operation mutex while the runtime command -is active so `Pause` and close can reach the controller. Command completion +is active so `Pause` and close can reach the hosted debugger. Command completion reacquires the operation mutex before committing state, which keeps pause responses and event ordering deterministic. Close cancels the session and calls -the controller without waiting behind a potentially blocking stopped-state +the hosted debugger without waiting behind a potentially blocking stopped-state operation, then serializes the final state and event commit. Event buffers are bounded and producers are non-blocking. Each watch first @@ -326,11 +307,11 @@ until the stream handler exits, including after lag or a terminal snapshot. Detached cleanup has a named owner, is panic-safe, and terminates deterministically. -`Lifecycle.settleSession` follows the existing detached-release terminal policy: -its recovery settles release waiters and registry bookkeeping if Wire +Each resource release follows the existing detached-release terminal policy: +its recovery settles release waiters and store bookkeeping if Wire orchestration panics. This is distinct from `panicboundary`, which guards only -external implementation calls. Session-local close relies on the existing -external `api.Session.Close` boundary without adding another raw recovery site. +external implementation calls. Normal-session release invokes the hosted +`api.Session.Close` through that boundary and retains the cleanup result. Direct Plan execution, normal Session run, and direct Runtime run construction publish running state. Debug-session construction @@ -344,7 +325,7 @@ completion then publishes stopped or terminal state with a monotonic sequence. Every Wire server is a potential remote-code-execution boundary, including over local IPC. Requests and lifecycle identifiers are untrusted. -`DefaultServerLimits` supplies the secure baseline: +`DefaultLimits` supplies the secure baseline: | Resource | Default limit | | --- | ---: | diff --git a/docs/protocol.md b/docs/protocol.md index 338dcba..132c95a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -113,6 +113,9 @@ Execution and debugger completion preserve Unified API encoded output exactly: The handwritten Go adapters use `pkg/execution`, `pkg/debugger`, and `pkg/failure` for shared Wire semantics, without protocol resource IDs. +Host identity is configured with `server.RuntimeIdentity`; its protobuf shape +is unchanged. Each logical connection owns its server resource store; resource +IDs are resolved only within that store. The public client projects execution and debugging onto canonical Universal API interfaces and events; Wire snapshots and watch streams remain private to its implementation. The adapters copy mutable output, diagnostic, range, and diff --git a/pkg/execution/execution_test.go b/pkg/execution/execution_test.go index bfe2454..19b31b4 100644 --- a/pkg/execution/execution_test.go +++ b/pkg/execution/execution_test.go @@ -38,12 +38,8 @@ func TestExecutionSemanticTypesUseUnifiedOutputAndSharedFailure(t *testing.T) { Failure: terminalFailure, }, } - identity := execution.Identity{Name: "host", Version: "1.0.0", InstanceID: "instance"} if event.Sequence != 7 || event.Snapshot.Output != output || event.Snapshot.Failure != terminalFailure { t.Fatalf("unexpected execution event: %#v", event) } - if identity.Name != "host" || identity.Version != "1.0.0" || identity.InstanceID != "instance" { - t.Fatalf("unexpected runtime identity: %#v", identity) - } } diff --git a/pkg/execution/identity.go b/pkg/execution/identity.go deleted file mode 100644 index de8cd8b..0000000 --- a/pkg/execution/identity.go +++ /dev/null @@ -1,8 +0,0 @@ -package execution - -// Identity describes optional host-supplied identity for a hosted runtime. -type Identity struct { - Name string - Version string - InstanceID string -} diff --git a/server/integration_test.go b/server/integration_test.go index b4409d4..c1e5c89 100644 --- a/server/integration_test.go +++ b/server/integration_test.go @@ -14,7 +14,6 @@ import ( "github.com/MontFerret/api/source" "github.com/MontFerret/wire/client" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - "github.com/MontFerret/wire/pkg/execution" "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/server" "google.golang.org/grpc" @@ -47,7 +46,7 @@ func TestUnifiedRuntimeCompileExecuteAndBorrowedOwnership(t *testing.T) { runtime := &apiRuntimeSpy{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return plan, nil }} - env := newIntegrationEnv(t, runtime, server.WithRuntimeIdentity(execution.Identity{ + env := newIntegrationEnv(t, runtime, server.WithRuntimeIdentity(server.RuntimeIdentity{ Name: "test-host", Version: "1.2.3", InstanceID: "instance-1", })) diff --git a/server/internal/core/api.go b/server/internal/core/api.go index 0ccf726..9c1ed80 100644 --- a/server/internal/core/api.go +++ b/server/internal/core/api.go @@ -6,18 +6,10 @@ import ( "reflect" "github.com/MontFerret/api" + "github.com/MontFerret/api/debugger" "github.com/MontFerret/wire/server/internal/panicboundary" ) -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)) - } - - return options -} - func apiPlanParameters(plan api.Plan) ([]string, error) { parameters, err := panicboundary.Call(func() ([]string, error) { return plan.Params(), nil @@ -37,6 +29,10 @@ func closeAPISession(session api.Session) error { return runtimePanicError("close runtime session", panicboundary.Do(session.Close)) } +func closeAPIDebugSession(session debugger.Session) error { + return runtimePanicError("close runtime debug session", panicboundary.Do(session.Close)) +} + func runtimePanicError(operation string, err error) error { var panicErr *panicboundary.Error if !errors.As(err, &panicErr) { diff --git a/server/internal/core/architecture_test.go b/server/internal/core/architecture_test.go deleted file mode 100644 index cc76369..0000000 --- a/server/internal/core/architecture_test.go +++ /dev/null @@ -1,517 +0,0 @@ -package core - -import ( - "context" - "errors" - "go/ast" - "go/parser" - "go/token" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "github.com/MontFerret/api" - "github.com/MontFerret/api/debugger" - wiredebugger "github.com/MontFerret/wire/pkg/debugger" - "github.com/MontFerret/wire/pkg/execution" - "github.com/MontFerret/wire/server/internal/panicboundary" - "github.com/google/uuid" -) - -func TestConnectionContainsOnlyLifetimeState(t *testing.T) { - typeOfConnection := reflect.TypeFor[Connection]() - forbiddenFields := map[string]struct{}{ - "plans": {}, - "executions": {}, - "debugSessions": {}, - "runtime": {}, - } - for index := range typeOfConnection.NumField() { - field := typeOfConnection.Field(index) - if _, forbidden := forbiddenFields[field.Name]; forbidden { - t.Fatalf("Connection retained forbidden field %q", field.Name) - } - } - - methods := reflect.TypeFor[*Connection]() - for _, name := range []string{ - "Compile", - "Execute", - "CreateSession", - "RunSession", - "OpenDebugSession", - "ReleasePlan", - "ReleaseSession", - "ReleaseExecution", - "ReleaseDebugSession", - } { - if _, exists := methods.MethodByName(name); exists { - t.Fatalf("Connection retained forbidden method %q", name) - } - } -} - -func TestWireOwnedCommitPanicPropagatesWithoutBoundary(t *testing.T) { - registry := NewPlanRegistry(1) - plan := &Plan{id: "plan", owner: "owner"} - if err := registry.reserve(plan.owner); err != nil { - t.Fatal(err) - } - - if err := registry.commit(plan); err != nil { - t.Fatal(err) - } - - sentinel := errors.New("Wire defect") - recovered := func() (value any) { - defer func() { value = recover() }() - - _ = registry.commitChild(plan.owner, plan.id, plan, func() error { - panic(sentinel) - }) - - return nil - }() - if recovered != sentinel { - t.Fatalf("Wire-owned panic was changed: %#v", recovered) - } - - if _, contained := recovered.(*panicboundary.Error); contained { - t.Fatalf("Wire-owned panic was contained by panicboundary: %#v", recovered) - } -} - -func TestOperationAggregatesDelegateSupportingInfrastructure(t *testing.T) { - t.Run("execution", func(t *testing.T) { - typeOfExecution := reflect.TypeFor[Execution]() - for _, name := range []string{ - "session", - "maxWatchers", - "sequence", - "lastEvent", - "nextWatcher", - "subscriptions", - "watchers", - } { - if _, exists := typeOfExecution.FieldByName(name); exists { - t.Fatalf("Execution retained delegated field %q", name) - } - } - - events, exists := typeOfExecution.FieldByName("events") - if !exists || events.Type != reflect.TypeFor[*eventStream[execution.Event]]() { - t.Fatalf("Execution does not own the shared event stream: %v", events.Type) - } - }) - - t.Run("debug session", func(t *testing.T) { - typeOfSession := reflect.TypeFor[DebugSession]() - for _, name := range []string{ - "debugger", - "reason", - "location", - "hitIDs", - "depth", - "output", - "failure", - "maxWatchers", - "maxBreakpoints", - "sequence", - "lastEvent", - "nextWatcher", - "subscriptions", - "watchers", - } { - if _, exists := typeOfSession.FieldByName(name); exists { - t.Fatalf("DebugSession retained delegated field %q", name) - } - } - - state, exists := typeOfSession.FieldByName("state") - if !exists || state.Type != reflect.TypeFor[debugSessionState]() { - t.Fatalf("DebugSession does not own cohesive debug state: %v", state.Type) - } - - breakpoints, exists := typeOfSession.FieldByName("breakpoints") - if !exists || breakpoints.Type != reflect.TypeFor[*breakpointSet]() { - t.Fatalf("DebugSession does not own the breakpoint component: %v", breakpoints.Type) - } - - events, exists := typeOfSession.FieldByName("events") - if !exists || events.Type != reflect.TypeFor[*eventStream[wiredebugger.Event]]() { - t.Fatalf("DebugSession does not own the shared event stream: %v", events.Type) - } - - controller, exists := typeOfSession.FieldByName("controller") - if !exists || controller.Type != reflect.TypeFor[*DebugController]() { - t.Fatalf("DebugSession does not own the debug controller: %v", controller.Type) - } - - typeOfBreakpoints := reflect.TypeFor[breakpointSet]() - for _, name := range []string{"mu", "session"} { - if _, exists := typeOfBreakpoints.FieldByName(name); exists { - t.Fatalf("breakpointSet retained delegated field %q", name) - } - } - }) -} - -func TestPrincipalReceiversAndDebuggerHandleStayWithTheirOwners(t *testing.T) { - packages, err := parser.ParseDir(token.NewFileSet(), ".", nil, 0) - if err != nil { - t.Fatal(err) - } - - parsed := packages["core"] - for filename, file := range parsed.Files { - base := filepath.Base(filename) - if strings.HasSuffix(base, "_test.go") { - continue - } - - for _, declaration := range file.Decls { - function, ok := declaration.(*ast.FuncDecl) - if ok && function.Recv != nil && len(function.Recv.List) == 1 { - receiver := receiverTypeName(function.Recv.List[0].Type) - switch receiver { - case "Session": - if base != "session.go" { - t.Errorf("Session method %s is in %s", function.Name.Name, base) - } - case "DebugSession": - if base != "debug_session.go" { - t.Errorf("DebugSession method %s is in %s", function.Name.Name, base) - } - case "DebugController": - if base != "debug_controller.go" { - t.Errorf("DebugController method %s is in %s", function.Name.Name, base) - } - case "Execution": - if base != "execution.go" { - t.Errorf("Execution method %s is in %s", function.Name.Name, base) - } - case "breakpointSet": - if base != "breakpoint_set.go" { - t.Errorf("breakpointSet method %s is in %s", function.Name.Name, base) - } - } - } - - generic, ok := declaration.(*ast.GenDecl) - if !ok { - continue - } - - for _, specification := range generic.Specs { - typeSpec, ok := specification.(*ast.TypeSpec) - if !ok { - continue - } - - structure, ok := typeSpec.Type.(*ast.StructType) - if !ok { - continue - } - - for _, field := range structure.Fields.List { - if debuggerSessionType(field.Type) && typeSpec.Name.Name != "DebugController" { - t.Errorf("%s stores debugger.Session in %s", typeSpec.Name.Name, base) - } - } - } - } - } -} - -func receiverTypeName(expression ast.Expr) string { - if pointer, ok := expression.(*ast.StarExpr); ok { - expression = pointer.X - } - - identifier, _ := expression.(*ast.Ident) - if identifier == nil { - return "" - } - - return identifier.Name -} - -func debuggerSessionType(expression ast.Expr) bool { - selector, ok := expression.(*ast.SelectorExpr) - if !ok || selector.Sel.Name != "Session" { - return false - } - - packageName, _ := selector.X.(*ast.Ident) - - return packageName != nil && packageName.Name == "debugger" -} - -func TestContextCombinesRequestAndConnectionCancellation(t *testing.T) { - connection := NewConnection() - request, cancelRequest := context.WithCancel(context.Background()) - operation, cancelOperation := NewContext(request, connection) - t.Cleanup(cancelOperation) - - if operation.Connection() != connection { - t.Fatal("operation context did not retain its logical connection") - } - - cancelRequest() - select { - case <-operation.Done(): - case <-time.After(5 * time.Second): - t.Fatal("request cancellation did not cancel the operation context") - } - - connection = NewConnection() - operation, cancelOperation = NewContext(context.Background(), connection) - t.Cleanup(cancelOperation) - if !connection.beginClose() { - t.Fatal("connection close did not begin") - } - - select { - case <-operation.Done(): - case <-time.After(5 * time.Second): - t.Fatal("connection cancellation did not cancel the operation context") - } - connection.finishClose(nil) -} - -func TestGlobalRegistriesEnforceOwnershipAndParentIndexes(t *testing.T) { - plan := &spyPlan{ - newSession: func(context.Context, sessionOptions) (api.Session, error) { - return &spySession{}, nil - }, - newDebugSession: func(context.Context, sessionOptions) (debugger.Session, error) { - return &spyDebugger{}, 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) - } - owner, err := host.OpenConnection() - if err != nil { - t.Fatal(err) - } - other, err := host.OpenConnection() - if err != nil { - t.Fatal(err) - } - - compiled, err := owner.Compile(context.Background(), CompileInput{ - Source: api.Source{Content: "RETURN 1"}, - Debuggable: true, - }) - if err != nil { - t.Fatal(err) - } - execution, err := owner.Execute(context.Background(), ExecuteInput{PlanID: compiled.ID}) - if err != nil { - t.Fatal(err) - } - opened, err := owner.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}) - if err != nil { - t.Fatal(err) - } - - if _, err := host.plans.get(other.ID(), compiled.ID); !hasCategory(err, ErrorKindPlanNotFound) { - t.Fatalf("cross-owner plan lookup was not hidden: %v", err) - } - if _, err := host.executions.get(other.ID(), execution.ID); !hasCategory(err, ErrorKindExecutionNotFound) { - t.Fatalf("cross-owner execution lookup was not hidden: %v", err) - } - if _, err := host.sessions.get(other.ID(), opened.ID); !hasCategory(err, ErrorKindDebugSessionNotFound) { - t.Fatalf("cross-owner debug lookup was not hidden: %v", err) - } - - if ids := host.plans.listByOwner(owner.ID()); !reflect.DeepEqual(ids, []PlanID{compiled.ID}) { - t.Fatalf("unexpected owner plan index: %#v", ids) - } - if ids := host.executions.listByPlan(owner.ID(), compiled.ID); !reflect.DeepEqual(ids, []ExecutionID{execution.ID}) { - t.Fatalf("unexpected plan execution index: %#v", ids) - } - if ids := host.sessions.listByPlan(owner.ID(), compiled.ID); !reflect.DeepEqual(ids, []DebugSessionID{opened.ID}) { - t.Fatalf("unexpected plan debug index: %#v", ids) - } - - if err := owner.ReleasePlan(testContext(t), compiled.ID); err != nil { - t.Fatal(err) - } - if ids := host.plans.listByOwner(owner.ID()); len(ids) != 0 { - t.Fatalf("released plan remained indexed: %#v", ids) - } - if ids := host.executions.listByPlan(owner.ID(), compiled.ID); len(ids) != 0 { - t.Fatalf("released executions remained indexed: %#v", ids) - } - if ids := host.sessions.listByPlan(owner.ID(), compiled.ID); len(ids) != 0 { - t.Fatalf("released debug sessions remained indexed: %#v", ids) - } -} - -func TestGlobalRegistriesRetainCapacityThroughClosing(t *testing.T) { - owner := NewConnection().ID() - other := NewConnection().ID() - planID := PlanID(uuid.NewString()) - - t.Run("connections", func(t *testing.T) { - registry := NewConnectionRegistry(1) - connection := NewConnection() - if err := registry.Register(connection); err != nil { - t.Fatal(err) - } - - closing, started, err := registry.beginClose(connection.ID()) - if err != nil || !started || closing != connection { - t.Fatalf("unexpected close transition: connection=%p started=%v err=%v", closing, started, err) - } - if err := registry.Register(NewConnection()); !hasCategory(err, ErrorKindResourceExhausted) { - t.Fatalf("closing connection did not retain capacity: %v", err) - } - - registry.remove(connection.ID(), connection) - connection.finishClose(nil) - if err := registry.Register(NewConnection()); err != nil { - t.Fatalf("settled close did not release capacity: %v", err) - } - }) - - t.Run("plans", func(t *testing.T) { - registry := NewPlanRegistry(1) - if err := registry.reserve(owner); err != nil { - t.Fatal(err) - } - if err := registry.reserve(owner); !hasCategory(err, ErrorKindResourceExhausted) { - t.Fatalf("pending plan did not retain capacity: %v", err) - } - registry.rollback(owner) - - if err := registry.reserve(owner); err != nil { - t.Fatal(err) - } - plan := &Plan{id: planID, owner: owner} - if err := registry.commit(plan); err != nil { - t.Fatal(err) - } - if _, err := registry.get(other, planID); !hasCategory(err, ErrorKindPlanNotFound) { - t.Fatalf("cross-owner plan lookup was not hidden: %v", err) - } - - closing, started, err := registry.beginClose(owner, planID) - if err != nil || !started || closing != plan { - t.Fatalf("unexpected close transition: plan=%p started=%v err=%v", closing, started, err) - } - if err := registry.reserve(owner); !hasCategory(err, ErrorKindResourceExhausted) { - t.Fatalf("closing plan did not retain capacity: %v", err) - } - - registry.remove(plan) - plan.finishClose(nil) - if err := registry.reserve(owner); err != nil { - t.Fatalf("settled plan close did not release capacity: %v", err) - } - registry.rollback(owner) - }) - - t.Run("executions", func(t *testing.T) { - registry := NewExecutionRegistry(1, 1) - if err := registry.reserve(owner); err != nil { - t.Fatal(err) - } - if err := registry.reserve(owner); !hasCategory(err, ErrorKindResourceExhausted) { - t.Fatalf("pending execution did not retain capacity: %v", err) - } - registry.rollback(owner) - - if err := registry.reserve(owner); err != nil { - t.Fatal(err) - } - execution := &Execution{id: ExecutionID(uuid.NewString()), owner: owner, planID: planID} - if err := registry.commit(execution); err != nil { - t.Fatal(err) - } - if ids := registry.listByPlan(owner, planID); !reflect.DeepEqual(ids, []ExecutionID{execution.id}) { - t.Fatalf("unexpected plan execution index: %#v", ids) - } - if _, err := registry.get(other, execution.id); !hasCategory(err, ErrorKindExecutionNotFound) { - t.Fatalf("cross-owner execution lookup was not hidden: %v", err) - } - - closing, started, err := registry.beginClose(owner, execution.id) - if err != nil || !started || closing != execution { - t.Fatalf("unexpected close transition: execution=%p started=%v err=%v", closing, started, err) - } - if err := registry.reserve(owner); !hasCategory(err, ErrorKindResourceExhausted) { - t.Fatalf("closing execution did not retain capacity: %v", err) - } - - registry.remove(execution) - execution.release.Finish(nil) - if err := registry.reserve(owner); err != nil { - t.Fatalf("settled execution close did not release capacity: %v", err) - } - registry.rollback(owner) - }) - - t.Run("debug sessions", func(t *testing.T) { - registry := NewDebugSessionRegistry(1, 1, 1) - if err := registry.reserve(owner); err != nil { - t.Fatal(err) - } - if err := registry.reserve(owner); !hasCategory(err, ErrorKindResourceExhausted) { - t.Fatalf("pending debug session did not retain capacity: %v", err) - } - registry.rollback(owner) - - if err := registry.reserve(owner); err != nil { - t.Fatal(err) - } - session := &DebugSession{id: DebugSessionID(uuid.NewString()), owner: owner, planID: planID} - if err := registry.commit(session); err != nil { - t.Fatal(err) - } - if ids := registry.listByPlan(owner, planID); !reflect.DeepEqual(ids, []DebugSessionID{session.id}) { - t.Fatalf("unexpected plan debug index: %#v", ids) - } - if _, err := registry.get(other, session.id); !hasCategory(err, ErrorKindDebugSessionNotFound) { - t.Fatalf("cross-owner debug lookup was not hidden: %v", err) - } - - closing, started, err := registry.beginClose(owner, session.id) - if err != nil || !started || closing != session { - t.Fatalf("unexpected close transition: session=%p started=%v err=%v", closing, started, err) - } - if err := registry.reserve(owner); !hasCategory(err, ErrorKindResourceExhausted) { - t.Fatalf("closing debug session did not retain capacity: %v", err) - } - - registry.remove(session) - session.release.Finish(nil) - if err := registry.reserve(owner); err != nil { - t.Fatalf("settled debug close did not release capacity: %v", err) - } - registry.rollback(owner) - }) -} - -func TestConnectionRegistryRejectsRegistrationAfterShutdownBegins(t *testing.T) { - registry := NewConnectionRegistry(2) - connection := NewConnection() - if err := registry.Register(connection); err != nil { - t.Fatal(err) - } - - ids := registry.beginShutdown() - if !reflect.DeepEqual(ids, []ConnectionID{connection.ID()}) { - t.Fatalf("unexpected shutdown snapshot: %#v", ids) - } - if err := registry.Register(NewConnection()); !hasCategory(err, ErrorKindInvalidState) { - t.Fatalf("registration succeeded during shutdown: %v", err) - } -} diff --git a/server/internal/core/breakpoint_set_test.go b/server/internal/core/breakpoint_set_test.go index 8169730..8d2f1fd 100644 --- a/server/internal/core/breakpoint_set_test.go +++ b/server/internal/core/breakpoint_set_test.go @@ -3,11 +3,11 @@ package core import ( "context" "errors" - wiredebugger "github.com/MontFerret/wire/pkg/debugger" "testing" "github.com/MontFerret/api/debugger" "github.com/MontFerret/api/source" + wiredebugger "github.com/MontFerret/wire/pkg/debugger" ) func TestBreakpointSetOwnsOnlyBookkeepingAndCapacity(t *testing.T) { @@ -105,15 +105,9 @@ func newTestCoreDebugSession(t *testing.T, runtime debugger.Session, maxBreakpoi debugCtx, cancel := context.WithCancelCause(context.Background()) t.Cleanup(func() { cancel(context.Canceled) }) + limits := testLimits().resources() + limits.Watchers, limits.Breakpoints = 1, maxBreakpoints + plan := &Plan{store: newResourceStore(debugCtx, limits)} - return newDebugSession( - "session", - "owner", - "plan", - newDebugController(runtime), - debugCtx, - cancel, - 1, - maxBreakpoints, - ) + return newDebugSession(plan, runtime) } diff --git a/server/internal/core/compile.go b/server/internal/core/compile.go new file mode 100644 index 0000000..6228f74 --- /dev/null +++ b/server/internal/core/compile.go @@ -0,0 +1,86 @@ +package core + +import ( + "context" + "errors" + "fmt" + + "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. +func CompilePlan(ctx context.Context, runtime api.Runtime, store *ResourceStore, source api.Source, debug bool, options ...api.PlanOption) (*Plan, error) { + if err := store.operationError(ctx); err != nil { + return nil, err + } + + if source.Content == "" { + return nil, invalidRequest("source content is required") + } + + if source.Name == "" { + source.Name = "anonymous" + } + + if err := store.beginCreation(planResource, nil); err != nil { + return nil, err + } + + committed := false + defer func() { store.finishCreation(planResource, nil, committed) }() + + compile := runtime.Compile + if debug { + compile = runtime.CompileDebug + } + + compiled, err := panicboundary.Call(func() (api.Plan, error) { + return compile(ctx, source, options...) + }) + if err != nil { + var panicErr *panicboundary.Error + if errors.As(err, &panicErr) { + return nil, internalError(fmt.Errorf("compile runtime plan: %w", err)) + } + + compileErr := compilationError("compilation failed", err) + if !isNil(compiled) { + return nil, errors.Join(compileErr, closeAPIPlan(compiled)) + } + + return nil, compileErr + } + + if isNil(compiled) { + return nil, internalError(errors.New("runtime returned no plan")) + } + + if err := ctx.Err(); err != nil { + return nil, errors.Join(err, closeAPIPlan(compiled)) + } + + parameters, err := apiPlanParameters(compiled) + if err != nil { + return nil, errors.Join(err, closeAPIPlan(compiled)) + } + + created := &Plan{ + id: PlanID(uuid.NewString()), + store: store, + plan: compiled, + parameters: parameters, + debuggable: debug, + sessions: make(map[SessionID]*Session), + executions: make(map[ExecutionID]*Execution), + debugSessions: make(map[DebugSessionID]*DebugSession), + } + if err := store.registerPlan(ctx, created); err != nil { + return nil, errors.Join(err, closeAPIPlan(compiled)) + } + + committed = true + + return created, nil +} diff --git a/server/internal/core/compile_input.go b/server/internal/core/compile_input.go deleted file mode 100644 index 5d2f76b..0000000 --- a/server/internal/core/compile_input.go +++ /dev/null @@ -1,12 +0,0 @@ -package core - -import ( - "github.com/MontFerret/api" -) - -type CompileInput struct { - Source api.Source - Debuggable bool - OptimizationLevel api.OptimizationLevel - HasOptimizationLevel bool -} diff --git a/server/internal/core/compiler.go b/server/internal/core/compiler.go deleted file mode 100644 index 62f5a69..0000000 --- a/server/internal/core/compiler.go +++ /dev/null @@ -1,122 +0,0 @@ -package core - -import ( - "errors" - "fmt" - - "github.com/MontFerret/api" - "github.com/MontFerret/wire/server/internal/panicboundary" - "github.com/google/uuid" -) - -// Compiler owns the Unified API compilation use case. -type Compiler struct { - runtime api.Runtime - plans *PlanRegistry -} - -func NewCompiler(runtime api.Runtime, plans *PlanRegistry) (*Compiler, error) { - if isNil(runtime) { - return nil, invalidRequest("runtime is required") - } - - return &Compiler{runtime: runtime, plans: plans}, nil -} - -func (c *Compiler) Compile(ctx *Context, input CompileInput) (PlanSnapshot, error) { - connection := ctx.Connection() - - if err := connection.beginOperation(); err != nil { - return PlanSnapshot{}, err - } - defer connection.finishOperation() - - if err := ctx.Err(); err != nil { - return PlanSnapshot{}, err - } - - if input.Source.Content == "" { - return PlanSnapshot{}, invalidRequest("source content is required") - } - - if input.Source.Name == "" { - input.Source.Name = "anonymous" - } - - owner := connection.ID() - if err := c.plans.reserve(owner); err != nil { - return PlanSnapshot{}, err - } - - reserved := true - defer func() { - if reserved { - c.plans.rollback(owner) - } - }() - - compiled, err := c.compileAPIPlan(ctx, input) - if err != nil { - var panicErr *panicboundary.Error - if errors.As(err, &panicErr) { - return PlanSnapshot{}, internalError(fmt.Errorf("compile runtime plan: %w", err)) - } - - compileErr := compilationError("compilation failed", err) - if !isNil(compiled) { - return PlanSnapshot{}, errors.Join(compileErr, closeAPIPlan(compiled)) - } - - return PlanSnapshot{}, compileErr - } - - if isNil(compiled) { - return PlanSnapshot{}, internalError(errors.New("runtime returned no plan")) - } - - if err := ctx.Err(); err != nil { - return PlanSnapshot{}, errors.Join(err, closeAPIPlan(compiled)) - } - - parameters, err := apiPlanParameters(compiled) - if err != nil { - return PlanSnapshot{}, errors.Join(err, closeAPIPlan(compiled)) - } - - created := &Plan{ - id: PlanID(uuid.NewString()), - owner: owner, - plan: compiled, - parameters: parameters, - debuggable: input.Debuggable, - } - - if err := ctx.Err(); err != nil { - return PlanSnapshot{}, errors.Join(err, closeAPIPlan(compiled)) - } - - err = c.plans.commit(created) - reserved = false - if err != nil { - return PlanSnapshot{}, errors.Join(err, closeAPIPlan(compiled)) - } - - return created.snapshot(), nil -} - -func (c *Compiler) compileAPIPlan(ctx *Context, input CompileInput) (api.Plan, error) { - var options []api.PlanOption - if input.HasOptimizationLevel { - options = append(options, api.WithOptimizationLevel(input.OptimizationLevel)) - } - - if input.Debuggable { - return panicboundary.Call(func() (api.Plan, error) { - return c.runtime.CompileDebug(ctx, input.Source, options...) - }) - } - - return panicboundary.Call(func() (api.Plan, error) { - return c.runtime.Compile(ctx, input.Source, options...) - }) -} diff --git a/server/internal/core/component_boundary_test.go b/server/internal/core/component_boundary_test.go index 6d9a96c..6b3a766 100644 --- a/server/internal/core/component_boundary_test.go +++ b/server/internal/core/component_boundary_test.go @@ -16,39 +16,6 @@ import ( "github.com/MontFerret/wire/server/internal/panicboundary" ) -func TestHostRejectsNilRuntimeAndDoesNotCloseBorrowedRuntime(t *testing.T) { - var typedNil *spyRuntime - for name, runtime := range map[string]api.Runtime{ - "nil interface": nil, - "typed nil": typedNil, - } { - t.Run(name, func(t *testing.T) { - if _, err := newTestHost(runtime, testLimits()); !hasCategory(err, ErrorKindInvalidRequest) { - t.Fatalf("unexpected nil runtime result: %v", err) - } - }) - } - - runtime := &spyRuntime{} - host, err := newTestHost(runtime, testLimits()) - if err != nil { - t.Fatal(err) - } - - if _, err := host.OpenConnection(); err != nil { - t.Fatal(err) - } - - if err := host.Close(testContext(t)); err != nil { - t.Fatal(err) - } - - _, _, closeCalls := runtime.snapshot() - if closeCalls != 0 { - t.Fatalf("host closed borrowed runtime %d times", closeCalls) - } -} - func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { outputBytes := []byte(`{"ok":true}`) var sessionsMu sync.Mutex @@ -78,7 +45,7 @@ func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { t.Fatal(err) } - compiled, err := connection.Compile(context.Background(), CompileInput{ + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{ Name: "reusable.fql", Content: "RETURN @input", @@ -92,18 +59,18 @@ func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { t.Fatalf("unexpected parameters: %#v", compiled.Parameters) } - retained, err := connection.plans.lookup(compiled.ID) + retained, err := connection.resources.Plan(context.Background(), compiled.ID) 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.snapshot().Parameters; !reflect.DeepEqual(got, []string{"input"}) { + if got := retained.Params(); !reflect.DeepEqual(got, []string{"input"}) { t.Fatalf("Wire plan retained runtime parameter storage: %#v", got) } - inputs := []ExecuteInput{ + inputs := []executeRequest{ {PlanID: compiled.ID, Parameters: map[string]any{"input": []any{int64(1), []byte{2}}}}, {PlanID: compiled.ID, Parameters: map[string]any{"input": "second"}, OutputContentType: "application/x-wire"}, } @@ -119,9 +86,9 @@ func TestCompileExecuteRetainsReusableAPIPlanAndSessionOptions(t *testing.T) { } outputBytes[0] = '!' - executionIDs := connection.host.executions.listByPlan(connection.ID(), retained.id) - for _, id := range executionIDs { - execution, lookupErr := connection.executions.lookup(id) + executionIDs := retained.executions + for id := range executionIDs { + execution, lookupErr := connection.resources.Execution(context.Background(), id) if lookupErr != nil { continue } @@ -178,11 +145,11 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - if _, err := connection.Compile(ctx, CompileInput{Source: api.Source{Name: "debug.fql", Content: "RETURN 1"}, Debuggable: true}); !errors.Is(err, context.Canceled) { + 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) } - plan, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Name: "debug.fql", Content: "RETURN 1"}, Debuggable: true}) + plan, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Name: "debug.fql", Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) } @@ -197,7 +164,7 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { runtime.compile = func(context.Context, api.Source, bool) (api.Plan, error) { return &spyPlan{}, nil } - anonymous, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 2"}}) + anonymous, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}) if err != nil { t.Fatal(err) } @@ -222,7 +189,7 @@ func TestCompileDelegatesDebugSelectionAndClosesAbandonedPlan(t *testing.T) { compileCtx, cancelCompile := context.WithCancel(context.Background()) compileResult := make(chan error, 1) go func() { - _, compileErr := connection.Compile(compileCtx, CompileInput{Source: api.Source{Content: "RETURN 2"}}) + _, compileErr := connection.Compile(compileCtx, compileRequest{Source: api.Source{Content: "RETURN 2"}}) compileResult <- compileErr }() <-started @@ -258,7 +225,7 @@ func TestCompileForwardsOptionalOptimizationLevel(t *testing.T) { {optimizationLevel: api.OptimizationAggressive, hasOptimizationLevel: true}, } for index, level := range levels { - compiled, compileErr := connection.Compile(context.Background(), CompileInput{ + compiled, compileErr := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, OptimizationLevel: level.optimizationLevel, HasOptimizationLevel: level.hasOptimizationLevel, @@ -289,7 +256,7 @@ func TestCompilePanicsAreSanitizedAndCloseReturnedPlansOnce(t *testing.T) { panic("compile secret") }}) - _, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + _, 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) @@ -302,7 +269,7 @@ func TestCompilePanicsAreSanitizedAndCloseReturnedPlansOnce(t *testing.T) { return plan, nil }}) - _, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + _, 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) @@ -323,7 +290,7 @@ func TestCompilePanicsAreSanitizedAndCloseReturnedPlansOnce(t *testing.T) { return plan, nil }}) - if _, err := connection.Compile(ctx, CompileInput{Source: api.Source{Content: "RETURN 1"}}); !errors.Is(err, context.Canceled) || strings.Contains(err.Error(), "secret") { + if _, err := connection.Compile(ctx, compileRequest{Source: api.Source{Content: "RETURN 1"}}); !errors.Is(err, context.Canceled) || strings.Contains(err.Error(), "secret") { t.Fatalf("abandoned cleanup panic was not contained: %v", err) } @@ -342,12 +309,12 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -365,7 +332,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(), CompileInput{ + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, }) @@ -373,7 +340,7 @@ func TestSessionConstructionPanicsAreSanitized(t *testing.T) { t.Fatal(err) } - _, err = connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}) + _, 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) @@ -393,11 +360,11 @@ func TestBoundaryPanicsDoNotPoisonReusableParents(t *testing.T) { return &spyPlan{}, nil }} connection := newTestConnection(t, runtime) - if _, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}); !hasCategory(err, ErrorKindInternal) { + 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) } - compiled, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 2"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}) if err != nil { t.Fatalf("runtime was poisoned after compile panic: %v", err) } @@ -420,12 +387,12 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } - first, err := connection.Execute(context.Background(), ExecuteInput{PlanID: compiled.ID}) + first, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -434,7 +401,7 @@ func TestBoundaryPanicsDoNotPoisonReusableParents(t *testing.T) { t.Fatalf("constructor panic did not fail execution: %#v", settled) } - second, err := connection.Execute(context.Background(), ExecuteInput{PlanID: compiled.ID}) + second, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatalf("plan was poisoned after session-constructor panic: %v", err) } @@ -467,7 +434,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(), CompileInput{ + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, }) @@ -475,11 +442,11 @@ func TestBoundaryPanicsDoNotPoisonReusableParents(t *testing.T) { t.Fatal(err) } - if _, err := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInternal) { + if _, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInternal) { t.Fatalf("first debug constructor did not contain panic: %v", err) } - opened, err := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}) + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatalf("plan was poisoned after debug-constructor panic: %v", err) } @@ -499,7 +466,7 @@ func TestSuccessfulNilAPIResourcesAreRejectedSafely(t *testing.T) { connection := newTestConnection(t, &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { return nilPlan, nil }}) - if _, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}); !hasCategory(err, ErrorKindInternal) { + if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}); !hasCategory(err, ErrorKindInternal) { t.Fatalf("typed-nil plan was not rejected: %v", err) } @@ -510,11 +477,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -533,7 +500,7 @@ func TestAPIResourcesReturnedWithErrorsAreClosedOnce(t *testing.T) { return plan, runtimeErr }}) - if _, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}); !hasCategory(err, ErrorKindCompilation) || strings.Contains(err.Error(), "secret") { + if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}); !hasCategory(err, ErrorKindCompilation) || strings.Contains(err.Error(), "secret") { t.Fatalf("unexpected plan-plus-error result: %v", err) } @@ -551,11 +518,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -580,12 +547,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(), CompileInput{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) } - if _, err := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInternal) || strings.Contains(err.Error(), "secret") { + if _, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInternal) || strings.Contains(err.Error(), "secret") { t.Fatalf("unexpected debug-session-plus-error result: %v", err) } @@ -606,12 +573,12 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) } - if _, err := connection.OpenDebugSession(ctx, OpenDebugInput{PlanID: compiled.ID}); !errors.Is(err, context.Canceled) || strings.Contains(err.Error(), "secret") { + if _, err := connection.OpenDebugSession(ctx, debugRequest{PlanID: compiled.ID}); !errors.Is(err, context.Canceled) || strings.Contains(err.Error(), "secret") { t.Fatalf("abandoned debug cleanup panic was not contained: %v", err) } @@ -654,11 +621,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -710,9 +677,9 @@ func newTestConnection(t *testing.T, runtime api.Runtime) *testEnvironment { return connection } -func waitExecution(t *testing.T, connection *testEnvironment, id ExecutionID) ExecutionRecord { +func waitExecution(t *testing.T, connection *testEnvironment, id ExecutionID) executionResult { t.Helper() - execution, err := connection.executions.lookup(id) + execution, err := connection.resources.Execution(context.Background(), id) if err != nil { t.Fatal(err) } @@ -722,7 +689,7 @@ func waitExecution(t *testing.T, connection *testEnvironment, id ExecutionID) Ex t.Fatal("execution did not finish") } - return execution.Snapshot() + return executionResult{ID: execution.ID(), Snapshot: execution.Snapshot()} } func testContext(t *testing.T) context.Context { @@ -750,3 +717,23 @@ func hasCategory(err error, category ErrorKind) bool { return errors.As(err, &domain) && domain.Kind == category } + +func TestWireOwnedExecutionOperationPanicPropagates(t *testing.T) { + sentinel := errors.New("Wire defect") + 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 { + t.Fatalf("Wire-owned panic was changed: %#v", recovered) + } + + if _, contained := recovered.(*panicboundary.Error); contained { + t.Fatalf("Wire-owned panic was contained by panicboundary: %#v", recovered) + } +} diff --git a/server/internal/core/connection.go b/server/internal/core/connection.go index 5803cbe..0f1a016 100644 --- a/server/internal/core/connection.go +++ b/server/internal/core/connection.go @@ -2,43 +2,30 @@ package core import ( "context" - "sync" + "errors" "github.com/MontFerret/wire/server/internal/lifecycle" "github.com/google/uuid" ) -type ( - connectionState uint8 - - // Connection is the logical identity and lifetime established by - // RuntimeService.Connect. Resource ownership is represented in the global - // registries rather than by child collections on Connection. - Connection struct { - mu sync.RWMutex - id ConnectionID - ctx context.Context - cancel context.CancelCauseFunc - state connectionState - operations sync.WaitGroup - close lifecycle.Close - } -) - -const ( - connectionOpen connectionState = iota + 1 - connectionClosing - connectionClosed -) +// Connection is the logical identity and lifetime established by the Connect +// stream. Its store owns allocation and reclamation within this lifetime. +type Connection struct { + id ConnectionID + ctx context.Context + cancel context.CancelCauseFunc + resources *ResourceStore + close lifecycle.Close +} -func NewConnection() *Connection { +func newConnection(limits ResourceLimits) *Connection { ctx, cancel := context.WithCancelCause(context.Background()) return &Connection{ - id: ConnectionID(uuid.NewString()), - ctx: ctx, - cancel: cancel, - state: connectionOpen, + id: ConnectionID(uuid.NewString()), + ctx: ctx, + cancel: cancel, + resources: newResourceStore(ctx, limits), } } @@ -50,48 +37,38 @@ func (c *Connection) Context() context.Context { return c.ctx } -func (c *Connection) beginOperation() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.state != connectionOpen { - return invalidState("connection is closed", context.Canceled) - } +func (c *Connection) Resources() *ResourceStore { + return c.resources +} - c.operations.Add(1) +func (c *Connection) settleClose() (err error) { + defer func() { + if recover() != nil { + err = errors.Join(err, internalError(errors.New("logical connection cleanup panicked"))) + } + }() - return nil -} + err = c.resources.Close(context.Background()) -func (c *Connection) finishOperation() { - c.operations.Done() + return err } // beginClose linearizes connection cancellation against operation admission. // The caller that receives true owns cross-resource teardown. func (c *Connection) beginClose() bool { - c.mu.Lock() - defer c.mu.Unlock() + c.resources.mu.Lock() + defer c.resources.mu.Unlock() if !c.close.Begin() { return false } - c.state = connectionClosing c.cancel(context.Canceled) return true } -func (c *Connection) waitOperations() { - c.operations.Wait() -} - func (c *Connection) finishClose(err error) { - c.mu.Lock() - c.state = connectionClosed - c.mu.Unlock() - c.close.Finish(err) } diff --git a/server/internal/core/connection_registry.go b/server/internal/core/connection_registry.go index 3ab0af2..a4dbe37 100644 --- a/server/internal/core/connection_registry.go +++ b/server/internal/core/connection_registry.go @@ -1,47 +1,46 @@ package core -import "sync" +import ( + "context" + "errors" + "sync" +) // ConnectionRegistry owns the global logical-connection index and capacity. type ConnectionRegistry struct { mu sync.RWMutex max int + limits ResourceLimits active map[ConnectionID]*Connection closing map[ConnectionID]*Connection closed bool } -func NewConnectionRegistry(maxConnections int) *ConnectionRegistry { +func NewConnectionRegistry(maxConnections int, limits ResourceLimits) *ConnectionRegistry { return &ConnectionRegistry{ max: maxConnections, + limits: limits, active: make(map[ConnectionID]*Connection), closing: make(map[ConnectionID]*Connection), } } -func (r *ConnectionRegistry) Register(connection *Connection) error { +func (r *ConnectionRegistry) Open() (*Connection, error) { r.mu.Lock() defer r.mu.Unlock() - if connection == nil { - return invalidRequest("connection is required") - } - if r.closed { - return invalidState("server is shutting down", nil) - } - - if r.active[connection.ID()] != nil || r.closing[connection.ID()] != nil { - return invalidState("connection is already registered", nil) + return nil, invalidState("server is shutting down", nil) } if len(r.active)+len(r.closing) >= r.max { - return resourceExhausted("logical connection limit reached") + return nil, resourceExhausted("logical connection limit reached") } + connection := newConnection(r.limits) r.active[connection.ID()] = connection - return nil + return connection, nil } func (r *ConnectionRegistry) Get(id ConnectionID) (*Connection, error) { @@ -109,3 +108,30 @@ func (r *ConnectionRegistry) beginShutdown() []ConnectionID { return ids } + +func (r *ConnectionRegistry) CloseConnection(ctx context.Context, id ConnectionID) error { + connection, started, err := r.beginClose(id) + if err != nil { + return err + } + + if started { + go func() { + closeErr := connection.settleClose() + r.remove(id, connection) + connection.finishClose(closeErr) + }() + } + + return connection.waitClose(ctx) +} + +func (r *ConnectionRegistry) Close(ctx context.Context) error { + var result error + for _, id := range r.beginShutdown() { + err := r.CloseConnection(ctx, id) + result = errors.Join(result, ignoreMissingResource(err, ErrorKindConnectionNotFound)) + } + + return result +} diff --git a/server/internal/core/connection_registry_test.go b/server/internal/core/connection_registry_test.go new file mode 100644 index 0000000..15bf038 --- /dev/null +++ b/server/internal/core/connection_registry_test.go @@ -0,0 +1,72 @@ +package core + +import ( + "context" + "testing" + "time" + + "github.com/MontFerret/api" +) + +func TestConnectionCapacityIsRetainedThroughCleanupAndShutdownRejectsAdmission(t *testing.T) { + entered := make(chan struct{}) + finish := make(chan struct{}) + hosted := &spyPlan{close: func() error { + close(entered) + <-finish + + return nil + }} + registry := NewConnectionRegistry(1, testLimits().resources()) + connection, err := registry.Open() + if err != nil { + t.Fatal(err) + } + + _, err = CompilePlan(testContext(t), &spyRuntime{compile: func(context.Context, api.Source, bool) (api.Plan, error) { + return hosted, nil + }}, connection.Resources(), api.Source{Content: "RETURN 1"}, false) + if err != nil { + t.Fatal(err) + } + + result := make(chan error, 1) + ctx := testContext(t) + go func() { result <- registry.CloseConnection(ctx, connection.ID()) }() + select { + case <-entered: + case <-ctx.Done(): + t.Fatal("cleanup did not reach the hosted plan") + } + + if _, err := registry.Open(); !hasCategory(err, ErrorKindResourceExhausted) { + t.Fatalf("closing connection released capacity early: %v", err) + } + + close(finish) + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("connection cleanup did not settle") + } + + if _, err := registry.Open(); err != nil { + t.Fatalf("settled connection retained capacity: %v", err) + } + + if err := registry.Close(ctx); err != nil { + t.Fatal(err) + } + + if _, err := registry.Open(); !hasCategory(err, ErrorKindInvalidState) { + t.Fatalf("shutdown accepted a new logical connection: %v", err) + } + + _, _, closes := hosted.snapshot() + if closes != 1 { + t.Fatalf("hosted plan closed %d times", closes) + } +} diff --git a/server/internal/core/context.go b/server/internal/core/context.go index 2a63f2b..acfdf8a 100644 --- a/server/internal/core/context.go +++ b/server/internal/core/context.go @@ -2,34 +2,17 @@ package core import "context" -// Context is one Wire operation scoped to a logical connection. -type Context struct { - context.Context - connection *Connection -} - -// NewContext combines caller cancellation with the logical connection -// lifetime. The returned cancel function must be called when the operation -// finishes so the connection cancellation callback is detached. -func NewContext(parent context.Context, connection *Connection) (*Context, context.CancelFunc) { +// OperationContext preserves request values and deadlines while joining the +// 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(connection.Context(), func() { - cancel(context.Cause(connection.Context())) - }) - if err := connection.Context().Err(); err != nil { - cancel(context.Cause(connection.Context())) + stop := context.AfterFunc(lifetime, func() { cancel(context.Cause(lifetime)) }) + if lifetime.Err() != nil { + cancel(context.Cause(lifetime)) } - return &Context{Context: operation, connection: connection}, func() { + return operation, func() { stop() cancel(context.Canceled) } } - -func (c *Context) Connection() *Connection { - return c.connection -} - -func (c *Context) connectionID() ConnectionID { - return c.connection.ID() -} diff --git a/server/internal/core/context_test.go b/server/internal/core/context_test.go new file mode 100644 index 0000000..21b265f --- /dev/null +++ b/server/internal/core/context_test.go @@ -0,0 +1,102 @@ +package core + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +// callbackLifetime exposes the standard context AfterFunc contract so the test +// can observe detachment without inspecting context's private implementation. +type callbackLifetime struct { + context.Context + done chan struct{} + callbacks atomic.Int32 +} + +func (c *callbackLifetime) Done() <-chan struct{} { + return c.done +} + +func (c *callbackLifetime) AfterFunc(func()) func() bool { + c.callbacks.Add(1) + var stopped atomic.Bool + + return func() bool { + if !stopped.CompareAndSwap(false, true) { + return false + } + + c.callbacks.Add(-1) + + return true + } +} + +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") + } + + cancel() + cancel() + if lifetime.callbacks.Load() != 0 || !errors.Is(operation.Err(), context.Canceled) { + t.Fatal("operation cancellation did not detach its lifetime callback") + } +} + +func TestOperationContextPreservesCancellationCauses(t *testing.T) { + for _, source := range []string{"request", "lifetime", "already cancelled lifetime", "deadline"} { + t.Run(source, func(t *testing.T) { + request, cancelRequest := context.WithCancelCause(context.Background()) + defer cancelRequest(nil) + + lifetime, cancelLifetime := context.WithCancelCause(context.Background()) + defer cancelLifetime(nil) + + cause := errors.New("cancellation cause") + if source == "already cancelled lifetime" { + cancelLifetime(cause) + } + + if source == "deadline" { + var cancel context.CancelFunc + request, cancel = context.WithDeadlineCause(request, time.Now().Add(-time.Second), cause) + defer cancel() + } + + operation, cancel := OperationContext(request, lifetime) + defer cancel() + + switch source { + case "request": + cancelRequest(cause) + case "lifetime": + cancelLifetime(cause) + } + + select { + case <-operation.Done(): + case <-time.After(5 * time.Second): + t.Fatal("operation did not cancel") + } + + if !errors.Is(context.Cause(operation), cause) { + t.Fatalf("cancellation cause = %v, want %v", context.Cause(operation), cause) + } + + want := context.Canceled + if source == "deadline" { + want = context.DeadlineExceeded + } + + if !errors.Is(operation.Err(), want) { + t.Fatalf("operation error = %v, want %v", operation.Err(), want) + } + }) + } +} diff --git a/server/internal/core/debug_boundary_spy_test.go b/server/internal/core/debug_boundary_spy_test.go new file mode 100644 index 0000000..754ac61 --- /dev/null +++ b/server/internal/core/debug_boundary_spy_test.go @@ -0,0 +1,146 @@ +package core + +import ( + "context" + "sync" + + "github.com/MontFerret/api/debugger" + "github.com/MontFerret/api/source" +) + +type ( + boundaryDebugger struct { + spyDebugger + callsMu sync.Mutex + calls []string + panicOn string + err error + } + + borrowedInspectionDebugger struct { + spyDebugger + frames []debugger.Frame + locals []debugger.Variable + values []debugger.Variable + } +) + +func (d *boundaryDebugger) Start(context.Context) (*debugger.Event, error) { + return d.command("start") +} + +func (d *boundaryDebugger) Continue(context.Context) (*debugger.Event, error) { + return d.command("continue") +} + +func (d *boundaryDebugger) StepOver(context.Context) (*debugger.Event, error) { + return d.command("step-over") +} + +func (d *boundaryDebugger) StepIn(context.Context) (*debugger.Event, error) { + return d.command("step-in") +} + +func (d *boundaryDebugger) StepOut(context.Context) (*debugger.Event, error) { + return d.command("step-out") +} + +func (d *boundaryDebugger) Pause() error { + return d.record("pause") +} + +func (d *boundaryDebugger) Frames() ([]debugger.Frame, error) { + if err := d.record("frames"); err != nil { + return nil, err + } + + return []debugger.Frame{{Name: "main"}}, nil +} + +func (d *boundaryDebugger) FrameLocals(frame int) ([]debugger.Variable, error) { + if err := d.record("frame-locals"); err != nil { + return nil, err + } + + return []debugger.Variable{{Name: "frame"}}, nil +} + +func (d *boundaryDebugger) Variables(reference debugger.ValueReference) ([]debugger.Variable, error) { + if err := d.record("variables"); err != nil { + return nil, err + } + + return []debugger.Variable{{Name: "reference"}}, nil +} + +func (d *boundaryDebugger) EvaluateFrame( + context.Context, + int, + string, +) (debugger.Value, error) { + if err := d.record("evaluate-frame"); err != nil { + return debugger.Value{}, err + } + + return debugger.Value{Type: "string", Display: "value"}, nil +} + +func (d *boundaryDebugger) SetBreakpointAt( + location source.Location, + options debugger.BreakpointOptions, +) (debugger.Breakpoint, error) { + if err := d.record("set-breakpoint"); err != nil { + return debugger.Breakpoint{}, err + } + + return debugger.Breakpoint{ID: 7, RequestedLocation: location, BindingMode: options.BindingMode}, nil +} + +func (d *boundaryDebugger) DeleteBreakpoint(debugger.BreakpointID) error { + return d.record("delete-breakpoint") +} + +func (d *boundaryDebugger) Close() error { + return d.record("close") +} + +func (d *boundaryDebugger) command(name string) (*debugger.Event, error) { + if err := d.record(name); err != nil { + return nil, err + } + + return &debugger.Event{Reason: debugger.ReasonStep}, nil +} + +func (d *boundaryDebugger) record(name string) error { + d.callsMu.Lock() + d.calls = append(d.calls, name) + panicOn := d.panicOn + err := d.err + d.callsMu.Unlock() + + if panicOn == name { + panic("runtime secret") + } + + return err +} + +func (d *boundaryDebugger) snapshotCalls() []string { + d.callsMu.Lock() + defer d.callsMu.Unlock() + + return append([]string(nil), d.calls...) +} + +func (d *borrowedInspectionDebugger) Frames() ([]debugger.Frame, error) { + return d.frames, nil +} + +func (d *borrowedInspectionDebugger) FrameLocals(int) ([]debugger.Variable, error) { + return d.locals, nil +} + +func (d *borrowedInspectionDebugger) Variables(debugger.ValueReference) ([]debugger.Variable, error) { + return d.values, nil +} diff --git a/server/internal/core/debug_boundary_test.go b/server/internal/core/debug_boundary_test.go new file mode 100644 index 0000000..4d9eff7 --- /dev/null +++ b/server/internal/core/debug_boundary_test.go @@ -0,0 +1,167 @@ +package core + +import ( + "errors" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/MontFerret/api/debugger" + "github.com/MontFerret/api/source" + wiredebugger "github.com/MontFerret/wire/pkg/debugger" + "github.com/MontFerret/wire/pkg/failure" + "github.com/MontFerret/wire/server/internal/panicboundary" +) + +func TestDebugSessionContainsPanicsAtEveryHostedMethod(t *testing.T) { + for _, name := range []string{"start", "continue", "step-over", "step-in", "step-out", "pause", "frames", "frame-locals", "variables", "evaluate-frame", "set-breakpoint", "delete-breakpoint", "close"} { + t.Run(name, func(t *testing.T) { + hosted := &boundaryDebugger{panicOn: name} + session := newTestCoreDebugSession(t, hosted, 2) + session.state.status = wiredebugger.StateStopped + ctx := testContext(t) + var err error + asynchronous := false + switch name { + case "start": + session.state.status = wiredebugger.StateCreated + _, err = session.Start(ctx) + asynchronous = true + case "continue": + _, err = session.Continue(ctx) + asynchronous = true + case "step-over": + _, err = session.StepOver(ctx) + asynchronous = true + case "step-in": + _, err = session.StepIn(ctx) + asynchronous = true + case "step-out": + _, err = session.StepOut(ctx) + asynchronous = true + case "pause": + session.state.status = wiredebugger.StateRunning + _, err = session.Pause(ctx) + case "frames": + _, err = session.Frames(ctx) + case "frame-locals": + _, err = session.FrameLocals(ctx, 0) + case "variables": + _, err = session.Variables(ctx, 1) + case "evaluate-frame": + _, err = session.EvaluateFrame(ctx, 0, "value") + case "set-breakpoint": + _, err = session.SetBreakpoint(ctx, source.Location{SourceName: "query", Position: source.Position{Line: 1}}) + case "delete-breakpoint": + session.breakpoints.add(debugger.Breakpoint{ID: 1}) + err = session.DeleteBreakpoint(ctx, 1) + case "close": + err = session.Close(ctx) + } + + if asynchronous { + if err != nil { + t.Fatal(err) + } + + snapshot := waitCoreDebugState(t, session, wiredebugger.StateFailed) + if snapshot.Failure == nil || snapshot.Failure.Category != failure.CategoryInternalRuntime || + strings.Contains(snapshot.Failure.Message, "runtime secret") { + t.Fatalf("hosted panic was not a sanitized terminal failure: %#v", snapshot) + } + } else { + var contained *panicboundary.Error + if !errors.As(err, &contained) || contained.Value != "runtime secret" || + len(contained.Stack) == 0 || strings.Contains(err.Error(), "runtime secret") { + t.Fatalf("hosted panic lost its sanitized typed cause: %v", err) + } + } + + closeErr := session.Close(ctx) + if name == "close" { + var contained *panicboundary.Error + if !errors.As(closeErr, &contained) { + t.Fatalf("close lost its retained panic: %v", closeErr) + } + } else if closeErr != nil { + t.Fatal(closeErr) + } + + if got := hosted.snapshotCalls(); name == "close" { + if !reflect.DeepEqual(got, []string{"close"}) { + t.Fatalf("close ran more than once: %v", got) + } + } else if !reflect.DeepEqual(got, []string{name, "close"}) { + t.Fatalf("unexpected hosted calls: %v", got) + } + }) + } +} + +func TestDebugSessionCloseRetainsOneResultForConcurrentCallers(t *testing.T) { + closeErr := errors.New("close failed") + hosted := &boundaryDebugger{err: closeErr} + session := newTestCoreDebugSession(t, hosted, 1) + ctx := testContext(t) + const callers = 16 + results := make(chan error, callers) + var ready sync.WaitGroup + ready.Add(callers) + start := make(chan struct{}) + for range callers { + go func() { + ready.Done() + <-start + results <- session.Close(ctx) + }() + } + + ready.Wait() + close(start) + for range callers { + select { + case err := <-results: + if !errors.Is(err, closeErr) { + t.Fatalf("close result was not retained: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("concurrent close did not settle") + } + } + + if got := hosted.snapshotCalls(); !reflect.DeepEqual(got, []string{"close"}) { + t.Fatalf("hosted close calls: %v", got) + } +} + +func TestDebugSessionInspectionDetachesHostedSlices(t *testing.T) { + frames := []debugger.Frame{{Name: "frame"}} + locals := []debugger.Variable{{Name: "local"}} + variables := []debugger.Variable{{Name: "variable"}} + 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) + } + + gotLocals, err := session.FrameLocals(testContext(t), 0) + if err != nil { + t.Fatal(err) + } + + gotVariables, err := session.Variables(testContext(t), 1) + if err != nil { + t.Fatal(err) + } + + frames[0].Name, locals[0].Name, variables[0].Name = "changed", "changed", "changed" + if gotFrames[0].Name != "frame" || gotLocals[0].Name != "local" || gotVariables[0].Name != "variable" { + t.Fatal("inspection retained hosted slice storage") + } + + closeTestCoreDebugSession(t, session) +} diff --git a/server/internal/core/debug_controller.go b/server/internal/core/debug_controller.go deleted file mode 100644 index 95d75b9..0000000 --- a/server/internal/core/debug_controller.go +++ /dev/null @@ -1,125 +0,0 @@ -package core - -import ( - "context" - "sync" - - "github.com/MontFerret/api/debugger" - "github.com/MontFerret/api/source" - "github.com/MontFerret/wire/server/internal/panicboundary" -) - -// DebugController exclusively owns the Unified API debugger session and keeps -// panic containment at that external boundary. Wire state and policy remain -// owned by DebugSession and its collaborators. -type DebugController struct { - session debugger.Session - closeOnce sync.Once - closeErr error -} - -func newDebugController(session debugger.Session) *DebugController { - return &DebugController{session: session} -} - -func (c *DebugController) Start(ctx context.Context) (*debugger.Event, error) { - return panicboundary.Call(func() (*debugger.Event, error) { - return c.session.Start(ctx) - }) -} - -func (c *DebugController) Continue(ctx context.Context) (*debugger.Event, error) { - return panicboundary.Call(func() (*debugger.Event, error) { - return c.session.Continue(ctx) - }) -} - -func (c *DebugController) StepOver(ctx context.Context) (*debugger.Event, error) { - return panicboundary.Call(func() (*debugger.Event, error) { - return c.session.StepOver(ctx) - }) -} - -func (c *DebugController) StepIn(ctx context.Context) (*debugger.Event, error) { - return panicboundary.Call(func() (*debugger.Event, error) { - return c.session.StepIn(ctx) - }) -} - -func (c *DebugController) StepOut(ctx context.Context) (*debugger.Event, error) { - return panicboundary.Call(func() (*debugger.Event, error) { - return c.session.StepOut(ctx) - }) -} - -func (c *DebugController) Pause() error { - return panicboundary.Do(c.session.Pause) -} - -func (c *DebugController) Frames() ([]debugger.Frame, error) { - values, err := panicboundary.Call(c.session.Frames) - if err != nil { - return nil, err - } - - return append([]debugger.Frame(nil), values...), nil -} - -func (c *DebugController) FrameLocals(frame int) ([]debugger.Variable, error) { - values, err := panicboundary.Call(func() ([]debugger.Variable, error) { - return c.session.FrameLocals(frame) - }) - if err != nil { - return nil, err - } - - return append([]debugger.Variable(nil), values...), nil -} - -func (c *DebugController) Variables(reference debugger.ValueReference) ([]debugger.Variable, error) { - values, err := panicboundary.Call(func() ([]debugger.Variable, error) { - return c.session.Variables(reference) - }) - if err != nil { - return nil, err - } - - return append([]debugger.Variable(nil), values...), nil -} - -func (c *DebugController) EvaluateFrame( - ctx context.Context, - frame int, - expression string, -) (debugger.Value, error) { - value, err := panicboundary.Call(func() (debugger.Value, error) { - return c.session.EvaluateFrame(ctx, frame, expression) - }) - - return value, err -} - -func (c *DebugController) SetBreakpoint( - location source.Location, - options debugger.BreakpointOptions, -) (debugger.Breakpoint, error) { - value, err := panicboundary.Call(func() (debugger.Breakpoint, error) { - return c.session.SetBreakpointAt(location, options) - }) - - return value, err -} - -func (c *DebugController) DeleteBreakpoint(id debugger.BreakpointID) error { - return panicboundary.Do(func() error { - return c.session.DeleteBreakpoint(id) - }) -} - -func (c *DebugController) Close() error { - c.closeOnce.Do(func() { - c.closeErr = runtimePanicError("close runtime debug session", panicboundary.Do(c.session.Close)) - }) - - return c.closeErr -} diff --git a/server/internal/core/debug_controller_test.go b/server/internal/core/debug_controller_test.go deleted file mode 100644 index 13c6df9..0000000 --- a/server/internal/core/debug_controller_test.go +++ /dev/null @@ -1,344 +0,0 @@ -package core - -import ( - "context" - "errors" - "reflect" - "strings" - "sync" - "testing" - "time" - - "github.com/MontFerret/api/debugger" - "github.com/MontFerret/api/source" - "github.com/MontFerret/wire/server/internal/panicboundary" -) - -type controllerDebugger struct { - spyDebugger - callsMu sync.Mutex - calls []string - panicOn string - err error -} - -func (d *controllerDebugger) Start(context.Context) (*debugger.Event, error) { - return d.command("start") -} - -func (d *controllerDebugger) Continue(context.Context) (*debugger.Event, error) { - return d.command("continue") -} - -func (d *controllerDebugger) StepOver(context.Context) (*debugger.Event, error) { - return d.command("step-over") -} - -func (d *controllerDebugger) StepIn(context.Context) (*debugger.Event, error) { - return d.command("step-in") -} - -func (d *controllerDebugger) StepOut(context.Context) (*debugger.Event, error) { - return d.command("step-out") -} - -func (d *controllerDebugger) Pause() error { - return d.record("pause") -} - -func (d *controllerDebugger) Frames() ([]debugger.Frame, error) { - if err := d.record("frames"); err != nil { - return nil, err - } - - return []debugger.Frame{{Name: "main"}}, nil -} - -func (d *controllerDebugger) FrameLocals(frame int) ([]debugger.Variable, error) { - if err := d.record("frame-locals"); err != nil { - return nil, err - } - - return []debugger.Variable{{Name: "frame"}}, nil -} - -func (d *controllerDebugger) Variables(reference debugger.ValueReference) ([]debugger.Variable, error) { - if err := d.record("variables"); err != nil { - return nil, err - } - - return []debugger.Variable{{Name: "reference"}}, nil -} - -func (d *controllerDebugger) EvaluateFrame( - context.Context, - int, - string, -) (debugger.Value, error) { - if err := d.record("evaluate-frame"); err != nil { - return debugger.Value{}, err - } - - return debugger.Value{Type: "string", Display: "value"}, nil -} - -func (d *controllerDebugger) SetBreakpointAt( - location source.Location, - options debugger.BreakpointOptions, -) (debugger.Breakpoint, error) { - if err := d.record("set-breakpoint"); err != nil { - return debugger.Breakpoint{}, err - } - - return debugger.Breakpoint{ID: 7, RequestedLocation: location, BindingMode: options.BindingMode}, nil -} - -func (d *controllerDebugger) DeleteBreakpoint(debugger.BreakpointID) error { - return d.record("delete-breakpoint") -} - -func (d *controllerDebugger) Close() error { - return d.record("close") -} - -func (d *controllerDebugger) command(name string) (*debugger.Event, error) { - if err := d.record(name); err != nil { - return nil, err - } - - return &debugger.Event{Reason: debugger.ReasonStep}, nil -} - -func (d *controllerDebugger) record(name string) error { - d.callsMu.Lock() - d.calls = append(d.calls, name) - panicOn := d.panicOn - err := d.err - d.callsMu.Unlock() - - if panicOn == name { - panic("runtime secret") - } - - return err -} - -func (d *controllerDebugger) snapshotCalls() []string { - d.callsMu.Lock() - defer d.callsMu.Unlock() - - return append([]string(nil), d.calls...) -} - -func TestDebugControllerDelegatesCommandsAndInspection(t *testing.T) { - runtime := &controllerDebugger{} - controller := newDebugController(runtime) - ctx := context.Background() - - commands := []func(context.Context) (*debugger.Event, error){ - controller.Start, - controller.Continue, - controller.StepOver, - controller.StepIn, - controller.StepOut, - } - for _, command := range commands { - event, err := command(ctx) - if err != nil || event == nil || event.Reason != debugger.ReasonStep { - t.Fatalf("unexpected command result: %#v, %v", event, err) - } - } - - if err := controller.Pause(); err != nil { - t.Fatal(err) - } - - frames, err := controller.Frames() - if err != nil || !reflect.DeepEqual(frames, []debugger.Frame{{Name: "main"}}) { - t.Fatalf("unexpected frames: %#v, %v", frames, err) - } - - locals, err := controller.FrameLocals(3) - if err != nil || !reflect.DeepEqual(locals, []debugger.Variable{{Name: "frame"}}) { - t.Fatalf("unexpected frame locals: %#v, %v", locals, err) - } - - variables, err := controller.Variables(9) - if err != nil || !reflect.DeepEqual(variables, []debugger.Variable{{Name: "reference"}}) { - t.Fatalf("unexpected variables: %#v, %v", variables, err) - } - - value, err := controller.EvaluateFrame(ctx, 3, "value") - if err != nil || value != (debugger.Value{Type: "string", Display: "value"}) { - t.Fatalf("unexpected evaluated value: %#v, %v", value, err) - } - - location := source.Location{SourceName: "query.fql", Position: source.Position{Line: 1}} - options := debugger.BreakpointOptions{BindingMode: debugger.BreakpointBindExact} - breakpoint, err := controller.SetBreakpoint(location, options) - if err != nil || breakpoint.ID != 7 || breakpoint.RequestedLocation != location || breakpoint.BindingMode != options.BindingMode { - t.Fatalf("unexpected breakpoint: %#v, %v", breakpoint, err) - } - - if err := controller.DeleteBreakpoint(breakpoint.ID); err != nil { - t.Fatal(err) - } - - wantCalls := []string{ - "start", - "continue", - "step-over", - "step-in", - "step-out", - "pause", - "frames", - "frame-locals", - "variables", - "evaluate-frame", - "set-breakpoint", - "delete-breakpoint", - } - if got := runtime.snapshotCalls(); !reflect.DeepEqual(got, wantCalls) { - t.Fatalf("unexpected controller calls: %#v", got) - } -} - -func TestDebugControllerReturnsRuntimeErrors(t *testing.T) { - runtimeErr := errors.New("runtime failed") - runtime := &controllerDebugger{err: runtimeErr} - controller := newDebugController(runtime) - - if _, err := controller.Start(context.Background()); !errors.Is(err, runtimeErr) { - t.Fatalf("command error was not retained: %v", err) - } - - if _, err := controller.Frames(); !errors.Is(err, runtimeErr) { - t.Fatalf("inspection error was not retained: %v", err) - } - - if _, err := controller.SetBreakpoint(source.Location{}, debugger.BreakpointOptions{}); !errors.Is(err, runtimeErr) { - t.Fatalf("breakpoint error was not retained: %v", err) - } -} - -func TestDebugControllerContainsRuntimePanicsAtEachBoundary(t *testing.T) { - tests := []struct { - name string - call func(*DebugController) error - }{ - {name: "start", call: func(controller *DebugController) error { - _, err := controller.Start(context.Background()) - - return err - }}, - {name: "continue", call: func(controller *DebugController) error { - _, err := controller.Continue(context.Background()) - - return err - }}, - {name: "step-over", call: func(controller *DebugController) error { - _, err := controller.StepOver(context.Background()) - - return err - }}, - {name: "step-in", call: func(controller *DebugController) error { - _, err := controller.StepIn(context.Background()) - - return err - }}, - {name: "step-out", call: func(controller *DebugController) error { - _, err := controller.StepOut(context.Background()) - - return err - }}, - {name: "pause", call: func(controller *DebugController) error { - return controller.Pause() - }}, - {name: "frames", call: func(controller *DebugController) error { - _, err := controller.Frames() - - return err - }}, - {name: "frame-locals", call: func(controller *DebugController) error { - _, err := controller.FrameLocals(0) - - return err - }}, - {name: "variables", call: func(controller *DebugController) error { - _, err := controller.Variables(1) - - return err - }}, - {name: "evaluate-frame", call: func(controller *DebugController) error { - _, err := controller.EvaluateFrame(context.Background(), 0, "value") - - return err - }}, - {name: "set-breakpoint", call: func(controller *DebugController) error { - _, err := controller.SetBreakpoint(source.Location{}, debugger.BreakpointOptions{}) - - return err - }}, - {name: "delete-breakpoint", call: func(controller *DebugController) error { - return controller.DeleteBreakpoint(1) - }}, - {name: "close", call: func(controller *DebugController) error { - return controller.Close() - }}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - runtime := &controllerDebugger{panicOn: test.name} - err := test.call(newDebugController(runtime)) - if err == nil || strings.Contains(err.Error(), "runtime secret") { - t.Fatalf("runtime panic was not sanitized: %v", err) - } - - var panicErr *panicboundary.Error - if !errors.As(err, &panicErr) { - t.Fatalf("runtime panic was not retained as a typed cause: %v", err) - } - - if panicErr.Value != "runtime secret" || len(panicErr.Stack) == 0 { - t.Fatalf("runtime panic diagnostics were not retained: %#v", panicErr) - } - }) - } -} - -func TestDebugControllerCloseIsConcurrentAndIdempotent(t *testing.T) { - closeErr := errors.New("close failed") - runtime := &controllerDebugger{err: closeErr} - controller := newDebugController(runtime) - - const callers = 16 - results := make(chan error, callers) - var ready sync.WaitGroup - ready.Add(callers) - start := make(chan struct{}) - for range callers { - go func() { - ready.Done() - <-start - results <- controller.Close() - }() - } - - ready.Wait() - close(start) - for range callers { - select { - case err := <-results: - if !errors.Is(err, closeErr) { - t.Fatalf("close result was not retained: %v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for concurrent close") - } - } - - if got := runtime.snapshotCalls(); !reflect.DeepEqual(got, []string{"close"}) { - t.Fatalf("runtime close calls = %#v", got) - } -} diff --git a/server/internal/core/debug_session.go b/server/internal/core/debug_session.go index cb977ce..2f43fc3 100644 --- a/server/internal/core/debug_session.go +++ b/server/internal/core/debug_session.go @@ -12,19 +12,19 @@ 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" ) type DebugSession struct { // operationMu serializes state-dependent operations and command commits. // The active runtime resume and close paths intentionally do not hold it - // so pause and cancellation can reach the controller. + // so pause and cancellation can reach the hosted debugger. operationMu sync.Mutex // stateMu protects only Wire-visible state and never spans a runtime call. stateMu sync.Mutex id DebugSessionID - owner ConnectionID - planID PlanID - controller *DebugController + plan *Plan + session debugger.Session ctx context.Context cancel context.CancelCauseFunc state debugSessionState @@ -34,32 +34,49 @@ type DebugSession struct { release lifecycle.Close } -func newDebugSession( - id DebugSessionID, - owner ConnectionID, - planID PlanID, - controller *DebugController, - ctx context.Context, - cancel context.CancelCauseFunc, - maxWatchers int, - maxBreakpoints int, -) *DebugSession { +func newDebugSession(plan *Plan, hosted debugger.Session) *DebugSession { + ctx, cancel := context.WithCancelCause(plan.store.ctx) session := &DebugSession{ - id: id, - owner: owner, - planID: planID, - controller: controller, + id: DebugSessionID(uuid.NewString()), + plan: plan, + session: hosted, ctx: ctx, cancel: cancel, state: debugSessionState{status: wiredebugger.StateCreated}, - breakpoints: newBreakpointSet(maxBreakpoints), - events: newEventStream(maxWatchers, cloneDebugEvent, sequenceDebugEvent), + breakpoints: newBreakpointSet(plan.store.limits.Breakpoints), + events: newEventStream(plan.store.limits.Watchers, cloneDebugEvent, sequenceDebugEvent), } session.publishLocked(wiredebugger.EventCreated, false) return session } +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() + } + + return d.release.Wait(ctx) +} + +func (d *DebugSession) settleRelease() { + var err error + defer func() { + if recover() != nil { + err = errors.Join(err, internalError(errors.New("debug session release panicked"))) + } + + d.plan.store.removeDebugSession(d) + + d.release.Finish(err) + }() + + err = d.Close(context.Background()) +} + func (d *DebugSession) Close(ctx context.Context) error { d.beginClose() @@ -70,22 +87,22 @@ func (d *DebugSession) ID() DebugSessionID { return d.id } -func (d *DebugSession) Stop(ctx context.Context) (DebugSessionRecord, error) { - snapshot := d.snapshot() - if !snapshot.Snapshot.State.Terminal() { +func (d *DebugSession) Stop(ctx context.Context) (wiredebugger.Snapshot, error) { + snapshot := d.Snapshot() + if !snapshot.State.Terminal() { if err := d.Close(ctx); err != nil { - return DebugSessionRecord{}, err + return wiredebugger.Snapshot{}, err } - snapshot = d.snapshot() + snapshot = d.Snapshot() } return snapshot, nil } -func (d *DebugSession) Pause(ctx context.Context) (DebugSessionRecord, error) { +func (d *DebugSession) Pause(ctx context.Context) (wiredebugger.Snapshot, error) { if err := ctx.Err(); err != nil { - return DebugSessionRecord{}, err + return wiredebugger.Snapshot{}, err } d.operationMu.Lock() @@ -95,20 +112,20 @@ func (d *DebugSession) Pause(ctx context.Context) (DebugSessionRecord, error) { if d.state.status != wiredebugger.StateRunning { d.stateMu.Unlock() - return DebugSessionRecord{}, invalidState("debug session is not running", nil) + return wiredebugger.Snapshot{}, invalidState("debug session is not running", nil) } d.stateMu.Unlock() - if err := d.controller.Pause(); err != nil { + if err := panicboundary.Do(d.session.Pause); err != nil { if panicErr := d.poisonAfterRuntimePanic("pause runtime debugger", err); panicErr != nil { - return DebugSessionRecord{}, panicErr + return wiredebugger.Snapshot{}, panicErr } - return DebugSessionRecord{}, invalidState("pause failed", err) + return wiredebugger.Snapshot{}, invalidState("pause failed", err) } - return d.snapshot(), nil + return d.Snapshot(), nil } func (d *DebugSession) SetBreakpoint( @@ -159,7 +176,9 @@ func (d *DebugSession) SetBreakpointAt( return debugger.Breakpoint{}, err } - value, err := d.controller.SetBreakpoint(location, options) + value, err := panicboundary.Call(func() (debugger.Breakpoint, error) { + return d.session.SetBreakpointAt(location, options) + }) if err != nil { if panicErr := d.poisonAfterRuntimePanic("set runtime breakpoint", err); panicErr != nil { return debugger.Breakpoint{}, panicErr @@ -201,7 +220,7 @@ func (d *DebugSession) DeleteBreakpoint(ctx context.Context, breakpointID debugg return err } - if err := d.controller.DeleteBreakpoint(value.ID); err != nil { + if err := panicboundary.Do(func() error { return d.session.DeleteBreakpoint(value.ID) }); err != nil { if panicErr := d.poisonAfterRuntimePanic("delete runtime breakpoint", err); panicErr != nil { return panicErr } @@ -214,24 +233,24 @@ func (d *DebugSession) DeleteBreakpoint(ctx context.Context, breakpointID debugg return nil } -func (d *DebugSession) Start(ctx context.Context) (DebugSessionRecord, error) { - return d.start(ctx, true, d.controller.Start) +func (d *DebugSession) Start(ctx context.Context) (wiredebugger.Snapshot, error) { + return d.start(ctx, true, d.session.Start) } -func (d *DebugSession) Continue(ctx context.Context) (DebugSessionRecord, error) { - return d.start(ctx, false, d.controller.Continue) +func (d *DebugSession) Continue(ctx context.Context) (wiredebugger.Snapshot, error) { + return d.start(ctx, false, d.session.Continue) } -func (d *DebugSession) StepOver(ctx context.Context) (DebugSessionRecord, error) { - return d.start(ctx, false, d.controller.StepOver) +func (d *DebugSession) StepOver(ctx context.Context) (wiredebugger.Snapshot, error) { + return d.start(ctx, false, d.session.StepOver) } -func (d *DebugSession) StepIn(ctx context.Context) (DebugSessionRecord, error) { - return d.start(ctx, false, d.controller.StepIn) +func (d *DebugSession) StepIn(ctx context.Context) (wiredebugger.Snapshot, error) { + return d.start(ctx, false, d.session.StepIn) } -func (d *DebugSession) StepOut(ctx context.Context) (DebugSessionRecord, error) { - return d.start(ctx, false, d.controller.StepOut) +func (d *DebugSession) StepOut(ctx context.Context) (wiredebugger.Snapshot, error) { + return d.start(ctx, false, d.session.StepOut) } func (d *DebugSession) Frames(ctx context.Context) ([]debugger.Frame, error) { @@ -242,7 +261,7 @@ func (d *DebugSession) Frames(ctx context.Context) ([]debugger.Frame, error) { return nil, err } - values, err := d.controller.Frames() + values, err := panicboundary.Call(d.session.Frames) if err != nil { if panicErr := d.poisonAfterRuntimePanic("read runtime debugger frames", err); panicErr != nil { return nil, panicErr @@ -251,7 +270,7 @@ func (d *DebugSession) Frames(ctx context.Context) ([]debugger.Frame, error) { return nil, invalidState("frames failed", err) } - return values, nil + return append([]debugger.Frame(nil), values...), nil } func (d *DebugSession) FrameLocals(ctx context.Context, frame int) ([]debugger.Variable, error) { @@ -266,7 +285,9 @@ func (d *DebugSession) FrameLocals(ctx context.Context, frame int) ([]debugger.V return nil, err } - values, err := d.controller.FrameLocals(frame) + values, err := panicboundary.Call(func() ([]debugger.Variable, error) { + return d.session.FrameLocals(frame) + }) if err != nil { if panicErr := d.poisonAfterRuntimePanic("read runtime debugger frame locals", err); panicErr != nil { return nil, panicErr @@ -275,7 +296,7 @@ func (d *DebugSession) FrameLocals(ctx context.Context, frame int) ([]debugger.V return nil, invalidState("frame locals failed", err) } - return values, nil + return append([]debugger.Variable(nil), values...), nil } func (d *DebugSession) Variables( @@ -293,7 +314,9 @@ func (d *DebugSession) Variables( return nil, err } - values, err := d.controller.Variables(reference) + values, err := panicboundary.Call(func() ([]debugger.Variable, error) { + return d.session.Variables(reference) + }) if err != nil { if panicErr := d.poisonAfterRuntimePanic("read runtime debugger variables", err); panicErr != nil { return nil, panicErr @@ -302,7 +325,7 @@ func (d *DebugSession) Variables( return nil, invalidState("variables failed", err) } - return values, nil + return append([]debugger.Variable(nil), values...), nil } func (d *DebugSession) EvaluateFrame( @@ -325,10 +348,12 @@ func (d *DebugSession) EvaluateFrame( return debugger.Value{}, err } - evaluateCtx, cancel := d.operationContext(ctx) + evaluateCtx, cancel := OperationContext(ctx, d.ctx) defer cancel() - value, err := d.controller.EvaluateFrame(evaluateCtx, frame, expression) + value, err := panicboundary.Call(func() (debugger.Value, error) { + return d.session.EvaluateFrame(evaluateCtx, frame, expression) + }) if err != nil { if panicErr := d.poisonAfterRuntimePanic("evaluate with runtime debugger", err); panicErr != nil { return debugger.Value{}, panicErr @@ -358,16 +383,16 @@ func (d *DebugSession) start( ctx context.Context, initial bool, command func(context.Context) (*debugger.Event, error), -) (DebugSessionRecord, error) { +) (wiredebugger.Snapshot, error) { if err := ctx.Err(); err != nil { - return DebugSessionRecord{}, err + return wiredebugger.Snapshot{}, err } d.operationMu.Lock() defer d.operationMu.Unlock() if err := ctx.Err(); err != nil { - return DebugSessionRecord{}, err + return wiredebugger.Snapshot{}, err } d.stateMu.Lock() @@ -379,7 +404,7 @@ func (d *DebugSession) start( if d.state.status != expected { d.stateMu.Unlock() - return DebugSessionRecord{}, invalidState("debug command is not valid in the current state", nil) + return wiredebugger.Snapshot{}, invalidState("debug command is not valid in the current state", nil) } d.state.beginRunning() @@ -398,7 +423,9 @@ func (d *DebugSession) start( } func (d *DebugSession) runCommand(command func(context.Context) (*debugger.Event, error)) { - event, err := command(d.ctx) + event, err := panicboundary.Call(func() (*debugger.Event, error) { + return command(d.ctx) + }) if err != nil { d.finishCommand(nil, err) @@ -522,18 +549,6 @@ func (d *DebugSession) requireStopped(ctx context.Context) error { return nil } -func (d *DebugSession) operationContext(ctx context.Context) (context.Context, context.CancelFunc) { - operation, cancel := context.WithCancelCause(ctx) - stop := context.AfterFunc(d.ctx, func() { - cancel(context.Cause(d.ctx)) - }) - - return operation, func() { - stop() - cancel(context.Canceled) - } -} - // poisonAfterRuntimePanic applies the aggregate policy for a debugger // implementation panic. The caller holds operationMu, so the failed transition // is serialized with commands and breakpoint bookkeeping. @@ -557,21 +572,21 @@ func (d *DebugSession) poisonAfterRuntimePanic(operation string, err error) erro return runtimePanicError(operation, err) } -func (d *DebugSession) snapshot() DebugSessionRecord { +func (d *DebugSession) Snapshot() wiredebugger.Snapshot { d.stateMu.Lock() defer d.stateMu.Unlock() return d.snapshotLocked() } -func (d *DebugSession) snapshotLocked() DebugSessionRecord { - return d.state.snapshot(d.id) +func (d *DebugSession) snapshotLocked() wiredebugger.Snapshot { + return d.state.snapshot() } func (d *DebugSession) publishLocked(kind wiredebugger.EventKind, terminal bool) { d.events.publish(wiredebugger.Event{ Kind: kind, - Snapshot: d.snapshotLocked().Snapshot, + Snapshot: d.snapshotLocked(), }, terminal) } @@ -594,7 +609,7 @@ func (d *DebugSession) settleClose() { }() d.cancel(context.Canceled) - err = d.controller.Close() + err = closeAPIDebugSession(d.session) d.operationMu.Lock() d.stateMu.Lock() diff --git a/server/internal/core/debug_session_input.go b/server/internal/core/debug_session_input.go deleted file mode 100644 index c3d3d6c..0000000 --- a/server/internal/core/debug_session_input.go +++ /dev/null @@ -1,7 +0,0 @@ -package core - -type OpenDebugInput struct { - PlanID PlanID - Parameters map[string]any - OutputContentType string -} diff --git a/server/internal/core/debug_session_registry.go b/server/internal/core/debug_session_registry.go deleted file mode 100644 index f09b7e3..0000000 --- a/server/internal/core/debug_session_registry.go +++ /dev/null @@ -1,171 +0,0 @@ -package core - -import "sync" - -// DebugSessionRegistry owns debugger-session storage, ownership indexes, and -// per-connection accounting. -type DebugSessionRegistry struct { - mu sync.RWMutex - max int - maxWatchers int - maxBreakpoints int - pending map[ConnectionID]int - active map[DebugSessionID]*DebugSession - closing map[DebugSessionID]*DebugSession - byOwner map[ConnectionID]map[DebugSessionID]*DebugSession - byPlan map[PlanID]map[DebugSessionID]*DebugSession -} - -func NewDebugSessionRegistry(maxSessionsPerConnection, maxWatchers, maxBreakpoints int) *DebugSessionRegistry { - return &DebugSessionRegistry{ - max: maxSessionsPerConnection, - maxWatchers: maxWatchers, - maxBreakpoints: maxBreakpoints, - pending: make(map[ConnectionID]int), - active: make(map[DebugSessionID]*DebugSession), - closing: make(map[DebugSessionID]*DebugSession), - byOwner: make(map[ConnectionID]map[DebugSessionID]*DebugSession), - byPlan: make(map[PlanID]map[DebugSessionID]*DebugSession), - } -} - -func (r *DebugSessionRegistry) reserve(owner ConnectionID) error { - r.mu.Lock() - defer r.mu.Unlock() - - if r.pending[owner]+len(r.byOwner[owner]) >= r.max { - return resourceExhausted("debug session limit reached") - } - - r.pending[owner]++ - - return nil -} - -func (r *DebugSessionRegistry) rollback(owner ConnectionID) { - r.mu.Lock() - r.pending[owner]-- - if r.pending[owner] == 0 { - delete(r.pending, owner) - } - r.mu.Unlock() -} - -func (r *DebugSessionRegistry) commit(session *DebugSession) error { - r.mu.Lock() - defer r.mu.Unlock() - - r.pending[session.owner]-- - if r.pending[session.owner] == 0 { - delete(r.pending, session.owner) - } - - if r.active[session.id] != nil || r.closing[session.id] != nil { - return invalidState("debug session ID is already registered", nil) - } - - r.active[session.id] = session - owned := r.byOwner[session.owner] - if owned == nil { - owned = make(map[DebugSessionID]*DebugSession) - r.byOwner[session.owner] = owned - } - - owned[session.id] = session - children := r.byPlan[session.planID] - if children == nil { - children = make(map[DebugSessionID]*DebugSession) - r.byPlan[session.planID] = children - } - - children[session.id] = session - - return nil -} - -func (r *DebugSessionRegistry) get(owner ConnectionID, id DebugSessionID) (*DebugSession, error) { - if err := validateID(id, "debug session ID"); err != nil { - return nil, err - } - - r.mu.RLock() - session := r.active[id] - if session != nil && session.owner != owner { - session = nil - } - r.mu.RUnlock() - - if session == nil { - return nil, notFound(ErrorKindDebugSessionNotFound, string(id)) - } - - return session, nil -} - -func (r *DebugSessionRegistry) beginClose(owner ConnectionID, id DebugSessionID) (*DebugSession, bool, error) { - if err := validateID(id, "debug session ID"); err != nil { - return nil, false, err - } - - r.mu.Lock() - session := r.active[id] - started := false - if session != nil && session.owner == owner { - delete(r.active, id) - r.closing[id] = session - started = session.release.Begin() - } else { - session = r.closing[id] - if session != nil && session.owner != owner { - session = nil - } - } - r.mu.Unlock() - - if session == nil { - return nil, false, notFound(ErrorKindDebugSessionNotFound, string(id)) - } - - return session, started, nil -} - -func (r *DebugSessionRegistry) remove(session *DebugSession) { - r.mu.Lock() - if r.closing[session.id] == session { - delete(r.closing, session.id) - delete(r.byOwner[session.owner], session.id) - if len(r.byOwner[session.owner]) == 0 { - delete(r.byOwner, session.owner) - } - - delete(r.byPlan[session.planID], session.id) - if len(r.byPlan[session.planID]) == 0 { - delete(r.byPlan, session.planID) - } - } - r.mu.Unlock() -} - -func (r *DebugSessionRegistry) listByOwner(owner ConnectionID) []DebugSessionID { - r.mu.RLock() - ids := make([]DebugSessionID, 0, len(r.byOwner[owner])) - for id := range r.byOwner[owner] { - ids = append(ids, id) - } - r.mu.RUnlock() - - return ids -} - -func (r *DebugSessionRegistry) listByPlan(owner ConnectionID, planID PlanID) []DebugSessionID { - r.mu.RLock() - ids := make([]DebugSessionID, 0, len(r.byPlan[planID])) - for id, session := range r.byPlan[planID] { - if session.owner == owner { - ids = append(ids, id) - } - } - r.mu.RUnlock() - - return ids -} diff --git a/server/internal/core/debug_session_test.go b/server/internal/core/debug_session_test.go index 724e3eb..8323c8c 100644 --- a/server/internal/core/debug_session_test.go +++ b/server/internal/core/debug_session_test.go @@ -3,8 +3,6 @@ package core import ( "context" "errors" - wiredebugger "github.com/MontFerret/wire/pkg/debugger" - "github.com/MontFerret/wire/pkg/failure" "reflect" "sync/atomic" "testing" @@ -13,11 +11,13 @@ import ( "github.com/MontFerret/api/debugger" "github.com/MontFerret/api/diagnostics" "github.com/MontFerret/api/source" + wiredebugger "github.com/MontFerret/wire/pkg/debugger" + "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/server/internal/panicboundary" ) func TestDebugSessionRejectsInvalidCommandWithoutRuntimeOrEvent(t *testing.T) { - runtime := &controllerDebugger{} + runtime := &boundaryDebugger{} session := newTestCoreDebugSession(t, runtime, 1) subscription, err := session.Watch() if err != nil { @@ -121,7 +121,7 @@ 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 { + if snapshot := session.Snapshot(); snapshot.State != wiredebugger.StateRunning { t.Fatalf("watch disconnect changed debug state: %#v", snapshot) } @@ -276,7 +276,7 @@ func TestDebugSessionPauseFailurePreservesRunningStateWithoutEvent(t *testing.T) t.Fatalf("runtime pause failure was not propagated: %v", err) } - if snapshot := session.snapshot(); snapshot.State != wiredebugger.StateRunning { + if snapshot := session.Snapshot(); snapshot.State != wiredebugger.StateRunning { t.Fatalf("failed pause changed state: %#v", snapshot) } @@ -290,7 +290,7 @@ func TestDebugSessionPauseFailurePreservesRunningStateWithoutEvent(t *testing.T) } func TestDebugSessionCommandPanicPublishesFailureAndClosesRuntime(t *testing.T) { - runtime := &controllerDebugger{panicOn: "start"} + runtime := &boundaryDebugger{panicOn: "start"} session := newTestCoreDebugSession(t, runtime, 1) subscription, err := session.Watch() if err != nil { @@ -314,7 +314,7 @@ func TestDebugSessionCommandPanicPublishesFailureAndClosesRuntime(t *testing.T) t.Fatalf("runtime panic did not commit an internal failure: %#v", settled) } - waitControllerCalls(t, runtime, []string{"start", "close"}) + waitDebuggerCalls(t, runtime, []string{"start", "close"}) if _, err := session.Continue(context.Background()); !hasCategory(err, ErrorKindInvalidState) { t.Fatalf("poisoned session accepted another command: %v", err) } @@ -368,7 +368,7 @@ func TestDebugSessionSynchronousPanicPoisonsAndClosesRuntime(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - runtime := &controllerDebugger{panicOn: test.panicOn} + runtime := &boundaryDebugger{panicOn: test.panicOn} session := newTestCoreDebugSession(t, runtime, 1) session.state.status = test.state subscription, err := session.Watch() @@ -393,7 +393,7 @@ func TestDebugSessionSynchronousPanicPoisonsAndClosesRuntime(t *testing.T) { t.Fatalf("runtime panic did not publish a terminal failure: %#v", failed) } - waitControllerCalls(t, runtime, []string{test.panicOn, "close"}) + waitDebuggerCalls(t, runtime, []string{test.panicOn, "close"}) if err := test.call(session); !hasCategory(err, ErrorKindInvalidState) { t.Fatalf("poisoned session accepted another operation: %v", err) } @@ -438,8 +438,8 @@ func TestDebugSessionStoppedOperationsSerializeWithoutHoldingStateLock(t *testin }() <-entered - snapshotResult := make(chan DebugSessionRecord, 1) - go func() { snapshotResult <- session.snapshot() }() + snapshotResult := make(chan wiredebugger.Snapshot, 1) + go func() { snapshotResult <- session.Snapshot() }() select { case snapshot := <-snapshotResult: if snapshot.State != wiredebugger.StateCreated { @@ -575,7 +575,7 @@ func TestDebugSessionCloseReachesRuntimeDuringBlockedStoppedOperation(t *testing if err := <-closeResult; err != nil { t.Fatal(err) } - if snapshot := session.snapshot(); snapshot.State != wiredebugger.StateTerminated { + if snapshot := session.Snapshot(); snapshot.State != wiredebugger.StateTerminated { t.Fatalf("close did not commit terminal state: %#v", snapshot) } } @@ -593,12 +593,12 @@ func receiveDebugEvent(t *testing.T, events <-chan wiredebugger.Event) wiredebug } } -func waitCoreDebugState(t *testing.T, session *DebugSession, state wiredebugger.State) DebugSessionRecord { +func waitCoreDebugState(t *testing.T, session *DebugSession, state wiredebugger.State) wiredebugger.Snapshot { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - snapshot := session.snapshot() + snapshot := session.Snapshot() if snapshot.State == state { return snapshot } @@ -608,10 +608,10 @@ func waitCoreDebugState(t *testing.T, session *DebugSession, state wiredebugger. t.Fatalf("debug session did not reach state %d", state) - return DebugSessionRecord{} + return wiredebugger.Snapshot{} } -func waitControllerCalls(t *testing.T, runtime *controllerDebugger, want []string) { +func waitDebuggerCalls(t *testing.T, runtime *boundaryDebugger, want []string) { t.Helper() deadline := time.Now().Add(5 * time.Second) diff --git a/server/internal/core/debug_snapshot.go b/server/internal/core/debug_snapshot.go index ee76d1f..66987d0 100644 --- a/server/internal/core/debug_snapshot.go +++ b/server/internal/core/debug_snapshot.go @@ -6,13 +6,6 @@ import ( "github.com/MontFerret/wire/pkg/failure" ) -// DebugSessionRecord combines server-private identity with a shared semantic -// snapshot. Registry and parent metadata never enters the shared model. -type DebugSessionRecord struct { - ID DebugSessionID - wiredebugger.Snapshot -} - func cloneDebugSnapshot(snapshot wiredebugger.Snapshot) wiredebugger.Snapshot { result := snapshot result.HitBreakpointIDs = append(result.HitBreakpointIDs[:0:0], snapshot.HitBreakpointIDs...) diff --git a/server/internal/core/debug_state.go b/server/internal/core/debug_state.go index 103d212..e316f72 100644 --- a/server/internal/core/debug_state.go +++ b/server/internal/core/debug_state.go @@ -35,17 +35,14 @@ func (s *debugSessionState) terminate() { s.failure = nil } -func (s *debugSessionState) snapshot(id DebugSessionID) DebugSessionRecord { - return DebugSessionRecord{ - ID: id, - Snapshot: cloneDebugSnapshot(wiredebugger.Snapshot{ - State: s.status, - StopReason: s.reason, - Location: s.location, - HitBreakpointIDs: s.hitIDs, - Depth: s.depth, - Output: s.output, - Failure: s.failure, - }), - } +func (s *debugSessionState) snapshot() wiredebugger.Snapshot { + return cloneDebugSnapshot(wiredebugger.Snapshot{ + State: s.status, + StopReason: s.reason, + Location: s.location, + HitBreakpointIDs: s.hitIDs, + Depth: s.depth, + Output: s.output, + Failure: s.failure, + }) } diff --git a/server/internal/core/debug_state_test.go b/server/internal/core/debug_state_test.go index 635bb73..6b72939 100644 --- a/server/internal/core/debug_state_test.go +++ b/server/internal/core/debug_state_test.go @@ -21,7 +21,7 @@ func TestDebugSessionStateBuildsDefensiveSnapshots(t *testing.T) { failure: &failure.Failure{Category: failure.CategoryExecution, Message: "runtime operation failed"}, } - snapshot := state.snapshot("session") + snapshot := state.snapshot() state.hitIDs[0] = 2 state.output.Content[0] = '2' if snapshot.HitBreakpointIDs[0] != 1 || string(snapshot.Output.Content) != "1" { @@ -31,8 +31,8 @@ func TestDebugSessionStateBuildsDefensiveSnapshots(t *testing.T) { snapshot.HitBreakpointIDs[0] = 3 snapshot.Output.Content[0] = '3' - retained := state.snapshot("session") - if retained.ID != "session" || retained.State != wiredebugger.StateStopped || retained.StopReason != debugger.ReasonBreakpoint || + retained := state.snapshot() + if retained.State != wiredebugger.StateStopped || retained.StopReason != debugger.ReasonBreakpoint || retained.Location == nil || *retained.Location != *state.location || retained.Location == state.location || retained.Depth != 2 { t.Fatalf("unexpected debug snapshot: %#v", retained) } diff --git a/server/internal/core/debugger.go b/server/internal/core/debugger.go deleted file mode 100644 index 8b1a618..0000000 --- a/server/internal/core/debugger.go +++ /dev/null @@ -1,126 +0,0 @@ -package core - -import ( - "context" - "errors" - "fmt" - - "github.com/MontFerret/api/debugger" - "github.com/MontFerret/wire/server/internal/panicboundary" - "github.com/google/uuid" -) - -// Debugger owns debugger-session creation and ownership-checked lookup. -type Debugger struct { - plans *PlanRegistry - sessions *DebugSessionRegistry -} - -func NewDebugger(plans *PlanRegistry, sessions *DebugSessionRegistry) *Debugger { - return &Debugger{plans: plans, sessions: sessions} -} - -func (d *Debugger) Create(ctx *Context, input OpenDebugInput) (DebugSessionRecord, error) { - connection := ctx.Connection() - if err := connection.beginOperation(); err != nil { - return DebugSessionRecord{}, err - } - - defer connection.finishOperation() - - if err := ctx.Err(); err != nil { - return DebugSessionRecord{}, err - } - - if err := validateID(input.PlanID, "plan ID"); err != nil { - return DebugSessionRecord{}, err - } - - owner := connection.ID() - if err := d.sessions.reserve(owner); err != nil { - return DebugSessionRecord{}, err - } - - reserved := true - defer func() { - if reserved { - d.sessions.rollback(owner) - } - }() - - plan, err := d.plans.beginChild(owner, input.PlanID, true) - if err != nil { - return DebugSessionRecord{}, err - } - - defer plan.finishChildCreation() - - options := apiSessionOptions(input.Parameters, input.OutputContentType) - runtimeDebugger, err := panicboundary.Call(func() (debugger.Session, error) { - return plan.plan.NewDebugSession(ctx, options...) - }) - if err != nil { - var panicErr *panicboundary.Error - if errors.As(err, &panicErr) { - return DebugSessionRecord{}, internalError(fmt.Errorf("create runtime debug session: %w", err)) - } - } - - var controller *DebugController - if !isNil(runtimeDebugger) { - controller = newDebugController(runtimeDebugger) - } - - if err != nil { - if controller != nil { - return DebugSessionRecord{}, errors.Join(internalError(err), controller.Close()) - } - - return DebugSessionRecord{}, internalError(err) - } - - if controller == nil { - return DebugSessionRecord{}, internalError(errors.New("runtime returned no debug session")) - } - - if err := ctx.Err(); err != nil { - return DebugSessionRecord{}, errors.Join(err, controller.Close()) - } - - debugCtx, cancel := context.WithCancelCause(connection.Context()) - created := newDebugSession( - DebugSessionID(uuid.NewString()), - owner, - plan.id, - controller, - debugCtx, - cancel, - d.sessions.maxWatchers, - d.sessions.maxBreakpoints, - ) - - err = d.plans.commitChild(owner, input.PlanID, plan, func() error { - if err := ctx.Err(); err != nil { - return err - } - - return d.sessions.commit(created) - }) - if err != nil { - cancel(context.Canceled) - - return DebugSessionRecord{}, errors.Join(err, controller.Close()) - } - - reserved = false - - return created.snapshot(), nil -} - -func (d *Debugger) Session(ctx *Context, id DebugSessionID) (*DebugSession, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - - return d.sessions.get(ctx.connectionID(), id) -} diff --git a/server/internal/core/domain_error.go b/server/internal/core/domain_error.go index 73590da..46e502e 100644 --- a/server/internal/core/domain_error.go +++ b/server/internal/core/domain_error.go @@ -1,5 +1,7 @@ package core +import "github.com/MontFerret/wire/pkg/failure" + type ( ErrorKind uint8 @@ -43,3 +45,33 @@ func (e *DomainError) Error() string { func (e *DomainError) Unwrap() error { return e.Cause } + +// Category returns the shared Wire failure category; transport-native conditions have none. +func (e *DomainError) Category() failure.Category { + switch e.Kind { + case ErrorKindCompilation: + return failure.CategoryCompilation + case ErrorKindExecution: + return failure.CategoryExecution + case ErrorKindPlanNotFound: + return failure.CategoryPlanNotFound + case ErrorKindExecutionNotFound: + return failure.CategoryExecutionNotFound + case ErrorKindDebugSessionNotFound: + return failure.CategoryDebugSessionNotFound + case ErrorKindConnectionNotFound: + return failure.CategoryConnectionNotFound + case ErrorKindInvalidState: + return failure.CategoryInvalidState + case ErrorKindWatcherLagged: + return failure.CategoryWatcherLagged + case ErrorKindBreakpointNotFound: + return failure.CategoryBreakpointNotFound + case ErrorKindInternal: + return failure.CategoryInternalRuntime + case ErrorKindSessionNotFound: + return failure.CategorySessionNotFound + default: + return 0 + } +} diff --git a/server/internal/core/errors.go b/server/internal/core/errors.go index e33caf2..c873f65 100644 --- a/server/internal/core/errors.go +++ b/server/internal/core/errors.go @@ -46,11 +46,12 @@ func failureFromError(category failure.Category, err error) *failure.Failure { return &failure.Failure{ Category: category, Message: "runtime operation failed", - Diagnostics: diagnosticsFromError(err), + Diagnostics: DiagnosticsFromError(err), } } -func diagnosticsFromError(err error) diagnostics.Diagnostics { +// DiagnosticsFromError extracts and detaches only canonical diagnostics from an error chain. +func DiagnosticsFromError(err error) diagnostics.Diagnostics { if err == nil { return nil } diff --git a/server/internal/core/event_stream_benchmark_test.go b/server/internal/core/event_stream_benchmark_test.go index 966089f..c956631 100644 --- a/server/internal/core/event_stream_benchmark_test.go +++ b/server/internal/core/event_stream_benchmark_test.go @@ -41,7 +41,6 @@ func BenchmarkExecutionEventPublication(b *testing.B) { func newPublicationBenchmarkExecution() *Execution { return &Execution{ id: ExecutionID("execution"), - planID: PlanID("plan"), state: execution.StateRunning, events: newEventStream(1, cloneExecutionEvent, sequenceExecutionEvent), done: make(chan struct{}), diff --git a/server/internal/core/execution.go b/server/internal/core/execution.go index f36d33c..2d20906 100644 --- a/server/internal/core/execution.go +++ b/server/internal/core/execution.go @@ -10,77 +10,45 @@ 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" ) type Execution struct { - mu sync.Mutex - id ExecutionID - owner ConnectionID - planID PlanID - sessionID SessionID - plan api.Plan - operation func(context.Context) (api.Output, error) - ctx context.Context - cancel context.CancelCauseFunc - parameters map[string]any - contentType string - state wireexecution.State - output *api.Output - failure *failure.Failure - events *eventStream[wireexecution.Event] - done chan struct{} - close lifecycle.Close - release lifecycle.Close + mu sync.Mutex + id ExecutionID + store *ResourceStore + plan *Plan + session *Session + operation func(context.Context) (api.Output, error) + ctx context.Context + cancel context.CancelCauseFunc + options []api.SessionOption + state wireexecution.State + output *api.Output + failure *failure.Failure + events *eventStream[wireexecution.Event] + done chan struct{} + release lifecycle.Close } -func newExecution( - id ExecutionID, - owner ConnectionID, - planID PlanID, - plan api.Plan, - ctx context.Context, - cancel context.CancelCauseFunc, - input ExecuteInput, - maxWatchers int, -) *Execution { - execution := &Execution{ - id: id, - owner: owner, - planID: planID, - plan: plan, - ctx: ctx, - cancel: cancel, - parameters: cloneParameters(input.Parameters), - contentType: input.OutputContentType, - state: wireexecution.StateRunning, - events: newEventStream(maxWatchers, cloneExecutionEvent, sequenceExecutionEvent), - done: make(chan 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 } - execution.publishLocked(false) - - return execution -} -func newOperationExecution( - id ExecutionID, - owner ConnectionID, - planID PlanID, - sessionID SessionID, - ctx context.Context, - cancel context.CancelCauseFunc, - operation func(context.Context) (api.Output, error), - maxWatchers int, -) *Execution { + ctx, cancel := context.WithCancelCause(lifetime) execution := &Execution{ - id: id, - owner: owner, - planID: planID, - sessionID: sessionID, + id: ExecutionID(uuid.NewString()), + store: store, + plan: plan, + session: session, operation: operation, + options: options, ctx: ctx, cancel: cancel, state: wireexecution.StateRunning, - events: newEventStream(maxWatchers, cloneExecutionEvent, sequenceExecutionEvent), + events: newEventStream(store.limits.Watchers, cloneExecutionEvent, sequenceExecutionEvent), done: make(chan struct{}), } execution.publishLocked(false) @@ -88,6 +56,33 @@ func newOperationExecution( return execution } +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() + } + + return e.release.Wait(ctx) +} + +func (e *Execution) settleRelease() { + var err error + defer func() { + if recover() != nil { + err = errors.Join(err, internalError(errors.New("execution release panicked"))) + } + + e.store.removeExecution(e) + e.release.Finish(err) + }() + + e.cancel(context.Canceled) + <-e.done + e.events.close() +} + func (e *Execution) run() { if e.operation != nil { e.runOperation() @@ -95,9 +90,8 @@ func (e *Execution) run() { return } - options := apiSessionOptions(e.parameters, e.contentType) session, err := panicboundary.Call(func() (api.Session, error) { - return e.plan.NewSession(e.ctx, options...) + return e.plan.plan.NewSession(e.ctx, e.options...) }) if err != nil { if !isNil(session) { @@ -121,7 +115,10 @@ func (e *Execution) run() { closeErr := closeAPISession(session) err = errors.Join(runErr, closeErr) - result := &api.Output{ContentType: output.ContentType, Content: append([]byte(nil), output.Content...)} + result := &api.Output{ + ContentType: output.ContentType, + Content: append([]byte(nil), output.Content...), + } var panicErr *panicboundary.Error if errors.As(runErr, &panicErr) { result = nil @@ -137,7 +134,10 @@ func (e *Execution) run() { func (e *Execution) runOperation() { output, runErr := e.operation(e.ctx) - result := &api.Output{ContentType: output.ContentType, Content: append([]byte(nil), output.Content...)} + result := &api.Output{ + ContentType: output.ContentType, + Content: append([]byte(nil), output.Content...), + } category := failure.CategoryExecution var domain *DomainError @@ -172,7 +172,7 @@ func (e *Execution) finish(output *api.Output, err error, category failure.Categ } } -func (e *Execution) Cancel() ExecutionRecord { +func (e *Execution) Cancel() wireexecution.Snapshot { e.cancel(context.Canceled) return e.Snapshot() @@ -182,30 +182,7 @@ func (e *Execution) ID() ExecutionID { return e.id } -func (e *Execution) Close(ctx context.Context) error { - if e.close.Begin() { - go e.settleClose() - } - - return e.close.Wait(ctx) -} - -func (e *Execution) settleClose() { - var err error - defer func() { - if recover() != nil { - err = errors.Join(err, internalError(errors.New("execution cleanup panicked"))) - } - - e.close.Finish(err) - }() - - e.cancel(context.Canceled) - <-e.done - e.events.close() -} - -func (e *Execution) Snapshot() ExecutionRecord { +func (e *Execution) Snapshot() wireexecution.Snapshot { e.mu.Lock() defer e.mu.Unlock() @@ -226,19 +203,16 @@ func (e *Execution) Watch() (ExecutionSubscription, error) { }, nil } -func (e *Execution) snapshotLocked() ExecutionRecord { - return ExecutionRecord{ - ID: e.id, - Snapshot: cloneExecutionSnapshot(wireexecution.Snapshot{ - State: e.state, - Output: e.output, - Failure: e.failure, - }), - } +func (e *Execution) snapshotLocked() wireexecution.Snapshot { + return cloneExecutionSnapshot(wireexecution.Snapshot{ + State: e.state, + Output: e.output, + Failure: e.failure, + }) } func (e *Execution) publishLocked(terminal bool) { - e.events.publish(wireexecution.Event{Snapshot: e.snapshotLocked().Snapshot}, terminal) + e.events.publish(wireexecution.Event{Snapshot: e.snapshotLocked()}, terminal) if terminal { close(e.done) diff --git a/server/internal/core/executor_benchmark_test.go b/server/internal/core/execution_benchmark_test.go similarity index 75% rename from server/internal/core/executor_benchmark_test.go rename to server/internal/core/execution_benchmark_test.go index c1c3f56..f8cbf4c 100644 --- a/server/internal/core/executor_benchmark_test.go +++ b/server/internal/core/execution_benchmark_test.go @@ -7,7 +7,7 @@ import ( "github.com/MontFerret/api" ) -func BenchmarkExecutorCancelExecution(b *testing.B) { +func BenchmarkCancelExecution(b *testing.B) { plan := &spyPlan{newSession: func(context.Context, sessionOptions) (api.Session, error) { return &spySession{run: func(context.Context) (api.Output, error) { return api.Output{}, nil @@ -23,15 +23,15 @@ func BenchmarkExecutorCancelExecution(b *testing.B) { if err != nil { b.Fatal(err) } - compiled, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { b.Fatal(err) } - retained, err := connection.executions.lookup(execution.ID) + retained, err := connection.resources.Execution(context.Background(), execution.ID) if err != nil { b.Fatal(err) } @@ -47,7 +47,7 @@ func BenchmarkExecutorCancelExecution(b *testing.B) { defer cancel() b.ResetTimer() for b.Loop() { - retained, err := connection.host.executor.Execution(operation, execution.ID) + retained, err := connection.resources.Execution(operation, execution.ID) if err != nil { b.Fatal(err) } @@ -56,7 +56,7 @@ func BenchmarkExecutorCancelExecution(b *testing.B) { } } -func BenchmarkExecutorRunDurableSession(b *testing.B) { +func BenchmarkRunDurableSession(b *testing.B) { plan := &spyPlan{newSession: func(context.Context, sessionOptions) (api.Session, error) { return &spySession{run: func(context.Context) (api.Output, error) { return api.Output{ContentType: "text/plain", Content: []byte("ok")}, nil @@ -72,11 +72,11 @@ func BenchmarkExecutorRunDurableSession(b *testing.B) { if err != nil { b.Fatal(err) } - compiled, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), CreateSessionInput{PlanID: compiled.ID}) + session, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}) if err != nil { b.Fatal(err) } @@ -93,7 +93,7 @@ func BenchmarkExecutorRunDurableSession(b *testing.B) { if err != nil { b.Fatal(err) } - retained, err := connection.executions.lookup(run.ID) + retained, err := connection.resources.Execution(context.Background(), run.ID) if err != nil { b.Fatal(err) } diff --git a/server/internal/core/execution_input.go b/server/internal/core/execution_input.go deleted file mode 100644 index 6f2ad0a..0000000 --- a/server/internal/core/execution_input.go +++ /dev/null @@ -1,17 +0,0 @@ -package core - -import ( - "github.com/MontFerret/api" -) - -type ExecuteInput struct { - PlanID PlanID - Parameters map[string]any - OutputContentType string -} - -type RunInput struct { - Source api.Source - Parameters map[string]any - OutputContentType string -} diff --git a/server/internal/core/execution_registry.go b/server/internal/core/execution_registry.go deleted file mode 100644 index 1c8a866..0000000 --- a/server/internal/core/execution_registry.go +++ /dev/null @@ -1,207 +0,0 @@ -package core - -import "sync" - -// ExecutionRegistry owns execution storage, ownership indexes, and accounting. -type ExecutionRegistry struct { - mu sync.RWMutex - max int - maxWatchers int - pending map[ConnectionID]int - active map[ExecutionID]*Execution - closing map[ExecutionID]*Execution - byOwner map[ConnectionID]map[ExecutionID]*Execution - byPlan map[PlanID]map[ExecutionID]*Execution - bySession map[SessionID]map[ExecutionID]*Execution -} - -func NewExecutionRegistry(maxExecutionsPerConnection, maxWatchers int) *ExecutionRegistry { - return &ExecutionRegistry{ - max: maxExecutionsPerConnection, - maxWatchers: maxWatchers, - pending: make(map[ConnectionID]int), - active: make(map[ExecutionID]*Execution), - closing: make(map[ExecutionID]*Execution), - byOwner: make(map[ConnectionID]map[ExecutionID]*Execution), - byPlan: make(map[PlanID]map[ExecutionID]*Execution), - bySession: make(map[SessionID]map[ExecutionID]*Execution), - } -} - -func (r *ExecutionRegistry) reserve(owner ConnectionID) error { - r.mu.Lock() - defer r.mu.Unlock() - - if r.pending[owner]+len(r.byOwner[owner]) >= r.max { - return resourceExhausted("execution limit reached") - } - - r.pending[owner]++ - - return nil -} - -func (r *ExecutionRegistry) rollback(owner ConnectionID) { - r.mu.Lock() - r.pending[owner]-- - if r.pending[owner] == 0 { - delete(r.pending, owner) - } - r.mu.Unlock() -} - -func (r *ExecutionRegistry) commit(execution *Execution) error { - r.mu.Lock() - defer r.mu.Unlock() - - r.pending[execution.owner]-- - if r.pending[execution.owner] == 0 { - delete(r.pending, execution.owner) - } - - if r.active[execution.id] != nil || r.closing[execution.id] != nil { - return invalidState("execution ID is already registered", nil) - } - - r.active[execution.id] = execution - owned := r.byOwner[execution.owner] - if owned == nil { - owned = make(map[ExecutionID]*Execution) - r.byOwner[execution.owner] = owned - } - - owned[execution.id] = execution - if execution.planID != "" { - children := r.byPlan[execution.planID] - if children == nil { - children = make(map[ExecutionID]*Execution) - r.byPlan[execution.planID] = children - } - - children[execution.id] = execution - } - - if execution.sessionID != "" { - children := r.bySession[execution.sessionID] - if children == nil { - children = make(map[ExecutionID]*Execution) - r.bySession[execution.sessionID] = children - } - - children[execution.id] = execution - } - - return nil -} - -func (r *ExecutionRegistry) get(owner ConnectionID, id ExecutionID) (*Execution, error) { - if err := validateID(id, "execution ID"); err != nil { - return nil, err - } - - r.mu.RLock() - execution := r.active[id] - if execution != nil && execution.owner != owner { - execution = nil - } - r.mu.RUnlock() - - if execution == nil { - return nil, notFound(ErrorKindExecutionNotFound, string(id)) - } - - return execution, nil -} - -func (r *ExecutionRegistry) beginClose(owner ConnectionID, id ExecutionID) (*Execution, bool, error) { - if err := validateID(id, "execution ID"); err != nil { - return nil, false, err - } - - r.mu.Lock() - execution := r.active[id] - started := false - if execution != nil && execution.owner == owner { - delete(r.active, id) - r.closing[id] = execution - started = execution.release.Begin() - } else { - execution = r.closing[id] - if execution != nil && execution.owner != owner { - execution = nil - } - } - r.mu.Unlock() - - if execution == nil { - return nil, false, notFound(ErrorKindExecutionNotFound, string(id)) - } - - return execution, started, nil -} - -func (r *ExecutionRegistry) remove(execution *Execution) { - r.mu.Lock() - if r.closing[execution.id] == execution { - delete(r.closing, execution.id) - delete(r.byOwner[execution.owner], execution.id) - - if len(r.byOwner[execution.owner]) == 0 { - delete(r.byOwner, execution.owner) - } - - if execution.planID != "" { - delete(r.byPlan[execution.planID], execution.id) - - if len(r.byPlan[execution.planID]) == 0 { - delete(r.byPlan, execution.planID) - } - } - - if execution.sessionID != "" { - delete(r.bySession[execution.sessionID], execution.id) - - if len(r.bySession[execution.sessionID]) == 0 { - delete(r.bySession, execution.sessionID) - } - } - } - r.mu.Unlock() -} - -func (r *ExecutionRegistry) listByOwner(owner ConnectionID) []ExecutionID { - r.mu.RLock() - ids := make([]ExecutionID, 0, len(r.byOwner[owner])) - for id := range r.byOwner[owner] { - ids = append(ids, id) - } - r.mu.RUnlock() - - return ids -} - -func (r *ExecutionRegistry) listByPlan(owner ConnectionID, planID PlanID) []ExecutionID { - r.mu.RLock() - ids := make([]ExecutionID, 0, len(r.byPlan[planID])) - for id, execution := range r.byPlan[planID] { - if execution.owner == owner { - ids = append(ids, id) - } - } - r.mu.RUnlock() - - return ids -} - -func (r *ExecutionRegistry) listBySession(owner ConnectionID, sessionID SessionID) []ExecutionID { - r.mu.RLock() - ids := make([]ExecutionID, 0, len(r.bySession[sessionID])) - for id, execution := range r.bySession[sessionID] { - if execution.owner == owner { - ids = append(ids, id) - } - } - r.mu.RUnlock() - - return ids -} diff --git a/server/internal/core/execution_state.go b/server/internal/core/execution_state.go index bf2b070..df8eb80 100644 --- a/server/internal/core/execution_state.go +++ b/server/internal/core/execution_state.go @@ -6,13 +6,6 @@ import ( "github.com/MontFerret/wire/pkg/failure" ) -// ExecutionRecord combines server-private identity with a shared semantic -// snapshot. Registry and parent metadata never enters the shared model. -type ExecutionRecord struct { - ID ExecutionID - execution.Snapshot -} - func cloneExecutionSnapshot(snapshot execution.Snapshot) execution.Snapshot { result := snapshot diff --git a/server/internal/core/executor.go b/server/internal/core/executor.go deleted file mode 100644 index 64fdd8a..0000000 --- a/server/internal/core/executor.go +++ /dev/null @@ -1,360 +0,0 @@ -package core - -import ( - "context" - "errors" - - "github.com/MontFerret/api" - "github.com/MontFerret/wire/server/internal/panicboundary" - "github.com/google/uuid" -) - -// Executor owns durable session and asynchronous execution creation. -type Executor struct { - runtime api.Runtime - plans *PlanRegistry - sessions *SessionRegistry - executions *ExecutionRegistry -} - -func NewExecutor( - runtime api.Runtime, - plans *PlanRegistry, - sessions *SessionRegistry, - executions *ExecutionRegistry, -) *Executor { - return &Executor{runtime: runtime, plans: plans, sessions: sessions, executions: executions} -} - -func (e *Executor) Execute(ctx *Context, input ExecuteInput) (ExecutionRecord, error) { - connection := ctx.Connection() - - if err := connection.beginOperation(); err != nil { - return ExecutionRecord{}, err - } - - defer connection.finishOperation() - - if err := ctx.Err(); err != nil { - return ExecutionRecord{}, err - } - - if err := validateID(input.PlanID, "plan ID"); err != nil { - return ExecutionRecord{}, err - } - - owner := connection.ID() - if err := e.executions.reserve(owner); err != nil { - return ExecutionRecord{}, err - } - - reserved := true - defer func() { - if reserved { - e.executions.rollback(owner) - } - }() - - plan, err := e.plans.beginChild(owner, input.PlanID, false) - if err != nil { - return ExecutionRecord{}, err - } - - defer plan.finishChildCreation() - - executionCtx, cancel := context.WithCancelCause(connection.Context()) - created := newExecution( - ExecutionID(uuid.NewString()), - owner, - plan.id, - plan.plan, - executionCtx, - cancel, - input, - e.executions.maxWatchers, - ) - - err = e.plans.commitChild(owner, input.PlanID, plan, func() error { - if err := ctx.Err(); err != nil { - return err - } - - return e.executions.commit(created) - }) - if err != nil { - cancel(context.Canceled) - - return ExecutionRecord{}, err - } - - reserved = false - - go created.run() - - return created.Snapshot(), nil -} - -func (e *Executor) Execution(ctx *Context, id ExecutionID) (*Execution, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - - return e.executions.get(ctx.connectionID(), id) -} - -func (e *Executor) CreateSession(ctx *Context, input CreateSessionInput) (SessionID, error) { - connection := ctx.Connection() - if err := connection.beginOperation(); err != nil { - return "", err - } - - defer connection.finishOperation() - - if err := ctx.Err(); err != nil { - return "", err - } - - owner := connection.ID() - if err := e.sessions.reserve(owner); err != nil { - return "", err - } - - reserved := true - defer func() { - if reserved { - e.sessions.rollback(owner) - } - }() - - plan, err := e.plans.beginChild(owner, input.PlanID, false) - if err != nil { - return "", err - } - - defer plan.finishChildCreation() - - options := apiSessionOptions(input.Parameters, input.OutputContentType) - hostedSession, err := panicboundary.Call(func() (api.Session, error) { - return plan.plan.NewSession(ctx, options...) - }) - if err != nil { - var closeErr error - if !isNil(hostedSession) { - closeErr = closeAPISession(hostedSession) - } - - if ctxErr := ctx.Err(); ctxErr != nil { - return "", errors.Join(ctxErr, closeErr) - } - - return "", errors.Join(internalError(err), closeErr) - } - - if isNil(hostedSession) { - return "", internalError(errors.New("runtime returned no session")) - } - - if err := ctx.Err(); err != nil { - return "", errors.Join(err, closeAPISession(hostedSession)) - } - - sessionCtx, cancel := context.WithCancelCause(connection.Context()) - created := newSession( - SessionID(uuid.NewString()), - owner, - plan.id, - hostedSession, - sessionCtx, - cancel, - ) - - committed := false - err = e.plans.commitChild(owner, input.PlanID, plan, func() error { - if err := ctx.Err(); err != nil { - return err - } - - committed = true - - return e.sessions.commit(created) - }) - if committed { - reserved = false - } - - if err != nil { - cancel(context.Canceled) - - return "", errors.Join(err, closeAPISession(hostedSession)) - } - - return created.id, nil -} - -func (e *Executor) RunSession(ctx *Context, id SessionID) (ExecutionRecord, error) { - connection := ctx.Connection() - if err := connection.beginOperation(); err != nil { - return ExecutionRecord{}, err - } - - defer connection.finishOperation() - - if err := ctx.Err(); err != nil { - return ExecutionRecord{}, err - } - - owner := connection.ID() - if err := e.executions.reserve(owner); err != nil { - return ExecutionRecord{}, err - } - - reserved := true - defer func() { - if reserved { - e.executions.rollback(owner) - } - }() - - candidate, err := e.sessions.get(owner, id) - if err != nil { - return ExecutionRecord{}, err - } - - plan, err := e.plans.beginChild(owner, candidate.planID, false) - if err != nil { - return ExecutionRecord{}, err - } - - defer plan.finishChildCreation() - - executionID := ExecutionID(uuid.NewString()) - session, err := e.sessions.beginExecution(owner, id, executionID) - if err != nil { - return ExecutionRecord{}, err - } - - defer session.finishExecutionCreation() - - committed := false - defer func() { - if !committed { - session.finishExecution(executionID) - } - }() - - executionCtx, cancel := context.WithCancelCause(session.Context()) - created := newOperationExecution( - executionID, - owner, - plan.id, - session.id, - executionCtx, - cancel, - session.Run, - e.executions.maxWatchers, - ) - - registryCommitted := false - err = e.plans.commitChild(owner, plan.id, plan, func() error { - return e.sessions.commitExecution(owner, id, session, executionID, func() error { - if err := ctx.Err(); err != nil { - return err - } - - registryCommitted = true - - return e.executions.commit(created) - }) - }) - if registryCommitted { - reserved = false - } - - if err != nil { - cancel(context.Canceled) - - return ExecutionRecord{}, err - } - - committed = true - snapshot := created.Snapshot() - go created.run() - - return snapshot, nil -} - -func (e *Executor) Run(ctx *Context, input RunInput) (ExecutionRecord, error) { - connection := ctx.Connection() - if err := connection.beginOperation(); err != nil { - return ExecutionRecord{}, err - } - - defer connection.finishOperation() - - if err := ctx.Err(); err != nil { - return ExecutionRecord{}, err - } - - if input.Source.Content == "" { - return ExecutionRecord{}, invalidRequest("source content is required") - } - - if input.Source.Name == "" { - input.Source.Name = "anonymous" - } - - owner := connection.ID() - if err := e.executions.reserve(owner); err != nil { - return ExecutionRecord{}, err - } - - reserved := true - defer func() { - if reserved { - e.executions.rollback(owner) - } - }() - - executionCtx, cancel := context.WithCancelCause(connection.Context()) - created := newOperationExecution( - ExecutionID(uuid.NewString()), - owner, - "", - "", - executionCtx, - cancel, - func(runCtx context.Context) (api.Output, error) { - return e.run(runCtx, input) - }, - e.executions.maxWatchers, - ) - - if err := ctx.Err(); err != nil { - cancel(context.Canceled) - - return ExecutionRecord{}, err - } - - err := e.executions.commit(created) - reserved = false - if err != nil { - cancel(context.Canceled) - - return ExecutionRecord{}, err - } - - // Capture the promised running response before fast hosted work can finish. - snapshot := created.Snapshot() - go created.run() - - return snapshot, nil -} - -func (e *Executor) run(ctx context.Context, input RunInput) (api.Output, error) { - options := apiSessionOptions(input.Parameters, input.OutputContentType) - output, err := panicboundary.Call(func() (api.Output, error) { - return e.runtime.Run(ctx, input.Source, options...) - }) - - return output, runtimePanicError("run hosted runtime", err) -} diff --git a/server/internal/core/fixture_limits_test.go b/server/internal/core/fixture_limits_test.go index b9489e4..00def93 100644 --- a/server/internal/core/fixture_limits_test.go +++ b/server/internal/core/fixture_limits_test.go @@ -9,3 +9,11 @@ type fixtureLimits struct { MaxWatchersPerResource int MaxBreakpointsPerDebugSession int } + +func (l fixtureLimits) resources() ResourceLimits { + return ResourceLimits{ + Plans: l.MaxPlansPerConnection, Sessions: l.MaxSessionsPerConnection, + Executions: l.MaxExecutionsPerConnection, DebugSessions: l.MaxDebugSessionsPerConnection, + Watchers: l.MaxWatchersPerResource, Breakpoints: l.MaxBreakpointsPerDebugSession, + } +} diff --git a/server/internal/core/lifecycle.go b/server/internal/core/lifecycle.go deleted file mode 100644 index c1a17e7..0000000 --- a/server/internal/core/lifecycle.go +++ /dev/null @@ -1,245 +0,0 @@ -package core - -import ( - "context" - "errors" -) - -// Lifecycle coordinates teardown that spans registry and resource boundaries. -type Lifecycle struct { - connections *ConnectionRegistry - plans *PlanRegistry - sessions *SessionRegistry - executions *ExecutionRegistry - debug *DebugSessionRegistry -} - -func NewLifecycle( - connections *ConnectionRegistry, - plans *PlanRegistry, - sessions *SessionRegistry, - executions *ExecutionRegistry, - debug *DebugSessionRegistry, -) *Lifecycle { - return &Lifecycle{ - connections: connections, - plans: plans, - sessions: sessions, - executions: executions, - debug: debug, - } -} - -func (l *Lifecycle) ReleaseExecution(ctx *Context, id ExecutionID) error { - return l.releaseExecution(ctx, ctx.connectionID(), id) -} - -func (l *Lifecycle) releaseExecution(waiter context.Context, owner ConnectionID, id ExecutionID) error { - execution, started, err := l.executions.beginClose(owner, id) - if err != nil { - return err - } - - if started { - go l.settleExecution(execution) - } - - return execution.release.Wait(waiter) -} - -func (l *Lifecycle) settleExecution(execution *Execution) { - var err error - defer func() { - if recover() != nil { - err = errors.Join(err, internalError(errors.New("execution release panicked"))) - } - - l.executions.remove(execution) - if execution.sessionID != "" { - l.sessions.finishExecution(execution.owner, execution.sessionID, execution.id) - } - - execution.release.Finish(err) - }() - - err = execution.Close(context.Background()) -} - -func (l *Lifecycle) ReleaseSession(ctx *Context, id SessionID) error { - return l.releaseSession(ctx, ctx.connectionID(), id) -} - -func (l *Lifecycle) releaseSession(waiter context.Context, owner ConnectionID, id SessionID) error { - session, started, err := l.sessions.beginClose(owner, id) - if err != nil { - return err - } - - if started { - go l.settleSession(session) - } - - return session.waitRelease(waiter) -} - -// settleSession is the terminal boundary of a committed, detached release. -// As with the other settle operations, a Wire orchestration panic must settle -// release waiters and registry bookkeeping. It is not an external API panic; -// only calls into the hosted implementation use panicboundary. -func (l *Lifecycle) settleSession(session *Session) { - var err error - defer func() { - if recover() != nil { - err = errors.Join(err, internalError(errors.New("session release panicked"))) - } - - l.sessions.remove(session) - session.finishRelease(err) - }() - - session.waitChildCreations() - - for _, id := range l.executions.listBySession(session.owner, session.id) { - releaseErr := l.releaseExecution(context.Background(), session.owner, id) - err = errors.Join(err, ignoreMissingResource(releaseErr, ErrorKindExecutionNotFound)) - } - - err = errors.Join(err, session.Close(context.Background())) -} - -func (l *Lifecycle) ReleaseDebugSession(ctx *Context, id DebugSessionID) error { - return l.releaseDebugSession(ctx, ctx.connectionID(), id) -} - -func (l *Lifecycle) releaseDebugSession(waiter context.Context, owner ConnectionID, id DebugSessionID) error { - session, started, err := l.debug.beginClose(owner, id) - if err != nil { - return err - } - - if started { - go l.settleDebugSession(session) - } - - return session.release.Wait(waiter) -} - -func (l *Lifecycle) settleDebugSession(session *DebugSession) { - var err error - defer func() { - if recover() != nil { - err = errors.Join(err, internalError(errors.New("debug session release panicked"))) - } - - l.debug.remove(session) - session.release.Finish(err) - }() - - err = session.Close(context.Background()) -} - -func (l *Lifecycle) ReleasePlan(ctx *Context, id PlanID) error { - return l.releasePlan(ctx, ctx.connectionID(), id) -} - -func (l *Lifecycle) releasePlan(waiter context.Context, owner ConnectionID, id PlanID) error { - plan, started, err := l.plans.beginClose(owner, id) - if err != nil { - return err - } - - if started { - go l.settlePlan(plan) - } - - return plan.waitClose(waiter) -} - -func (l *Lifecycle) settlePlan(plan *Plan) { - var err error - defer func() { - if recover() != nil { - err = errors.Join(err, internalError(errors.New("plan cleanup panicked"))) - } - - l.plans.remove(plan) - plan.finishClose(err) - }() - - plan.waitChildCreations() - - for _, id := range l.executions.listByPlan(plan.owner, plan.id) { - releaseErr := l.releaseExecution(context.Background(), plan.owner, id) - err = errors.Join(err, ignoreMissingResource(releaseErr, ErrorKindExecutionNotFound)) - } - - for _, id := range l.sessions.listByPlan(plan.owner, plan.id) { - releaseErr := l.releaseSession(context.Background(), plan.owner, id) - err = errors.Join(err, ignoreMissingResource(releaseErr, ErrorKindSessionNotFound)) - } - - for _, id := range l.debug.listByPlan(plan.owner, plan.id) { - releaseErr := l.releaseDebugSession(context.Background(), plan.owner, id) - err = errors.Join(err, ignoreMissingResource(releaseErr, ErrorKindDebugSessionNotFound)) - } - - err = errors.Join(err, closeAPIPlan(plan.plan)) -} - -func (l *Lifecycle) CloseConnection(ctx context.Context, id ConnectionID) error { - connection, started, err := l.connections.beginClose(id) - if err != nil { - return err - } - - if started { - go l.settleConnection(connection) - } - - return connection.waitClose(ctx) -} - -func (l *Lifecycle) settleConnection(connection *Connection) { - var err error - defer func() { - if recover() != nil { - err = errors.Join(err, internalError(errors.New("logical connection cleanup panicked"))) - } - - l.connections.remove(connection.ID(), connection) - connection.finishClose(err) - }() - - connection.waitOperations() - owner := connection.ID() - - for _, id := range l.executions.listByOwner(owner) { - releaseErr := l.releaseExecution(context.Background(), owner, id) - err = errors.Join(err, ignoreMissingResource(releaseErr, ErrorKindExecutionNotFound)) - } - - for _, id := range l.sessions.listByOwner(owner) { - releaseErr := l.releaseSession(context.Background(), owner, id) - err = errors.Join(err, ignoreMissingResource(releaseErr, ErrorKindSessionNotFound)) - } - - for _, id := range l.debug.listByOwner(owner) { - releaseErr := l.releaseDebugSession(context.Background(), owner, id) - err = errors.Join(err, ignoreMissingResource(releaseErr, ErrorKindDebugSessionNotFound)) - } - - for _, id := range l.plans.listByOwner(owner) { - releaseErr := l.releasePlan(context.Background(), owner, id) - err = errors.Join(err, ignoreMissingResource(releaseErr, ErrorKindPlanNotFound)) - } -} - -func (l *Lifecycle) Close(ctx context.Context) error { - ids := l.connections.beginShutdown() - var result error - for _, id := range ids { - result = errors.Join(result, l.CloseConnection(ctx, id)) - } - - return result -} diff --git a/server/internal/core/lifecycle_boundary_test.go b/server/internal/core/lifecycle_boundary_test.go index 5f05031..aeed7ba 100644 --- a/server/internal/core/lifecycle_boundary_test.go +++ b/server/internal/core/lifecycle_boundary_test.go @@ -3,9 +3,6 @@ package core import ( "context" "errors" - wiredebugger "github.com/MontFerret/wire/pkg/debugger" - wireexecution "github.com/MontFerret/wire/pkg/execution" - "github.com/MontFerret/wire/pkg/failure" "reflect" "strings" "sync" @@ -15,6 +12,9 @@ import ( "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" "github.com/MontFerret/api/source" + wiredebugger "github.com/MontFerret/wire/pkg/debugger" + wireexecution "github.com/MontFerret/wire/pkg/execution" + "github.com/MontFerret/wire/pkg/failure" "github.com/MontFerret/wire/server/internal/panicboundary" ) @@ -41,11 +41,11 @@ func TestPendingCompileCountsAgainstLimitAndConnectionCloseWaits(t *testing.T) { compileResult := make(chan error, 1) go func() { - _, compileErr := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + _, compileErr := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) compileResult <- compileErr }() <-started - if _, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 2"}}); !hasCategory(err, ErrorKindResourceExhausted) { + 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) } @@ -90,18 +90,18 @@ func TestPendingDebugCreationCountsAgainstLimitAndConnectionCloseWaits(t *testin if err != nil { t.Fatal(err) } - compiled, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) if err != nil { t.Fatal(err) } openResult := make(chan error, 1) go func() { - _, openErr := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}) + _, openErr := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) openResult <- openErr }() <-started - if _, err := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { + 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) } @@ -143,7 +143,7 @@ func TestClosingPlanCountsAgainstLimitUntilCleanupSettles(t *testing.T) { if err != nil { t.Fatal(err) } - compiled, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } @@ -151,7 +151,7 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 2"}}); !hasCategory(err, ErrorKindResourceExhausted) { + 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) @@ -161,7 +161,7 @@ func TestClosingPlanCountsAgainstLimitUntilCleanupSettles(t *testing.T) { 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(), CompileInput{Source: api.Source{Content: "RETURN 2"}}); err != nil { + 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,7 +180,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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } @@ -246,28 +246,28 @@ func TestResourceLimitsAndConnectionIsolationRemainWireOwned(t *testing.T) { if _, err := host.OpenConnection(); !hasCategory(err, ErrorKindResourceExhausted) { t.Fatalf("connection limit was bypassed: %v", err) } - compiled, err := owner.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) + 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(), CompileInput{Source: api.Source{Content: "RETURN 2"}}); !hasCategory(err, ErrorKindResourceExhausted) { + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := owner.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } - if _, err := owner.Execute(context.Background(), ExecuteInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { + 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(), OpenDebugInput{PlanID: compiled.ID}) + debugSession, err := owner.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } - if _, err := owner.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { + 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(), ExecuteInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindPlanNotFound) { + if _, err := other.Execute(context.Background(), executeRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("plan crossed connection boundary: %v", err) } @@ -287,7 +287,7 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } @@ -323,12 +323,12 @@ func TestConnectionCleanupContinuesAfterRuntimeClosePanic(t *testing.T) { t.Fatal(err) } - firstSnapshot, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + firstSnapshot, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } - secondSnapshot, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 2"}}) + secondSnapshot, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}) if err != nil { t.Fatal(err) } @@ -346,7 +346,7 @@ func TestConnectionCleanupContinuesAfterRuntimeClosePanic(t *testing.T) { } for _, id := range []PlanID{firstSnapshot.ID, secondSnapshot.ID} { - if _, getErr := host.plans.get(connection.ID(), id); !hasCategory(getErr, ErrorKindPlanNotFound) { + if _, getErr := connection.resources.Plan(context.Background(), id); !hasCategory(getErr, ErrorKindPlanNotFound) { t.Fatalf("plan %s remained after connection cleanup: %v", id, getErr) } } @@ -371,7 +371,7 @@ func TestConcurrentConnectionCloseSharesResultAndThenBecomesStale(t *testing.T) if err != nil { t.Fatal(err) } - if _, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}); err != nil { + if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}); err != nil { t.Fatal(err) } @@ -427,11 +427,11 @@ func TestConnectionCloseCancelsExecutionAndReleasesWireResources(t *testing.T) { if err != nil { t.Fatal(err) } - compiled, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN BLOCK()"}}) + 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(), ExecuteInput{PlanID: compiled.ID}); err != nil { + if _, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}); err != nil { t.Fatal(err) } <-started @@ -471,11 +471,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -500,11 +500,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) + 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(), OpenDebugInput{PlanID: compiled.ID}) + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -576,16 +576,16 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}, Debuggable: true}) + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } <-executionStarted - opened, err := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}) + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -653,11 +653,11 @@ 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(), CompileInput{Source: api.Source{Name: "debug.fql", Content: "RETURN 7"}, Debuggable: true}) + 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(), OpenDebugInput{ + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{ PlanID: compiled.ID, Parameters: map[string]any{"input": int64(7)}, OutputContentType: "application/json", @@ -775,14 +775,14 @@ 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(), CompileInput{ + 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(), OpenDebugInput{PlanID: compiled.ID}) + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -870,7 +870,7 @@ func TestDebugSessionStopAndParentCascadeCloseOnce(t *testing.T) { }) } -func openTestDebugSession(t *testing.T, runtimeDebugger debugger.Session) (*testEnvironment, PlanSnapshot, DebugSessionRecord) { +func openTestDebugSession(t *testing.T, runtimeDebugger debugger.Session) (*testEnvironment, planResult, debugResult) { t.Helper() plan := &spyPlan{newDebugSession: func(context.Context, sessionOptions) (debugger.Session, error) { return runtimeDebugger, nil @@ -878,14 +878,14 @@ 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(), CompileInput{ + 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(), OpenDebugInput{PlanID: compiled.ID}) + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -893,22 +893,22 @@ func openTestDebugSession(t *testing.T, runtimeDebugger debugger.Session) (*test return connection, compiled, opened } -func waitDebugState(t *testing.T, connection *testEnvironment, id DebugSessionID, state wiredebugger.State) DebugSessionRecord { +func waitDebugState(t *testing.T, connection *testEnvironment, id DebugSessionID, state wiredebugger.State) debugResult { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - session, err := connection.debugSessions.lookup(id) + session, err := connection.resources.DebugSession(context.Background(), id) if err != nil { t.Fatal(err) } - snapshot := session.snapshot() + snapshot := session.Snapshot() if snapshot.State == state { - return snapshot + return debugResult{ID: id, Snapshot: snapshot} } time.Sleep(time.Millisecond) } t.Fatalf("debug session did not reach state %d", state) - return DebugSessionRecord{} + return debugResult{} } diff --git a/server/internal/core/plan.go b/server/internal/core/plan.go index 1a02496..6d1b8eb 100644 --- a/server/internal/core/plan.go +++ b/server/internal/core/plan.go @@ -2,70 +2,200 @@ package core import ( "context" + "errors" "sync" "github.com/MontFerret/api" + "github.com/MontFerret/api/debugger" "github.com/MontFerret/wire/server/internal/lifecycle" + "github.com/MontFerret/wire/server/internal/panicboundary" ) type Plan struct { - mu sync.Mutex - id PlanID - owner ConnectionID - plan api.Plan - parameters []string - debuggable bool - closing bool - childCreations sync.WaitGroup - release lifecycle.Close + id PlanID + store *ResourceStore + plan api.Plan + parameters []string + debuggable bool + // Child collections and creation admission are guarded by store.mu. + creating sync.WaitGroup + sessions map[SessionID]*Session + executions map[ExecutionID]*Execution + debugSessions map[DebugSessionID]*DebugSession + release lifecycle.Close } -func (p *Plan) snapshot() PlanSnapshot { - return PlanSnapshot{ID: p.id, Parameters: append([]string(nil), p.parameters...)} +func (p *Plan) ID() PlanID { + return p.id } -func (p *Plan) beginChildCreation(debug bool) error { - p.mu.Lock() - defer p.mu.Unlock() +func (p *Plan) Params() []string { + return append([]string(nil), p.parameters...) +} - if p.closing { - return notFound(ErrorKindPlanNotFound, string(p.id)) +func (p *Plan) NewSession(ctx context.Context, options ...api.SessionOption) (*Session, error) { + if err := p.store.operationError(ctx); err != nil { + return nil, err } - if debug && !p.debuggable { - return invalidState("plan was not compiled for debugging", nil) + if err := p.store.beginCreation(sessionResource, p); err != nil { + return nil, err } - p.childCreations.Add(1) + committed := false + defer func() { p.store.finishCreation(sessionResource, p, committed) }() - return nil -} + hosted, err := panicboundary.Call(func() (api.Session, error) { + return p.plan.NewSession(ctx, options...) + }) + if err != nil { + var closeErr error + if !isNil(hosted) { + closeErr = closeAPISession(hosted) + } -func (p *Plan) finishChildCreation() { - p.childCreations.Done() -} + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, errors.Join(ctxErr, closeErr) + } + + return nil, errors.Join(internalError(err), closeErr) + } + + if isNil(hosted) { + return nil, internalError(errors.New("runtime returned no session")) + } -func (p *Plan) markClosing() bool { - p.mu.Lock() - defer p.mu.Unlock() + created := newSession(p, hosted) + if err := p.store.registerSession(ctx, created); err != nil { + created.cancel(context.Canceled) - if p.closing { - return false + return nil, errors.Join(err, closeAPISession(hosted)) } - p.closing = true + committed = true - return p.release.Begin() + return created, nil } -func (p *Plan) waitChildCreations() { - p.childCreations.Wait() +func (p *Plan) Execute(ctx context.Context, options ...api.SessionOption) (*Execution, error) { + if err := p.store.operationError(ctx); err != nil { + return nil, err + } + + if err := p.store.beginCreation(executionResource, p); err != nil { + return nil, err + } + + committed := false + defer func() { p.store.finishCreation(executionResource, p, committed) }() + + created := newExecution(p.store, p, nil, nil, options) + if err := p.store.registerExecution(ctx, created); err != nil { + created.cancel(context.Canceled) + + return nil, err + } + + committed = true + go created.run() + + return created, nil } -func (p *Plan) finishClose(err error) { - p.release.Finish(err) +func (p *Plan) NewDebugSession(ctx context.Context, options ...api.SessionOption) (*DebugSession, error) { + if err := p.store.operationError(ctx); err != nil { + return nil, err + } + + if err := p.store.beginCreation(debugResource, p); err != nil { + return nil, err + } + + committed := false + defer func() { p.store.finishCreation(debugResource, p, committed) }() + + if !p.debuggable { + return nil, invalidState("plan was not compiled for debugging", nil) + } + + hosted, err := panicboundary.Call(func() (debugger.Session, error) { + return p.plan.NewDebugSession(ctx, options...) + }) + if err != nil { + var closeErr error + if !isNil(hosted) { + closeErr = closeAPIDebugSession(hosted) + } + + return nil, errors.Join(internalError(err), closeErr) + } + + if isNil(hosted) { + return nil, internalError(errors.New("runtime returned no debug session")) + } + + created := newDebugSession(p, hosted) + if err := p.store.registerDebugSession(ctx, created); err != nil { + return nil, errors.Join(err, created.Close(context.Background())) + } + + committed = true + + return created, nil } -func (p *Plan) waitClose(ctx context.Context) error { +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() + } + return p.release.Wait(ctx) } + +func (p *Plan) settleRelease() { + var err error + defer func() { + if recover() != nil { + err = errors.Join(err, internalError(errors.New("plan cleanup panicked"))) + } + + p.store.removePlan(p) + + p.release.Finish(err) + }() + + p.creating.Wait() + p.store.mu.Lock() + executions := make([]*Execution, 0, len(p.executions)) + for _, execution := range p.executions { + executions = append(executions, execution) + } + + sessions := make([]*Session, 0, len(p.sessions)) + for _, session := range p.sessions { + sessions = append(sessions, session) + } + + debugSessions := make([]*DebugSession, 0, len(p.debugSessions)) + for _, session := range p.debugSessions { + debugSessions = append(debugSessions, session) + } + + p.store.mu.Unlock() + for _, execution := range executions { + err = errors.Join(err, execution.Release(context.Background())) + } + + for _, session := range sessions { + err = errors.Join(err, session.Release(context.Background())) + } + + for _, session := range debugSessions { + err = errors.Join(err, session.Release(context.Background())) + } + + err = errors.Join(err, closeAPIPlan(p.plan)) +} diff --git a/server/internal/core/plan_lifecycle_test.go b/server/internal/core/plan_lifecycle_test.go new file mode 100644 index 0000000..2f4bd9c --- /dev/null +++ b/server/internal/core/plan_lifecycle_test.go @@ -0,0 +1,133 @@ +package core + +import ( + "context" + "errors" + "testing" + + "github.com/MontFerret/api" +) + +func TestPlanReleaseSettlesAbandonedSessionBeforeReclaimingCapacity(t *testing.T) { + ctx := testContext(t) + constructorStarted := make(chan struct{}) + finishConstructor := make(chan struct{}) + closeStarted := make(chan struct{}) + finishClose := make(chan struct{}) + closeErr := errors.New("session cleanup failed") + hosted := &spySession{close: func() error { + close(closeStarted) + select { + case <-finishClose: + case <-ctx.Done(): + return ctx.Err() + } + + return closeErr + }} + parent := &spyPlan{newSession: func(context.Context, sessionOptions) (api.Session, error) { + close(constructorStarted) + select { + case <-finishConstructor: + case <-ctx.Done(): + return nil, ctx.Err() + } + + return hosted, nil + }} + sibling := &spyPlan{newSession: func(context.Context, sessionOptions) (api.Session, error) { + return &spySession{}, nil + }} + runtime := &spyRuntime{compile: func(_ context.Context, source api.Source, _ bool) (api.Plan, error) { + if source.Name == "parent" { + return parent, nil + } + + return sibling, nil + }} + limits := testLimits().resources() + limits.Sessions = 1 + registry := NewConnectionRegistry(1, limits) + connection, err := registry.Open() + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + if err := registry.Close(testContext(t)); err != nil { + t.Error(err) + } + }) + plan, err := CompilePlan(ctx, runtime, connection.Resources(), api.Source{Name: "parent", Content: "RETURN 1"}, false) + if err != nil { + t.Fatal(err) + } + + other, err := CompilePlan(ctx, runtime, connection.Resources(), api.Source{Name: "sibling", Content: "RETURN 2"}, false) + if err != nil { + t.Fatal(err) + } + + creation := make(chan error, 1) + go func() { + _, err := plan.NewSession(ctx) + creation <- err + }() + select { + case <-constructorStarted: + case <-ctx.Done(): + t.Fatal("session constructor did not start") + } + + release := make(chan error, 1) + go func() { release <- plan.Release(ctx) }() + waitPlanClosing(t, plan) + close(finishConstructor) + select { + case <-closeStarted: + case <-ctx.Done(): + t.Fatal("rejected session was not closed") + } + + if _, err := other.NewSession(ctx); !hasCategory(err, ErrorKindResourceExhausted) { + t.Fatalf("abandoned session released its reservation before cleanup: %v", err) + } + + _, _, planCloses := parent.snapshot() + if planCloses != 0 { + t.Fatal("hosted plan closed before its abandoned session") + } + + close(finishClose) + select { + case err := <-creation: + if !hasCategory(err, ErrorKindPlanNotFound) || !errors.Is(err, closeErr) { + t.Fatalf("rejected publication lost its lookup or cleanup error: %v", err) + } + case <-ctx.Done(): + t.Fatal("abandoned session cleanup did not settle") + } + + select { + case err := <-release: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("plan release did not settle") + } + + _, closes := hosted.counts() + if closes != 1 { + t.Fatalf("abandoned session closed %d times", closes) + } + + created, err := other.NewSession(ctx) + if err != nil { + t.Fatalf("settled reservation was not reclaimed: %v", err) + } + + if err := created.Release(ctx); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/core/plan_registry.go b/server/internal/core/plan_registry.go deleted file mode 100644 index 3de5cdb..0000000 --- a/server/internal/core/plan_registry.go +++ /dev/null @@ -1,190 +0,0 @@ -package core - -import "sync" - -// PlanRegistry owns the global plan index, explicit connection ownership, and -// per-connection plan accounting. -type PlanRegistry struct { - mu sync.RWMutex - max int - pending map[ConnectionID]int - active map[PlanID]*Plan - closing map[PlanID]*Plan - byOwner map[ConnectionID]map[PlanID]*Plan -} - -func NewPlanRegistry(maxPlansPerConnection int) *PlanRegistry { - return &PlanRegistry{ - max: maxPlansPerConnection, - pending: make(map[ConnectionID]int), - active: make(map[PlanID]*Plan), - closing: make(map[PlanID]*Plan), - byOwner: make(map[ConnectionID]map[PlanID]*Plan), - } -} - -func (r *PlanRegistry) reserve(owner ConnectionID) error { - r.mu.Lock() - defer r.mu.Unlock() - - if r.pending[owner]+len(r.byOwner[owner]) >= r.max { - return resourceExhausted("plan limit reached") - } - - r.pending[owner]++ - - return nil -} - -func (r *PlanRegistry) rollback(owner ConnectionID) { - r.mu.Lock() - r.pending[owner]-- - if r.pending[owner] == 0 { - delete(r.pending, owner) - } - r.mu.Unlock() -} - -// commit consumes one reservation regardless of whether publication succeeds. -func (r *PlanRegistry) commit(plan *Plan) error { - r.mu.Lock() - defer r.mu.Unlock() - - r.pending[plan.owner]-- - if r.pending[plan.owner] == 0 { - delete(r.pending, plan.owner) - } - - if r.active[plan.id] != nil || r.closing[plan.id] != nil { - return invalidState("plan ID is already registered", nil) - } - - r.active[plan.id] = plan - owned := r.byOwner[plan.owner] - if owned == nil { - owned = make(map[PlanID]*Plan) - r.byOwner[plan.owner] = owned - } - owned[plan.id] = plan - - return nil -} - -func (r *PlanRegistry) get(owner ConnectionID, id PlanID) (*Plan, error) { - if err := validateID(id, "plan ID"); err != nil { - return nil, err - } - - r.mu.RLock() - plan := r.active[id] - if plan != nil && plan.owner != owner { - plan = nil - } - r.mu.RUnlock() - - if plan == nil { - return nil, notFound(ErrorKindPlanNotFound, string(id)) - } - - return plan, nil -} - -func (r *PlanRegistry) beginChild(owner ConnectionID, id PlanID, debug bool) (*Plan, error) { - if err := validateID(id, "plan ID"); err != nil { - return nil, err - } - - r.mu.RLock() - plan := r.active[id] - if plan == nil || plan.owner != owner { - r.mu.RUnlock() - - return nil, notFound(ErrorKindPlanNotFound, string(id)) - } - - err := plan.beginChildCreation(debug) - r.mu.RUnlock() - if err != nil { - return nil, err - } - - return plan, nil -} - -// commitChild keeps active-plan validation atomic with child publication. The -// callback must not call external code. -func (r *PlanRegistry) commitChild(owner ConnectionID, id PlanID, expected *Plan, commit func() error) error { - r.mu.RLock() - plan := r.active[id] - if plan == nil || plan != expected || plan.owner != owner { - r.mu.RUnlock() - - return notFound(ErrorKindPlanNotFound, string(id)) - } - - plan.mu.Lock() - if plan.closing { - plan.mu.Unlock() - r.mu.RUnlock() - - return notFound(ErrorKindPlanNotFound, string(id)) - } - - err := commit() - plan.mu.Unlock() - r.mu.RUnlock() - - return err -} - -func (r *PlanRegistry) beginClose(owner ConnectionID, id PlanID) (*Plan, bool, error) { - if err := validateID(id, "plan ID"); err != nil { - return nil, false, err - } - - r.mu.Lock() - plan := r.active[id] - if plan != nil && plan.owner == owner { - delete(r.active, id) - r.closing[id] = plan - } else { - plan = r.closing[id] - if plan != nil && plan.owner != owner { - plan = nil - } - } - - if plan == nil { - r.mu.Unlock() - - return nil, false, notFound(ErrorKindPlanNotFound, string(id)) - } - - started := plan.markClosing() - r.mu.Unlock() - - return plan, started, nil -} - -func (r *PlanRegistry) remove(plan *Plan) { - r.mu.Lock() - if r.closing[plan.id] == plan { - delete(r.closing, plan.id) - delete(r.byOwner[plan.owner], plan.id) - if len(r.byOwner[plan.owner]) == 0 { - delete(r.byOwner, plan.owner) - } - } - r.mu.Unlock() -} - -func (r *PlanRegistry) listByOwner(owner ConnectionID) []PlanID { - r.mu.RLock() - ids := make([]PlanID, 0, len(r.byOwner[owner])) - for id := range r.byOwner[owner] { - ids = append(ids, id) - } - r.mu.RUnlock() - - return ids -} diff --git a/server/internal/core/plan_snapshot.go b/server/internal/core/plan_snapshot.go deleted file mode 100644 index 2289489..0000000 --- a/server/internal/core/plan_snapshot.go +++ /dev/null @@ -1,6 +0,0 @@ -package core - -type PlanSnapshot struct { - ID PlanID - Parameters []string -} diff --git a/server/internal/core/registry_lifecycle_test.go b/server/internal/core/registry_lifecycle_test.go index 8dc62a7..5d01452 100644 --- a/server/internal/core/registry_lifecycle_test.go +++ b/server/internal/core/registry_lifecycle_test.go @@ -3,7 +3,6 @@ package core import ( "context" "errors" - "github.com/MontFerret/wire/pkg/execution" "reflect" "sync" "testing" @@ -11,6 +10,7 @@ import ( "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" + "github.com/MontFerret/wire/pkg/execution" ) func TestConnectionCloseIsIdempotentAndRejectsNewResources(t *testing.T) { @@ -25,7 +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(), CompileInput{ + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, }) @@ -38,15 +38,15 @@ func TestConnectionCloseIsIdempotentAndRejectsNewResources(t *testing.T) { go func() { first <- connection.Close(context.Background()) }() <-closeStarted - if _, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 2"}}); !hasCategory(err, ErrorKindInvalidState) { + if _, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 2"}}); !hasCategory(err, ErrorKindInvalidState) { t.Fatalf("compile was accepted while connection was closing: %v", err) } - if _, err := connection.Execute(context.Background(), ExecuteInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInvalidState) { + if _, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInvalidState) { t.Fatalf("execution was accepted while connection was closing: %v", err) } - if _, err := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInvalidState) { + if _, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInvalidState) { t.Fatalf("debug session was accepted while connection was closing: %v", err) } @@ -107,21 +107,21 @@ 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(), CompileInput{ + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, }) if err != nil { t.Fatal(err) } - retained, err := connection.plans.lookup(compiled.ID) + retained, err := connection.resources.Plan(context.Background(), compiled.ID) if err != nil { t.Fatal(err) } openResult := make(chan error, 1) go func() { - _, openErr := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}) + _, openErr := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) openResult <- openErr }() <-constructorStarted @@ -130,10 +130,10 @@ func TestPlanReleaseWaitsForInFlightDebugCreation(t *testing.T) { go func() { releaseResult <- connection.ReleasePlan(context.Background(), compiled.ID) }() waitPlanClosing(t, retained) - if _, err := connection.Execute(context.Background(), ExecuteInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindPlanNotFound) { + 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(), OpenDebugInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindPlanNotFound) { + if _, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindPlanNotFound) { t.Fatalf("closing plan accepted another debug session: %v", err) } @@ -202,15 +202,15 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } - retained, err := connection.plans.lookup(compiled.ID) + retained, err := connection.resources.Plan(context.Background(), compiled.ID) if err != nil { t.Fatal(err) } - execution, err := connection.Execute(context.Background(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -277,18 +277,18 @@ 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(), CompileInput{ + compiled, err := connection.Compile(context.Background(), compileRequest{ Source: api.Source{Content: "RETURN 1"}, Debuggable: true, }) if err != nil { t.Fatal(err) } - retained, err := connection.plans.lookup(compiled.ID) + retained, err := connection.resources.Plan(context.Background(), compiled.ID) if err != nil { t.Fatal(err) } - opened, err := connection.OpenDebugSession(context.Background(), OpenDebugInput{PlanID: compiled.ID}) + opened, err := connection.OpenDebugSession(context.Background(), debugRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -348,11 +348,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + execution, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -433,11 +433,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + started, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -462,11 +462,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), ExecuteInput{PlanID: compiled.ID}) + started, err := connection.Execute(context.Background(), executeRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -489,9 +489,7 @@ func waitPlanClosing(t *testing.T, plan *Plan) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - plan.mu.Lock() - closing := plan.closing - plan.mu.Unlock() + closing := plan.release.Started() if closing { return } diff --git a/server/internal/core/resource_store.go b/server/internal/core/resource_store.go new file mode 100644 index 0000000..97dc24c --- /dev/null +++ b/server/internal/core/resource_store.go @@ -0,0 +1,470 @@ +package core + +import ( + "context" + "errors" + "sync" + + "github.com/MontFerret/wire/server/internal/lifecycle" +) + +type ( + // ResourceLimits bounds one logical connection, including pending and closing resources. + ResourceLimits struct { + Plans int + Sessions int + Executions int + DebugSessions int + Watchers int + Breakpoints int + } + + // ResourceStore owns the resources of exactly one logical connection. + // mu protects membership, reservations, parent links, and release admission. + // Resource state locks must never be held when acquiring mu. No hosted call + // or cleanup wait runs under mu. + ResourceStore struct { + mu sync.Mutex + ctx context.Context + limits ResourceLimits + pending [4]int + creating sync.WaitGroup + closing bool + close lifecycle.Close + plans map[PlanID]*Plan + sessions map[SessionID]*Session + executions map[ExecutionID]*Execution + debugSessions map[DebugSessionID]*DebugSession + } + + resourceKind uint8 +) + +const ( + planResource resourceKind = iota + sessionResource + executionResource + debugResource +) + +func newResourceStore(ctx context.Context, limits ResourceLimits) *ResourceStore { + return &ResourceStore{ + ctx: ctx, + limits: limits, + plans: make(map[PlanID]*Plan), + sessions: make(map[SessionID]*Session), + executions: make(map[ExecutionID]*Execution), + debugSessions: make(map[DebugSessionID]*DebugSession), + } +} + +// operationError preserves connection-closure precedence for creation requests. +func (r *ResourceStore) operationError(ctx context.Context) error { + r.mu.Lock() + err := r.checkOpen(nil) + r.mu.Unlock() + if err != nil { + return err + } + + return ctx.Err() +} + +// beginCreation reserves capacity before external allocation and joins both +// connection and parent creation gates. finishCreation is required on every path. +func (r *ResourceStore) beginCreation(kind resourceKind, plan *Plan) error { + r.mu.Lock() + defer r.mu.Unlock() + + if err := r.checkOpen(plan); err != nil { + return err + } + + var count, limit int + var name string + switch kind { + case planResource: + count, limit, name = len(r.plans), r.limits.Plans, "plan" + case sessionResource: + count, limit, name = len(r.sessions), r.limits.Sessions, "session" + case executionResource: + count, limit, name = len(r.executions), r.limits.Executions, "execution" + case debugResource: + count, limit, name = len(r.debugSessions), r.limits.DebugSessions, "debug session" + } + + if count+r.pending[kind] >= limit { + return resourceExhausted(name + " limit reached") + } + + r.pending[kind]++ + r.creating.Add(1) + if plan != nil { + plan.creating.Add(1) + } + + return nil +} + +func (r *ResourceStore) finishCreation(kind resourceKind, plan *Plan, committed bool) { + if !committed { + r.mu.Lock() + r.pending[kind]-- + r.mu.Unlock() + } + + if plan != nil { + plan.creating.Done() + } + + r.creating.Done() +} + +// checkOpen requires mu. Publication uses the same gate as release admission. +func (r *ResourceStore) checkOpen(plan *Plan) error { + if r.closing || r.ctx.Err() != nil { + return invalidState("connection is closed", context.Canceled) + } + + if plan != nil && (r.plans[plan.id] != plan || plan.release.Started()) { + return notFound(ErrorKindPlanNotFound, string(plan.id)) + } + + return nil +} + +func (r *ResourceStore) Plan(ctx context.Context, id PlanID) (*Plan, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + if err := validateID(id, "plan ID"); err != nil { + return nil, err + } + + r.mu.Lock() + defer r.mu.Unlock() + + resource := r.plans[id] + if resource == nil || resource.release.Started() { + return nil, notFound(ErrorKindPlanNotFound, string(id)) + } + + return resource, nil +} + +func (r *ResourceStore) ReleasePlan(ctx context.Context, id PlanID) error { + if err := validateID(id, "plan ID"); err != nil { + return err + } + + r.mu.Lock() + resource := r.plans[id] + r.mu.Unlock() + if resource == nil { + return notFound(ErrorKindPlanNotFound, string(id)) + } + + return resource.Release(ctx) +} + +func (r *ResourceStore) Session(ctx context.Context, id SessionID) (*Session, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + if err := validateID(id, "session ID"); err != nil { + return nil, err + } + + r.mu.Lock() + defer r.mu.Unlock() + + resource := r.sessions[id] + if resource == nil || resource.release.Started() { + return nil, notFound(ErrorKindSessionNotFound, string(id)) + } + + return resource, nil +} + +func (r *ResourceStore) ReleaseSession(ctx context.Context, id SessionID) error { + if err := validateID(id, "session ID"); err != nil { + return err + } + + r.mu.Lock() + resource := r.sessions[id] + r.mu.Unlock() + if resource == nil { + return notFound(ErrorKindSessionNotFound, string(id)) + } + + return resource.Release(ctx) +} + +func (r *ResourceStore) Execution(ctx context.Context, id ExecutionID) (*Execution, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + if err := validateID(id, "execution ID"); err != nil { + return nil, err + } + + r.mu.Lock() + defer r.mu.Unlock() + + resource := r.executions[id] + if resource == nil || resource.release.Started() { + return nil, notFound(ErrorKindExecutionNotFound, string(id)) + } + + return resource, nil +} + +func (r *ResourceStore) ReleaseExecution(ctx context.Context, id ExecutionID) error { + if err := validateID(id, "execution ID"); err != nil { + return err + } + + r.mu.Lock() + resource := r.executions[id] + r.mu.Unlock() + if resource == nil { + return notFound(ErrorKindExecutionNotFound, string(id)) + } + + return resource.Release(ctx) +} + +func (r *ResourceStore) DebugSession(ctx context.Context, id DebugSessionID) (*DebugSession, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + if err := validateID(id, "debug session ID"); err != nil { + return nil, err + } + + r.mu.Lock() + defer r.mu.Unlock() + + resource := r.debugSessions[id] + if resource == nil || resource.release.Started() { + return nil, notFound(ErrorKindDebugSessionNotFound, string(id)) + } + + return resource, nil +} + +func (r *ResourceStore) ReleaseDebugSession(ctx context.Context, id DebugSessionID) error { + if err := validateID(id, "debug session ID"); err != nil { + return err + } + + r.mu.Lock() + resource := r.debugSessions[id] + r.mu.Unlock() + if resource == nil { + return notFound(ErrorKindDebugSessionNotFound, string(id)) + } + + return resource.Release(ctx) +} + +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() + } + + return r.close.Wait(ctx) +} + +func (r *ResourceStore) settleClose() { + var err error + defer func() { + if recover() != nil { + err = errors.Join(err, internalError(errors.New("resource cleanup panicked"))) + } + + r.close.Finish(err) + }() + + r.creating.Wait() + r.mu.Lock() + executions := make([]*Execution, 0, len(r.executions)) + for _, execution := range r.executions { + executions = append(executions, execution) + } + + sessions := make([]*Session, 0, len(r.sessions)) + for _, session := range r.sessions { + sessions = append(sessions, session) + } + + debugSessions := make([]*DebugSession, 0, len(r.debugSessions)) + for _, session := range r.debugSessions { + debugSessions = append(debugSessions, session) + } + + plans := make([]*Plan, 0, len(r.plans)) + for _, plan := range r.plans { + plans = append(plans, plan) + } + + r.mu.Unlock() + for _, execution := range executions { + err = errors.Join(err, execution.Release(context.Background())) + } + + for _, session := range sessions { + err = errors.Join(err, session.Release(context.Background())) + } + + for _, session := range debugSessions { + err = errors.Join(err, session.Release(context.Background())) + } + + for _, plan := range plans { + err = errors.Join(err, plan.Release(context.Background())) + } +} + +func (r *ResourceStore) registerPlan(ctx context.Context, p *Plan) error { + r.mu.Lock() + defer r.mu.Unlock() + + if err := ctx.Err(); err != nil { + return err + } + + if err := r.checkOpen(nil); err != nil { + return err + } + + if r.plans[p.id] != nil { + return invalidState("plan ID is already registered", nil) + } + + r.pending[planResource]-- + r.plans[p.id] = p + + return nil +} + +func (r *ResourceStore) removePlan(p *Plan) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.plans, p.id) +} + +func (r *ResourceStore) registerSession(ctx context.Context, s *Session) error { + r.mu.Lock() + defer r.mu.Unlock() + + if err := ctx.Err(); err != nil { + return err + } + + if err := r.checkOpen(s.plan); err != nil { + return err + } + + if r.sessions[s.id] != nil { + return invalidState("session ID is already registered", nil) + } + + r.pending[sessionResource]-- + r.sessions[s.id] = s + s.plan.sessions[s.id] = s + + return nil +} + +func (r *ResourceStore) removeSession(s *Session) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.sessions, s.id) + delete(s.plan.sessions, s.id) +} + +func (r *ResourceStore) registerExecution(ctx context.Context, e *Execution) error { + r.mu.Lock() + defer r.mu.Unlock() + + if err := ctx.Err(); err != nil { + return err + } + + if err := r.checkOpen(e.plan); err != nil { + return err + } + + if e.session != nil && (r.sessions[e.session.id] != e.session || e.session.release.Started() || e.session.active != e) { + return notFound(ErrorKindSessionNotFound, string(e.session.id)) + } + + if r.executions[e.id] != nil { + return invalidState("execution ID is already registered", nil) + } + + r.pending[executionResource]-- + r.executions[e.id] = e + if e.plan != nil { + e.plan.executions[e.id] = e + } + + return nil +} + +func (r *ResourceStore) removeExecution(e *Execution) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.executions, e.id) + if e.plan != nil { + delete(e.plan.executions, e.id) + } + + if e.session != nil && e.session.active == e { + e.session.active = nil + } +} + +func (r *ResourceStore) registerDebugSession(ctx context.Context, d *DebugSession) error { + r.mu.Lock() + defer r.mu.Unlock() + + if err := ctx.Err(); err != nil { + return err + } + + if err := r.checkOpen(d.plan); err != nil { + return err + } + + if r.debugSessions[d.id] != nil { + return invalidState("debug session ID is already registered", nil) + } + + r.pending[debugResource]-- + r.debugSessions[d.id] = d + d.plan.debugSessions[d.id] = d + + return nil +} + +func (r *ResourceStore) removeDebugSession(d *DebugSession) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.debugSessions, d.id) + delete(d.plan.debugSessions, d.id) +} diff --git a/server/internal/core/run.go b/server/internal/core/run.go new file mode 100644 index 0000000..6877621 --- /dev/null +++ b/server/internal/core/run.go @@ -0,0 +1,48 @@ +package core + +import ( + "context" + + "github.com/MontFerret/api" + "github.com/MontFerret/wire/server/internal/panicboundary" +) + +// Run creates an asynchronous execution of exactly one hosted Runtime.Run call. +func Run(ctx context.Context, runtime api.Runtime, store *ResourceStore, source api.Source, options ...api.SessionOption) (*Execution, error) { + if err := store.operationError(ctx); err != nil { + return nil, err + } + + if source.Content == "" { + return nil, invalidRequest("source content is required") + } + + if source.Name == "" { + source.Name = "anonymous" + } + + if err := store.beginCreation(executionResource, nil); err != nil { + return nil, err + } + + committed := false + defer func() { store.finishCreation(executionResource, nil, committed) }() + + created := newExecution(store, nil, nil, func(runCtx context.Context) (api.Output, error) { + output, err := panicboundary.Call(func() (api.Output, error) { + return runtime.Run(runCtx, source, options...) + }) + + return output, runtimePanicError("run hosted runtime", err) + }, nil) + if err := store.registerExecution(ctx, created); err != nil { + created.cancel(context.Canceled) + + return nil, err + } + + committed = true + go created.run() + + return created, nil +} diff --git a/server/internal/core/runtime_execution_test.go b/server/internal/core/runtime_execution_test.go index 57d418d..09ef48e 100644 --- a/server/internal/core/runtime_execution_test.go +++ b/server/internal/core/runtime_execution_test.go @@ -16,7 +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(), RunInput{ + run, err := connection.Run(context.Background(), runRequest{ Source: api.Source{Name: "direct.fql", Content: "RETURN @input"}, Parameters: map[string]any{"input": int64(7)}, OutputContentType: "text/plain", @@ -35,7 +35,7 @@ func TestRunUsesBorrowedRuntimeWithoutPlan(t *testing.T) { t.Fatalf("unexpected direct runtime execution: %#v", terminal) } - if ids := connection.host.plans.listByOwner(connection.ID()); len(ids) != 0 { + if ids := connection.resources.plans; len(ids) != 0 { t.Fatalf("direct Runtime.Run created Plans: %#v", ids) } @@ -60,7 +60,7 @@ func TestRunPanicIsContainedAsInternalFailure(t *testing.T) { panic("runtime secret") }} connection := newTestConnection(t, hosted) - run, err := connection.Run(context.Background(), RunInput{ + run, err := connection.Run(context.Background(), runRequest{ Source: api.Source{Content: "RETURN 1"}, }) if err != nil { @@ -87,15 +87,15 @@ func TestRunRejectsCancelledAndInvalidRequestsBeforeAllocation(t *testing.T) { connection := newTestConnection(t, hosted) cancelled, cancel := context.WithCancel(context.Background()) cancel() - if _, err := connection.Run(cancelled, RunInput{Source: api.Source{Content: "RETURN 1"}}); !errors.Is(err, context.Canceled) { + 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) } - if _, err := connection.Run(context.Background(), RunInput{}); !hasCategory(err, ErrorKindInvalidRequest) { + if _, err := connection.Run(context.Background(), runRequest{}); !hasCategory(err, ErrorKindInvalidRequest) { t.Fatalf("empty direct source was admitted: %v", err) } - if ids := connection.host.executions.listByOwner(connection.ID()); len(ids) != 0 { + if ids := connection.resources.executions; len(ids) != 0 { t.Fatalf("rejected direct runs leaked executions: %#v", ids) } } diff --git a/server/internal/core/runtime_info.go b/server/internal/core/runtime_info.go deleted file mode 100644 index df36b65..0000000 --- a/server/internal/core/runtime_info.go +++ /dev/null @@ -1,11 +0,0 @@ -package core - -import ( - "github.com/MontFerret/wire/pkg/execution" -) - -type RuntimeInfo struct { - ProtocolName string - ProtocolVersion string - RuntimeIdentity execution.Identity -} diff --git a/server/internal/core/session.go b/server/internal/core/session.go index be6fac7..1ae3b84 100644 --- a/server/internal/core/session.go +++ b/server/internal/core/session.go @@ -4,42 +4,36 @@ import ( "context" "errors" "sync" + "sync/atomic" "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 Unified API session and admits one execution at a time. +// Session owns one durable hosted session. Its execution slot remains occupied +// through execution release, including after the run reaches a terminal state. type Session struct { - mu sync.Mutex - id SessionID - owner ConnectionID - planID PlanID - session api.Session - ctx context.Context - cancel context.CancelCauseFunc - closing bool - poisoned bool - active ExecutionID - childCreations sync.WaitGroup - close lifecycle.Close - release lifecycle.Close + id SessionID + plan *Plan + session api.Session + ctx context.Context + cancel context.CancelCauseFunc + poisoned atomic.Bool + // active and creation admission are guarded by plan.store.mu. + active *Execution + creating sync.WaitGroup + release lifecycle.Close } -func newSession( - id SessionID, - owner ConnectionID, - planID PlanID, - session api.Session, - ctx context.Context, - cancel context.CancelCauseFunc, -) *Session { +func newSession(plan *Plan, hosted api.Session) *Session { + ctx, cancel := context.WithCancelCause(plan.store.ctx) + return &Session{ - id: id, - owner: owner, - planID: planID, - session: session, + id: SessionID(uuid.NewString()), + plan: plan, + session: hosted, ctx: ctx, cancel: cancel, } @@ -49,99 +43,107 @@ func (s *Session) ID() SessionID { return s.id } -func (s *Session) Context() context.Context { - return s.ctx -} +func (s *Session) Execute(ctx context.Context) (*Execution, error) { + if err := s.plan.store.operationError(ctx); err != nil { + return nil, err + } -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.mu.Lock() - s.poisoned = true - s.mu.Unlock() + r := s.plan.store + if err := r.beginCreation(executionResource, s.plan); err != nil { + return nil, err } - return output, runtimePanicError("run runtime session", err) -} + committed := false + defer func() { r.finishCreation(executionResource, s.plan, committed) }() -func (s *Session) beginExecution(id ExecutionID) error { - s.mu.Lock() - defer s.mu.Unlock() + r.mu.Lock() + if r.sessions[s.id] != s || s.release.Started() { + r.mu.Unlock() - if s.closing { - return notFound(ErrorKindSessionNotFound, string(s.id)) + return nil, notFound(ErrorKindSessionNotFound, string(s.id)) } - if s.poisoned { - return invalidState("session cannot run after a runtime panic", nil) - } + if s.poisoned.Load() { + r.mu.Unlock() - if s.active != "" { - return invalidState("session already has an active execution", nil) + return nil, invalidState("session cannot run after a runtime panic", nil) } - s.active = id - s.childCreations.Add(1) - - return nil -} - -func (s *Session) finishExecutionCreation() { - s.childCreations.Done() -} + if s.active != nil { + r.mu.Unlock() -func (s *Session) finishExecution(id ExecutionID) { - s.mu.Lock() - if s.active == id { - s.active = "" + return nil, invalidState("session already has an active execution", nil) } - s.mu.Unlock() -} + created := newExecution(r, s.plan, s, s.run, nil) + s.active = created + s.creating.Add(1) + r.mu.Unlock() + defer s.creating.Done() -func (s *Session) markClosing() bool { - s.mu.Lock() - defer s.mu.Unlock() + if err := r.registerExecution(ctx, created); err != nil { + created.cancel(context.Canceled) + r.mu.Lock() + s.active = nil + r.mu.Unlock() - if s.closing { - return false + return nil, err } - s.closing = true - s.cancel(context.Canceled) + committed = true + go created.run() - return s.release.Begin() + return created, nil } -func (s *Session) waitChildCreations() { - s.childCreations.Wait() +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) + } + + return output, runtimePanicError("run runtime session", err) } -func (s *Session) Close(ctx context.Context) error { - if s.close.Begin() { - go s.settleClose() +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() } - return s.close.Wait(ctx) + return s.release.Wait(ctx) } -func (s *Session) settleClose() { +func (s *Session) settleRelease() { var err error + r := s.plan.store defer func() { - s.close.Finish(err) - }() + if recover() != nil { + err = errors.Join(err, internalError(errors.New("session release panicked"))) + } - s.cancel(context.Canceled) - err = closeAPISession(s.session) -} + r.removeSession(s) -func (s *Session) finishRelease(err error) { - s.release.Finish(err) -} + s.release.Finish(err) + }() -func (s *Session) waitRelease(ctx context.Context) error { - return s.release.Wait(ctx) + s.creating.Wait() + r.mu.Lock() + execution := s.active + r.mu.Unlock() + if execution != nil { + err = execution.Release(context.Background()) + } + + err = errors.Join(err, closeAPISession(s.session)) } diff --git a/server/internal/core/session_input.go b/server/internal/core/session_input.go deleted file mode 100644 index 0ed1439..0000000 --- a/server/internal/core/session_input.go +++ /dev/null @@ -1,7 +0,0 @@ -package core - -type CreateSessionInput struct { - PlanID PlanID - Parameters map[string]any - OutputContentType string -} diff --git a/server/internal/core/session_lifecycle_test.go b/server/internal/core/session_lifecycle_test.go index f4c8e8a..a9439aa 100644 --- a/server/internal/core/session_lifecycle_test.go +++ b/server/internal/core/session_lifecycle_test.go @@ -25,12 +25,12 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN @input"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN @input"}}) if err != nil { t.Fatal(err) } - created, err := connection.CreateSession(context.Background(), CreateSessionInput{ + created, err := connection.CreateSession(context.Background(), sessionRequest{ PlanID: compiled.ID, Parameters: map[string]any{"input": int64(42)}, OutputContentType: "application/json", @@ -195,7 +195,7 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { if err != nil { t.Fatal(err) } - compiled, err := connection.Compile(context.Background(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } @@ -205,7 +205,7 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { err error }, 1) go func() { - session, createErr := connection.CreateSession(context.Background(), CreateSessionInput{PlanID: compiled.ID}) + session, createErr := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}) creation <- struct { session SessionID err error @@ -213,7 +213,7 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { }() <-constructorStarted - if _, err := connection.CreateSession(context.Background(), CreateSessionInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { + 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) @@ -226,13 +226,13 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { go func() { release <- connection.ReleaseSession(context.Background(), created.session) }() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - if _, err := connection.CreateSession(context.Background(), CreateSessionInput{PlanID: compiled.ID}); hasCategory(err, ErrorKindResourceExhausted) { + if _, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}); hasCategory(err, ErrorKindResourceExhausted) { break } time.Sleep(time.Millisecond) } - if _, err := connection.CreateSession(context.Background(), CreateSessionInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindResourceExhausted) { + 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) @@ -240,7 +240,7 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { t.Fatal(err) } - createdAgain, err := connection.CreateSession(context.Background(), CreateSessionInput{PlanID: compiled.ID}) + createdAgain, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}) if err != nil { t.Fatalf("settled session did not release its limit slot: %v", err) } @@ -250,7 +250,7 @@ func TestSessionLimitCountsPendingAndClosingSessions(t *testing.T) { } } -func openTestSession(t *testing.T, runtimeSession api.Session) (*testEnvironment, PlanSnapshot, SessionID) { +func openTestSession(t *testing.T, runtimeSession api.Session) (*testEnvironment, planResult, SessionID) { t.Helper() plan := &spyPlan{newSession: func(context.Context, sessionOptions) (api.Session, error) { return runtimeSession, nil @@ -258,11 +258,11 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + 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(), CreateSessionInput{PlanID: compiled.ID}) + created, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}) if err != nil { t.Fatal(err) } @@ -277,13 +277,13 @@ 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(), CompileInput{Source: api.Source{Content: "RETURN 1"}}) + compiled, err := connection.Compile(context.Background(), compileRequest{Source: api.Source{Content: "RETURN 1"}}) if err != nil { t.Fatal(err) } for range 2 { - if _, err := connection.CreateSession(context.Background(), CreateSessionInput{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInternal) { + if _, err := connection.CreateSession(context.Background(), sessionRequest{PlanID: compiled.ID}); !hasCategory(err, ErrorKindInternal) { t.Fatalf("unexpected creation failure: %v", err) } } diff --git a/server/internal/core/session_registry.go b/server/internal/core/session_registry.go deleted file mode 100644 index b4fd28b..0000000 --- a/server/internal/core/session_registry.go +++ /dev/null @@ -1,232 +0,0 @@ -package core - -import "sync" - -// SessionRegistry owns durable execution-session storage, ownership indexes, -// and per-connection accounting. -type SessionRegistry struct { - mu sync.RWMutex - max int - pending map[ConnectionID]int - active map[SessionID]*Session - closing map[SessionID]*Session - byOwner map[ConnectionID]map[SessionID]*Session - byPlan map[PlanID]map[SessionID]*Session -} - -func NewSessionRegistry(maxSessionsPerConnection int) *SessionRegistry { - return &SessionRegistry{ - max: maxSessionsPerConnection, - pending: make(map[ConnectionID]int), - active: make(map[SessionID]*Session), - closing: make(map[SessionID]*Session), - byOwner: make(map[ConnectionID]map[SessionID]*Session), - byPlan: make(map[PlanID]map[SessionID]*Session), - } -} - -func (r *SessionRegistry) reserve(owner ConnectionID) error { - r.mu.Lock() - defer r.mu.Unlock() - - if r.pending[owner]+len(r.byOwner[owner]) >= r.max { - return resourceExhausted("session limit reached") - } - - r.pending[owner]++ - - return nil -} - -func (r *SessionRegistry) rollback(owner ConnectionID) { - r.mu.Lock() - r.pending[owner]-- - if r.pending[owner] == 0 { - delete(r.pending, owner) - } - r.mu.Unlock() -} - -func (r *SessionRegistry) commit(session *Session) error { - r.mu.Lock() - defer r.mu.Unlock() - - r.pending[session.owner]-- - if r.pending[session.owner] == 0 { - delete(r.pending, session.owner) - } - - if r.active[session.id] != nil || r.closing[session.id] != nil { - return invalidState("session ID is already registered", nil) - } - - r.active[session.id] = session - owned := r.byOwner[session.owner] - if owned == nil { - owned = make(map[SessionID]*Session) - r.byOwner[session.owner] = owned - } - owned[session.id] = session - - children := r.byPlan[session.planID] - if children == nil { - children = make(map[SessionID]*Session) - r.byPlan[session.planID] = children - } - children[session.id] = session - - return nil -} - -func (r *SessionRegistry) get(owner ConnectionID, id SessionID) (*Session, error) { - if err := validateID(id, "session ID"); err != nil { - return nil, err - } - - r.mu.RLock() - session := r.active[id] - if session != nil && session.owner != owner { - session = nil - } - r.mu.RUnlock() - - if session == nil { - return nil, notFound(ErrorKindSessionNotFound, string(id)) - } - - return session, nil -} - -func (r *SessionRegistry) beginExecution(owner ConnectionID, id SessionID, executionID ExecutionID) (*Session, error) { - if err := validateID(id, "session ID"); err != nil { - return nil, err - } - - r.mu.RLock() - session := r.active[id] - if session == nil || session.owner != owner { - r.mu.RUnlock() - - return nil, notFound(ErrorKindSessionNotFound, string(id)) - } - - err := session.beginExecution(executionID) - r.mu.RUnlock() - if err != nil { - return nil, err - } - - return session, nil -} - -func (r *SessionRegistry) commitExecution( - owner ConnectionID, - id SessionID, - expected *Session, - executionID ExecutionID, - commit func() error, -) error { - r.mu.RLock() - session := r.active[id] - if session == nil || session != expected || session.owner != owner { - r.mu.RUnlock() - - return notFound(ErrorKindSessionNotFound, string(id)) - } - - session.mu.Lock() - if session.closing || session.active != executionID { - session.mu.Unlock() - r.mu.RUnlock() - - return notFound(ErrorKindSessionNotFound, string(id)) - } - - err := commit() - session.mu.Unlock() - r.mu.RUnlock() - - return err -} - -func (r *SessionRegistry) finishExecution(owner ConnectionID, id SessionID, executionID ExecutionID) { - r.mu.RLock() - session := r.active[id] - if session == nil { - session = r.closing[id] - } - if session != nil && session.owner == owner { - session.finishExecution(executionID) - } - r.mu.RUnlock() -} - -func (r *SessionRegistry) beginClose(owner ConnectionID, id SessionID) (*Session, bool, error) { - if err := validateID(id, "session ID"); err != nil { - return nil, false, err - } - - r.mu.Lock() - session := r.active[id] - if session != nil && session.owner == owner { - delete(r.active, id) - r.closing[id] = session - } else { - session = r.closing[id] - if session != nil && session.owner != owner { - session = nil - } - } - - if session == nil { - r.mu.Unlock() - - return nil, false, notFound(ErrorKindSessionNotFound, string(id)) - } - - started := session.markClosing() - r.mu.Unlock() - - return session, started, nil -} - -func (r *SessionRegistry) remove(session *Session) { - r.mu.Lock() - if r.closing[session.id] == session { - delete(r.closing, session.id) - delete(r.byOwner[session.owner], session.id) - if len(r.byOwner[session.owner]) == 0 { - delete(r.byOwner, session.owner) - } - - delete(r.byPlan[session.planID], session.id) - if len(r.byPlan[session.planID]) == 0 { - delete(r.byPlan, session.planID) - } - } - r.mu.Unlock() -} - -func (r *SessionRegistry) listByOwner(owner ConnectionID) []SessionID { - r.mu.RLock() - ids := make([]SessionID, 0, len(r.byOwner[owner])) - for id := range r.byOwner[owner] { - ids = append(ids, id) - } - r.mu.RUnlock() - - return ids -} - -func (r *SessionRegistry) listByPlan(owner ConnectionID, planID PlanID) []SessionID { - r.mu.RLock() - ids := make([]SessionID, 0, len(r.byPlan[planID])) - for id, session := range r.byPlan[planID] { - if session.owner == owner { - ids = append(ids, id) - } - } - r.mu.RUnlock() - - return ids -} diff --git a/server/internal/core/test_debug_session_registry_test.go b/server/internal/core/test_debug_session_registry_test.go deleted file mode 100644 index f441384..0000000 --- a/server/internal/core/test_debug_session_registry_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package core - -type testDebugSessionRegistry struct { - registry *DebugSessionRegistry - owner ConnectionID -} - -func (r testDebugSessionRegistry) lookup(id DebugSessionID) (*DebugSession, error) { - return r.registry.get(r.owner, id) -} diff --git a/server/internal/core/test_environment_test.go b/server/internal/core/test_environment_test.go index 6fef3e9..e7fd4a7 100644 --- a/server/internal/core/test_environment_test.go +++ b/server/internal/core/test_environment_test.go @@ -3,81 +3,137 @@ package core import ( "context" + "github.com/MontFerret/api" "github.com/MontFerret/api/debugger" "github.com/MontFerret/api/source" + wireexecution "github.com/MontFerret/wire/pkg/execution" ) type testEnvironment struct { *Connection - host *testHost - plans testPlanRegistry - executions testExecutionRegistry - debugSessions testDebugSessionRegistry + host *testHost } -func (e *testEnvironment) operation(ctx context.Context) (*Context, context.CancelFunc) { - return NewContext(ctx, e.Connection) +func (e *testEnvironment) operation(ctx context.Context) (context.Context, context.CancelFunc) { + return OperationContext(ctx, e.Context()) } -func (e *testEnvironment) Compile(ctx context.Context, input CompileInput) (PlanSnapshot, error) { +func (e *testEnvironment) Compile(ctx context.Context, input compileRequest) (planResult, error) { operation, cancel := e.operation(ctx) defer cancel() - return e.host.compiler.Compile(operation, input) + var options []api.PlanOption + if input.HasOptimizationLevel { + options = append(options, api.WithOptimizationLevel(input.OptimizationLevel)) + } + + plan, err := CompilePlan(operation, e.host.runtime, e.resources, input.Source, input.Debuggable, options...) + if err != nil { + return planResult{}, err + } + + return planResult{ID: plan.ID(), Parameters: plan.Params()}, nil } -func (e *testEnvironment) Execute(ctx context.Context, input ExecuteInput) (ExecutionRecord, error) { +func (e *testEnvironment) Execute(ctx context.Context, input executeRequest) (executionResult, error) { operation, cancel := e.operation(ctx) defer cancel() - return e.host.executor.Execute(operation, input) + if err := e.resources.operationError(operation); err != nil { + return executionResult{}, err + } + + plan, err := e.resources.Plan(operation, input.PlanID) + if err != nil { + return executionResult{}, err + } + + execution, err := plan.Execute(operation, apiSessionOptions(input.Parameters, input.OutputContentType)...) + if err != nil { + return executionResult{}, err + } + + return executionResult{ID: execution.ID(), Snapshot: execution.Snapshot()}, nil } -func (e *testEnvironment) CreateSession(ctx context.Context, input CreateSessionInput) (SessionID, error) { +func (e *testEnvironment) CreateSession(ctx context.Context, input sessionRequest) (SessionID, error) { operation, cancel := e.operation(ctx) defer cancel() - return e.host.executor.CreateSession(operation, input) + if err := e.resources.operationError(operation); err != nil { + return "", err + } + + plan, err := e.resources.Plan(operation, input.PlanID) + if err != nil { + return "", err + } + + session, err := plan.NewSession(operation, apiSessionOptions(input.Parameters, input.OutputContentType)...) + if err != nil { + return "", err + } + + return session.ID(), nil } -func (e *testEnvironment) RunSession(ctx context.Context, id SessionID) (ExecutionRecord, error) { +func (e *testEnvironment) RunSession(ctx context.Context, id SessionID) (executionResult, error) { operation, cancel := e.operation(ctx) defer cancel() - return e.host.executor.RunSession(operation, id) + if err := e.resources.operationError(operation); err != nil { + return executionResult{}, err + } + + session, err := e.resources.Session(operation, id) + if err != nil { + return executionResult{}, err + } + + execution, err := session.Execute(operation) + if err != nil { + return executionResult{}, err + } + + return executionResult{ID: execution.ID(), Snapshot: wireexecution.Snapshot{State: wireexecution.StateRunning}}, nil } -func (e *testEnvironment) Run(ctx context.Context, input RunInput) (ExecutionRecord, error) { +func (e *testEnvironment) Run(ctx context.Context, input runRequest) (executionResult, error) { operation, cancel := e.operation(ctx) defer cancel() - return e.host.executor.Run(operation, input) + execution, err := Run(operation, e.host.runtime, e.resources, input.Source, apiSessionOptions(input.Parameters, input.OutputContentType)...) + if err != nil { + return executionResult{}, err + } + + return executionResult{ID: execution.ID(), Snapshot: wireexecution.Snapshot{State: wireexecution.StateRunning}}, nil } func (e *testEnvironment) ReleaseSession(ctx context.Context, id SessionID) error { operation, cancel := e.operation(ctx) defer cancel() - return e.host.lifecycle.ReleaseSession(operation, id) + return e.resources.ReleaseSession(operation, id) } -func (e *testEnvironment) CancelExecution(id ExecutionID) (ExecutionRecord, error) { +func (e *testEnvironment) CancelExecution(id ExecutionID) (executionResult, error) { operation, cancel := e.operation(context.Background()) defer cancel() - execution, err := e.host.executor.Execution(operation, id) + execution, err := e.resources.Execution(operation, id) if err != nil { - return ExecutionRecord{}, err + return executionResult{}, err } - return execution.Cancel(), nil + return executionResult{ID: execution.ID(), Snapshot: execution.Cancel()}, nil } func (e *testEnvironment) WatchExecution(id ExecutionID) (ExecutionSubscription, error) { operation, cancel := e.operation(context.Background()) defer cancel() - execution, err := e.host.executor.Execution(operation, id) + execution, err := e.resources.Execution(operation, id) if err != nil { return ExecutionSubscription{}, err } @@ -89,19 +145,33 @@ func (e *testEnvironment) ReleaseExecution(ctx context.Context, id ExecutionID) operation, cancel := e.operation(ctx) defer cancel() - return e.host.lifecycle.ReleaseExecution(operation, id) + return e.resources.ReleaseExecution(operation, id) } -func (e *testEnvironment) OpenDebugSession(ctx context.Context, input OpenDebugInput) (DebugSessionRecord, error) { +func (e *testEnvironment) OpenDebugSession(ctx context.Context, input debugRequest) (debugResult, error) { operation, cancel := e.operation(ctx) defer cancel() - return e.host.debugger.Create(operation, input) + if err := e.resources.operationError(operation); err != nil { + return debugResult{}, err + } + + plan, err := e.resources.Plan(operation, input.PlanID) + if err != nil { + return debugResult{}, err + } + + session, err := plan.NewDebugSession(operation, apiSessionOptions(input.Parameters, input.OutputContentType)...) + if err != nil { + return debugResult{}, err + } + + return debugResult{ID: session.ID(), Snapshot: session.Snapshot()}, nil } -func (e *testEnvironment) debugSession(ctx context.Context, id DebugSessionID) (*Context, context.CancelFunc, *DebugSession, error) { +func (e *testEnvironment) debugSession(ctx context.Context, id DebugSessionID) (context.Context, context.CancelFunc, *DebugSession, error) { operation, cancel := e.operation(ctx) - session, err := e.host.debugger.Session(operation, id) + session, err := e.resources.DebugSession(operation, id) if err != nil { cancel() @@ -122,37 +192,43 @@ func (e *testEnvironment) WatchDebug(id DebugSessionID) (DebugSubscription, erro return session.Watch() } -func (e *testEnvironment) StartDebug(ctx context.Context, id DebugSessionID) (DebugSessionRecord, error) { +func (e *testEnvironment) StartDebug(ctx context.Context, id DebugSessionID) (debugResult, error) { operation, cancel, session, err := e.debugSession(ctx, id) if err != nil { - return DebugSessionRecord{}, err + return debugResult{}, err } defer cancel() - return session.Start(operation) + snapshot, err := session.Start(operation) + + return debugResult{ID: session.ID(), Snapshot: snapshot}, err } -func (e *testEnvironment) ContinueDebug(ctx context.Context, id DebugSessionID) (DebugSessionRecord, error) { +func (e *testEnvironment) ContinueDebug(ctx context.Context, id DebugSessionID) (debugResult, error) { operation, cancel, session, err := e.debugSession(ctx, id) if err != nil { - return DebugSessionRecord{}, err + return debugResult{}, err } defer cancel() - return session.Continue(operation) + snapshot, err := session.Continue(operation) + + return debugResult{ID: session.ID(), Snapshot: snapshot}, err } -func (e *testEnvironment) StopDebug(ctx context.Context, id DebugSessionID) (DebugSessionRecord, error) { +func (e *testEnvironment) StopDebug(ctx context.Context, id DebugSessionID) (debugResult, error) { operation, cancel, session, err := e.debugSession(ctx, id) if err != nil { - return DebugSessionRecord{}, err + return debugResult{}, err } defer cancel() - return session.Stop(operation) + snapshot, err := session.Stop(operation) + + return debugResult{ID: session.ID(), Snapshot: snapshot}, err } func (e *testEnvironment) SetBreakpoint( @@ -231,18 +307,18 @@ func (e *testEnvironment) ReleaseDebugSession(ctx context.Context, id DebugSessi operation, cancel := e.operation(ctx) defer cancel() - return e.host.lifecycle.ReleaseDebugSession(operation, id) + return e.resources.ReleaseDebugSession(operation, id) } func (e *testEnvironment) ReleasePlan(ctx context.Context, id PlanID) error { operation, cancel := e.operation(ctx) defer cancel() - return e.host.lifecycle.ReleasePlan(operation, id) + return e.resources.ReleasePlan(operation, id) } func (e *testEnvironment) Close(ctx context.Context) error { - err := e.host.lifecycle.CloseConnection(ctx, e.ID()) + err := e.host.connections.CloseConnection(ctx, e.ID()) if hasCategory(err, ErrorKindConnectionNotFound) && e.close.Started() { return e.waitClose(ctx) } diff --git a/server/internal/core/test_execution_registry_test.go b/server/internal/core/test_execution_registry_test.go deleted file mode 100644 index ba5b7b9..0000000 --- a/server/internal/core/test_execution_registry_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package core - -type testExecutionRegistry struct { - registry *ExecutionRegistry - owner ConnectionID -} - -func (r testExecutionRegistry) lookup(id ExecutionID) (*Execution, error) { - return r.registry.get(r.owner, id) -} diff --git a/server/internal/core/test_host_test.go b/server/internal/core/test_host_test.go index 45da2d2..dd62dbf 100644 --- a/server/internal/core/test_host_test.go +++ b/server/internal/core/test_host_test.go @@ -8,72 +8,33 @@ import ( type testHost struct { connections *ConnectionRegistry - plans *PlanRegistry - normal *SessionRegistry - executions *ExecutionRegistry - sessions *DebugSessionRegistry - compiler *Compiler - executor *Executor - debugger *Debugger - lifecycle *Lifecycle + runtime api.Runtime } func newTestHost(runtime api.Runtime, limits fixtureLimits) (*testHost, error) { - connections := NewConnectionRegistry(limits.MaxConnections) - plans := NewPlanRegistry(limits.MaxPlansPerConnection) - normal := NewSessionRegistry(limits.MaxSessionsPerConnection) - executions := NewExecutionRegistry(limits.MaxExecutionsPerConnection, limits.MaxWatchersPerResource) - sessions := NewDebugSessionRegistry( - limits.MaxDebugSessionsPerConnection, - limits.MaxWatchersPerResource, - limits.MaxBreakpointsPerDebugSession, - ) - compiler, err := NewCompiler(runtime, plans) - if err != nil { - return nil, err + if isNil(runtime) { + return nil, invalidRequest("runtime is required") } return &testHost{ - connections: connections, - plans: plans, - normal: normal, - executions: executions, - sessions: sessions, - compiler: compiler, - executor: NewExecutor(runtime, plans, normal, executions), - debugger: NewDebugger(plans, sessions), - lifecycle: NewLifecycle(connections, plans, normal, executions, sessions), + runtime: runtime, + connections: NewConnectionRegistry(limits.MaxConnections, limits.resources()), }, nil } func (h *testHost) OpenConnection() (*testEnvironment, error) { - connection := NewConnection() - if err := h.connections.Register(connection); err != nil { + connection, err := h.connections.Open() + if err != nil { return nil, err } - return &testEnvironment{ - Connection: connection, - host: h, - plans: testPlanRegistry{ - registry: h.plans, - owner: connection.ID(), - }, - executions: testExecutionRegistry{ - registry: h.executions, - owner: connection.ID(), - }, - debugSessions: testDebugSessionRegistry{ - registry: h.sessions, - owner: connection.ID(), - }, - }, nil + return &testEnvironment{Connection: connection, host: h}, nil } func (h *testHost) CloseConnection(ctx context.Context, id ConnectionID) error { - return h.lifecycle.CloseConnection(ctx, id) + return h.connections.CloseConnection(ctx, id) } func (h *testHost) Close(ctx context.Context) error { - return h.lifecycle.Close(ctx) + return h.connections.Close(ctx) } diff --git a/server/internal/core/parameters.go b/server/internal/core/test_parameters_test.go similarity index 100% rename from server/internal/core/parameters.go rename to server/internal/core/test_parameters_test.go diff --git a/server/internal/core/test_plan_registry_test.go b/server/internal/core/test_plan_registry_test.go deleted file mode 100644 index 5162e21..0000000 --- a/server/internal/core/test_plan_registry_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package core - -type testPlanRegistry struct { - registry *PlanRegistry - owner ConnectionID -} - -func (r testPlanRegistry) lookup(id PlanID) (*Plan, error) { - return r.registry.get(r.owner, id) -} diff --git a/server/internal/core/test_requests_test.go b/server/internal/core/test_requests_test.go new file mode 100644 index 0000000..e4e64a8 --- /dev/null +++ b/server/internal/core/test_requests_test.go @@ -0,0 +1,63 @@ +package core + +import ( + "github.com/MontFerret/api" + wiredebugger "github.com/MontFerret/wire/pkg/debugger" + wireexecution "github.com/MontFerret/wire/pkg/execution" +) + +type ( + compileRequest struct { + Source api.Source + Debuggable bool + OptimizationLevel api.OptimizationLevel + HasOptimizationLevel bool + } + + executeRequest struct { + PlanID PlanID + Parameters map[string]any + OutputContentType string + } + + runRequest struct { + Source api.Source + Parameters map[string]any + OutputContentType string + } + + sessionRequest struct { + PlanID PlanID + Parameters map[string]any + OutputContentType string + } + + debugRequest struct { + PlanID PlanID + Parameters map[string]any + OutputContentType string + } + + planResult struct { + ID PlanID + Parameters []string + } + + executionResult struct { + ID ExecutionID + wireexecution.Snapshot + } + debugResult struct { + ID DebugSessionID + wiredebugger.Snapshot + } +) + +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)) + } + + return options +} diff --git a/server/internal/grpcserver/compile_options.go b/server/internal/grpcserver/compile_options.go index 1ee587d..34d3008 100644 --- a/server/internal/grpcserver/compile_options.go +++ b/server/internal/grpcserver/compile_options.go @@ -30,3 +30,16 @@ func optimizationLevel(options *wirev1.CompileOptions) (api.OptimizationLevel, b return level, true, nil } + +func decodeCompileOptions(options *wirev1.CompileOptions) ([]api.PlanOption, error) { + level, present, err := optimizationLevel(options) + if err != nil { + return nil, err + } + + if !present { + return nil, nil + } + + return []api.PlanOption{api.WithOptimizationLevel(level)}, nil +} diff --git a/server/internal/grpcserver/convert.go b/server/internal/grpcserver/convert.go deleted file mode 100644 index 24b87bc..0000000 --- a/server/internal/grpcserver/convert.go +++ /dev/null @@ -1,552 +0,0 @@ -package grpcserver - -import ( - "fmt" - - "github.com/MontFerret/api" - "github.com/MontFerret/api/debugger" - "github.com/MontFerret/api/diagnostics" - "github.com/MontFerret/api/source" - wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - wiredebugger "github.com/MontFerret/wire/pkg/debugger" - wireexecution "github.com/MontFerret/wire/pkg/execution" - wirefailure "github.com/MontFerret/wire/pkg/failure" - "github.com/MontFerret/wire/server/internal/core" -) - -func protocolInfo(value core.RuntimeInfo) *wirev1.ProtocolInfo { - return &wirev1.ProtocolInfo{Name: value.ProtocolName, Version: value.ProtocolVersion} -} - -func runtimeIdentity(value wireexecution.Identity) *wirev1.RuntimeIdentity { - if value == (wireexecution.Identity{}) { - return nil - } - - return &wirev1.RuntimeIdentity{ - Name: value.Name, - Version: value.Version, - InstanceId: value.InstanceID, - } -} - -func plan(value core.PlanSnapshot) *wirev1.Plan { - return &wirev1.Plan{ - Id: &wirev1.PlanId{Value: string(value.ID)}, - Parameters: append([]string(nil), value.Parameters...), - } -} - -func output(value *api.Output) *wirev1.Output { - if value == nil { - return nil - } - - return &wirev1.Output{ContentType: value.ContentType, Content: append([]byte(nil), value.Content...)} -} - -func failure(value *wirefailure.Failure) (*wirev1.Failure, error) { - if value == nil { - return nil, nil - } - - diagnosticSet, err := diagnosticsToProto(value.Diagnostics) - if err != nil { - return nil, err - } - - category, err := failureCategory(value.Category) - if err != nil { - return nil, err - } - - return &wirev1.Failure{ - Category: category, - Message: value.Message, - DiagnosticSet: diagnosticSet, - }, nil -} - -func failureCategory(value wirefailure.Category) (wirev1.ErrorCategory, error) { - switch value { - case wirefailure.CategoryCompilation: - return wirev1.ErrorCategory_ERROR_CATEGORY_COMPILATION_FAILURE, nil - case wirefailure.CategoryExecution: - return wirev1.ErrorCategory_ERROR_CATEGORY_EXECUTION_FAILURE, nil - case wirefailure.CategoryPlanNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_PLAN_NOT_FOUND, nil - case wirefailure.CategoryExecutionNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_EXECUTION_NOT_FOUND, nil - case wirefailure.CategoryDebugSessionNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_DEBUG_SESSION_NOT_FOUND, nil - case wirefailure.CategoryConnectionNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_CONNECTION_NOT_FOUND, nil - case wirefailure.CategoryInvalidState: - return wirev1.ErrorCategory_ERROR_CATEGORY_INVALID_STATE, nil - case wirefailure.CategoryInternalRuntime: - return wirev1.ErrorCategory_ERROR_CATEGORY_INTERNAL_RUNTIME_FAILURE, nil - case wirefailure.CategoryWatcherLagged: - return wirev1.ErrorCategory_ERROR_CATEGORY_WATCHER_LAGGED, nil - case wirefailure.CategoryBreakpointNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_BREAKPOINT_NOT_FOUND, nil - case wirefailure.CategorySessionNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_SESSION_NOT_FOUND, nil - } - - return 0, runtimeConversionError("runtime returned an invalid failure category") -} - -func execution(value core.ExecutionRecord) (*wirev1.Execution, error) { - state, err := executionState(value.Snapshot.State) - if err != nil { - return nil, err - } - - convertedFailure, err := failure(value.Snapshot.Failure) - if err != nil { - return nil, err - } - - return &wirev1.Execution{ - Id: &wirev1.ExecutionId{Value: string(value.ID)}, - State: state, - Output: output(value.Snapshot.Output), - Failure: convertedFailure, - }, nil -} - -func executionState(value wireexecution.State) (wirev1.ExecutionState, error) { - switch value { - case wireexecution.StateRunning: - return wirev1.ExecutionState_EXECUTION_STATE_RUNNING, nil - case wireexecution.StateCompleted: - return wirev1.ExecutionState_EXECUTION_STATE_COMPLETED, nil - case wireexecution.StateFailed: - return wirev1.ExecutionState_EXECUTION_STATE_FAILED, nil - case wireexecution.StateCancelled: - return wirev1.ExecutionState_EXECUTION_STATE_CANCELLED, nil - } - - return 0, runtimeConversionError("runtime returned an invalid execution state") -} - -func executionEvent(id core.ExecutionID, value wireexecution.Event) (*wirev1.WatchExecutionResponse, error) { - snapshot, err := execution(core.ExecutionRecord{ID: id, Snapshot: value.Snapshot}) - if err != nil { - return nil, err - } - - return &wirev1.WatchExecutionResponse{ - Sequence: value.Sequence, - Execution: snapshot, - }, nil -} - -func diagnosticsToProto(values diagnostics.Diagnostics) (*wirev1.DiagnosticSet, error) { - if values == nil { - return nil, nil - } - - result := &wirev1.DiagnosticSet{Diagnostics: make([]*wirev1.Diagnostic, len(values))} - for i, value := range values { - annotations := make([]*wirev1.DiagnosticAnnotation, len(value.Annotations)) - for j, annotation := range value.Annotations { - convertedRange, err := sourceRange(annotation.Range) - if err != nil { - return nil, err - } - - if convertedRange == nil { - return nil, runtimeConversionError("runtime returned a diagnostic annotation with no range") - } - - annotations[j] = &wirev1.DiagnosticAnnotation{ - Range: convertedRange, - Message: annotation.Message, - Primary: annotation.Primary, - } - } - - result.Diagnostics[i] = &wirev1.Diagnostic{ - Kind: value.Kind.String(), - Message: value.Message, - Hint: value.Hint, - Note: value.Note, - Source: &wirev1.Source{Name: value.Source.Name, Content: value.Source.Content}, - Annotations: annotations, - } - } - - return result, nil -} - -func sourceLocation(value source.Location) (*wirev1.Location, error) { - if value == (source.Location{}) { - return nil, nil - } - - if value.SourceName == "" { - return nil, runtimeConversionError("runtime returned a source location with no source name") - } - - if value.Line <= 0 || value.Column < 0 { - return nil, runtimeConversionError("runtime returned an invalid source location") - } - - return &wirev1.Location{ - SourceName: value.SourceName, - Position: &wirev1.Position{ - Line: int64(value.Line), - Column: int64(value.Column), - }, - }, nil -} - -func sourceRange(value source.Range) (*wirev1.Range, error) { - if value == (source.Range{}) { - return nil, nil - } - - location, err := sourceLocation(value.Location) - if err != nil { - return nil, err - } - - if location == nil { - return nil, runtimeConversionError("runtime returned a source range with no location") - } - - if value.Span.Start < 0 || value.Span.End < value.Span.Start { - return nil, runtimeConversionError("runtime returned an invalid source span") - } - - return &wirev1.Range{ - Location: location, - Span: &wirev1.Span{ - Start: int64(value.Span.Start), - End: int64(value.Span.End), - }, - }, nil -} - -func sourceLocationFromProto(value *wirev1.Location, name string) (source.Location, error) { - if value == nil { - return source.Location{}, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " is required"} - } - - if value.GetSourceName() == "" { - return source.Location{}, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " source name is required"} - } - - position := value.GetPosition() - if position == nil || position.GetLine() <= 0 || position.GetColumn() < 0 { - return source.Location{}, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " position is invalid"} - } - - line, err := intFromProto(position.GetLine(), name+" line") - if err != nil { - return source.Location{}, err - } - - column, err := intFromProto(position.GetColumn(), name+" column") - if err != nil { - return source.Location{}, err - } - - return source.Location{ - SourceName: value.GetSourceName(), - Position: source.Position{ - Line: line, - Column: column, - }, - }, nil -} - -func intFromProto(value int64, name string) (int, error) { - if value < 0 || uint64(value) > uint64(^uint(0)>>1) { - return 0, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " is out of range"} - } - - return int(value), nil -} - -func debugSession(value core.DebugSessionRecord) (*wirev1.DebugSession, error) { - state, err := debugState(value.Snapshot.State) - if err != nil { - return nil, err - } - - stopReason, err := debugStopReason(value.Snapshot.StopReason) - if err != nil { - return nil, err - } - - var location *wirev1.Range - if value.Snapshot.Location != nil { - location, err = sourceRange(*value.Snapshot.Location) - } - if err != nil { - return nil, err - } - if value.Snapshot.Location != nil && location == nil { - return nil, runtimeConversionError("runtime returned an empty debug location") - } - - if value.Snapshot.Depth < 0 { - return nil, runtimeConversionError("runtime returned an invalid debug depth") - } - - convertedFailure, err := failure(value.Snapshot.Failure) - if err != nil { - return nil, err - } - - hitIDs := make([]uint64, len(value.Snapshot.HitBreakpointIDs)) - for i, id := range value.Snapshot.HitBreakpointIDs { - converted, err := debuggerIDToProto(id, "hit breakpoint ID", false) - if err != nil { - return nil, err - } - - hitIDs[i] = converted - } - - return &wirev1.DebugSession{ - Id: &wirev1.DebugSessionId{Value: string(value.ID)}, - State: state, - StopReason: stopReason, - Location: location, - HitBreakpointIds: hitIDs, - Output: output(value.Snapshot.Output), - Failure: convertedFailure, - Depth: int64(value.Snapshot.Depth), - }, nil -} - -func debugState(value wiredebugger.State) (wirev1.DebugState, error) { - switch value { - case wiredebugger.StateCreated: - return wirev1.DebugState_DEBUG_STATE_CREATED, nil - case wiredebugger.StateRunning: - return wirev1.DebugState_DEBUG_STATE_RUNNING, nil - case wiredebugger.StateStopped: - return wirev1.DebugState_DEBUG_STATE_STOPPED, nil - case wiredebugger.StateCompleted: - return wirev1.DebugState_DEBUG_STATE_COMPLETED, nil - case wiredebugger.StateFailed: - return wirev1.DebugState_DEBUG_STATE_FAILED, nil - case wiredebugger.StateTerminated: - return wirev1.DebugState_DEBUG_STATE_TERMINATED, nil - } - - return 0, runtimeConversionError("runtime returned an invalid debug state") -} - -func debugStopReason(value debugger.Reason) (wirev1.DebugStopReason, error) { - switch value { - case "": - return wirev1.DebugStopReason_DEBUG_STOP_REASON_UNSPECIFIED, nil - case debugger.ReasonEntry: - return wirev1.DebugStopReason_DEBUG_STOP_REASON_ENTRY, nil - case debugger.ReasonBreakpoint: - return wirev1.DebugStopReason_DEBUG_STOP_REASON_BREAKPOINT, nil - case debugger.ReasonStep: - return wirev1.DebugStopReason_DEBUG_STOP_REASON_STEP, nil - case debugger.ReasonPause: - return wirev1.DebugStopReason_DEBUG_STOP_REASON_PAUSE, nil - case debugger.ReasonRuntimeError: - return wirev1.DebugStopReason_DEBUG_STOP_REASON_RUNTIME_ERROR, nil - } - - return 0, runtimeConversionError("runtime returned an invalid debug stop reason") -} - -func debugEvent(id core.DebugSessionID, value wiredebugger.Event) (*wirev1.WatchDebugResponse, error) { - kind, err := debugEventKind(value.Kind) - if err != nil { - return nil, err - } - - snapshot, err := debugSession(core.DebugSessionRecord{ID: id, Snapshot: value.Snapshot}) - if err != nil { - return nil, err - } - - return &wirev1.WatchDebugResponse{ - Sequence: value.Sequence, - Kind: kind, - Session: snapshot, - }, nil -} - -func debugEventKind(value wiredebugger.EventKind) (wirev1.DebugEventKind, error) { - switch value { - case wiredebugger.EventStarted: - return wirev1.DebugEventKind_DEBUG_EVENT_KIND_STARTED, nil - case wiredebugger.EventContinued: - return wirev1.DebugEventKind_DEBUG_EVENT_KIND_CONTINUED, nil - case wiredebugger.EventStopped: - return wirev1.DebugEventKind_DEBUG_EVENT_KIND_STOPPED, nil - case wiredebugger.EventCompleted: - return wirev1.DebugEventKind_DEBUG_EVENT_KIND_COMPLETED, nil - case wiredebugger.EventFailed: - return wirev1.DebugEventKind_DEBUG_EVENT_KIND_FAILED, nil - case wiredebugger.EventTerminated: - return wirev1.DebugEventKind_DEBUG_EVENT_KIND_TERMINATED, nil - case wiredebugger.EventCreated: - return wirev1.DebugEventKind_DEBUG_EVENT_KIND_CREATED, nil - } - - return 0, runtimeConversionError("runtime returned an invalid debug event kind") -} - -func breakpoint(value debugger.Breakpoint) (*wirev1.Breakpoint, error) { - id, err := debuggerIDToProto(value.ID, "breakpoint ID", false) - if err != nil { - return nil, err - } - - pointID, err := debuggerIDToProto(value.PointID, "breakpoint point ID", true) - if err != nil { - return nil, err - } - - functionID, err := debuggerIDToProto(value.FunctionID, "breakpoint function ID", true) - if err != nil { - return nil, err - } - - requested, err := sourceLocation(value.RequestedLocation) - if err != nil { - return nil, err - } - - if requested == nil { - return nil, runtimeConversionError("runtime returned no requested breakpoint location") - } - - resolved, err := sourceRange(value.Location) - if err != nil { - return nil, err - } - - if value.Bound && resolved == nil { - return nil, runtimeConversionError("runtime returned a bound breakpoint with no resolved location") - } - - bindingMode, err := breakpointBindingMode(value.BindingMode) - if err != nil { - return nil, err - } - - return &wirev1.Breakpoint{ - Id: id, - RequestedLocation: requested, - Location: resolved, - PointId: pointID, - FunctionId: functionID, - BindingMode: bindingMode, - Bound: value.Bound, - }, nil -} - -func breakpointBindingMode(value debugger.BreakpointBindingMode) (wirev1.BreakpointBindingMode, error) { - switch value { - case debugger.BreakpointBindNextExecutableInSource: - return wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_SOURCE, nil - case debugger.BreakpointBindExact: - return wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_EXACT, nil - case debugger.BreakpointBindNextExecutableInFunction: - return wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_FUNCTION, nil - default: - return wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_UNSPECIFIED, - runtimeConversionError("runtime returned an invalid breakpoint binding mode") - } -} - -func breakpointOptions(value *wirev1.BreakpointOptions) (debugger.BreakpointOptions, error) { - mode := wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_UNSPECIFIED - if value != nil { - mode = value.GetBindingMode() - } - - switch mode { - case wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_UNSPECIFIED, - wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_SOURCE: - return debugger.BreakpointOptions{BindingMode: debugger.BreakpointBindNextExecutableInSource}, nil - case wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_EXACT: - return debugger.BreakpointOptions{BindingMode: debugger.BreakpointBindExact}, nil - case wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_FUNCTION: - return debugger.BreakpointOptions{BindingMode: debugger.BreakpointBindNextExecutableInFunction}, nil - default: - return debugger.BreakpointOptions{}, &core.DomainError{ - Kind: core.ErrorKindInvalidRequest, - Message: "breakpoint binding mode is invalid", - } - } -} - -func frame(value debugger.Frame) (*wirev1.Frame, error) { - functionID, err := debuggerIDToProto(value.FunctionID, "frame function ID", true) - if err != nil { - return nil, err - } - - location, err := sourceLocation(value.Location) - if err != nil { - return nil, err - } - - return &wirev1.Frame{Name: value.Name, Location: location, FunctionId: functionID}, nil -} - -func debugValue(value debugger.Value) (*wirev1.DebugValue, error) { - reference, err := debuggerIDToProto(value.Reference, "debug value reference", true) - if err != nil { - return nil, err - } - - return &wirev1.DebugValue{Type: value.Type, Display: value.Display, Reference: reference}, nil -} - -func variable(value debugger.Variable) (*wirev1.Variable, error) { - converted, err := debugValue(value.Value) - if err != nil { - return nil, err - } - - return &wirev1.Variable{ - Name: value.Name, - Value: converted, - Mutable: value.Mutable, - Parameter: value.Param, - }, nil -} - -func debuggerIDFromProto[T ~int](value uint64, name string) (T, error) { - if value == 0 { - return 0, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " must be positive"} - } - - if value > uint64(^uint(0)>>1) { - return 0, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " is out of range"} - } - - return T(value), nil -} - -func debuggerIDToProto[T ~int](value T, name string, zeroAllowed bool) (uint64, error) { - if value < 0 || (!zeroAllowed && value == 0) { - return 0, runtimeConversionError("runtime returned an invalid %s", name) - } - - return uint64(value), nil -} - -func runtimeConversionError(format string, args ...any) error { - return &core.DomainError{ - Kind: core.ErrorKindInternal, - Message: "internal runtime failure", - Cause: fmt.Errorf(format, args...), - } -} diff --git a/server/internal/grpcserver/debug_conversion.go b/server/internal/grpcserver/debug_conversion.go new file mode 100644 index 0000000..b1109d2 --- /dev/null +++ b/server/internal/grpcserver/debug_conversion.go @@ -0,0 +1,296 @@ +package grpcserver + +import ( + "github.com/MontFerret/api/debugger" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + wiredebugger "github.com/MontFerret/wire/pkg/debugger" + "github.com/MontFerret/wire/server/internal/core" +) + +func debugSession(id core.DebugSessionID, value wiredebugger.Snapshot) (*wirev1.DebugSession, error) { + state, err := debugState(value.State) + if err != nil { + return nil, err + } + + stopReason, err := debugStopReason(value.StopReason) + if err != nil { + return nil, err + } + + var location *wirev1.Range + if value.Location != nil { + location, err = sourceRange(*value.Location) + if err != nil { + return nil, err + } + + if location == nil { + return nil, runtimeConversionError("runtime returned an empty debug location") + } + } + + if value.Depth < 0 { + return nil, runtimeConversionError("runtime returned an invalid debug depth") + } + + convertedFailure, err := failure(value.Failure) + if err != nil { + return nil, err + } + + hitIDs := make([]uint64, len(value.HitBreakpointIDs)) + for i, id := range value.HitBreakpointIDs { + converted, err := debuggerIDToProto(id, "hit breakpoint ID", false) + if err != nil { + return nil, err + } + + hitIDs[i] = converted + } + + return &wirev1.DebugSession{ + Id: &wirev1.DebugSessionId{Value: string(id)}, + State: state, + StopReason: stopReason, + Location: location, + HitBreakpointIds: hitIDs, + Output: output(value.Output), + Failure: convertedFailure, + Depth: int64(value.Depth), + }, nil +} + +func debugState(value wiredebugger.State) (wirev1.DebugState, error) { + switch value { + case wiredebugger.StateCreated: + return wirev1.DebugState_DEBUG_STATE_CREATED, nil + case wiredebugger.StateRunning: + return wirev1.DebugState_DEBUG_STATE_RUNNING, nil + case wiredebugger.StateStopped: + return wirev1.DebugState_DEBUG_STATE_STOPPED, nil + case wiredebugger.StateCompleted: + return wirev1.DebugState_DEBUG_STATE_COMPLETED, nil + case wiredebugger.StateFailed: + return wirev1.DebugState_DEBUG_STATE_FAILED, nil + case wiredebugger.StateTerminated: + return wirev1.DebugState_DEBUG_STATE_TERMINATED, nil + } + + return 0, runtimeConversionError("runtime returned an invalid debug state") +} + +func debugStopReason(value debugger.Reason) (wirev1.DebugStopReason, error) { + switch value { + case "": + return wirev1.DebugStopReason_DEBUG_STOP_REASON_UNSPECIFIED, nil + case debugger.ReasonEntry: + return wirev1.DebugStopReason_DEBUG_STOP_REASON_ENTRY, nil + case debugger.ReasonBreakpoint: + return wirev1.DebugStopReason_DEBUG_STOP_REASON_BREAKPOINT, nil + case debugger.ReasonStep: + return wirev1.DebugStopReason_DEBUG_STOP_REASON_STEP, nil + case debugger.ReasonPause: + return wirev1.DebugStopReason_DEBUG_STOP_REASON_PAUSE, nil + case debugger.ReasonRuntimeError: + return wirev1.DebugStopReason_DEBUG_STOP_REASON_RUNTIME_ERROR, nil + } + + return 0, runtimeConversionError("runtime returned an invalid debug stop reason") +} + +func debugEvent(id core.DebugSessionID, value wiredebugger.Event) (*wirev1.WatchDebugResponse, error) { + kind, err := debugEventKind(value.Kind) + if err != nil { + return nil, err + } + + snapshot, err := debugSession(id, value.Snapshot) + if err != nil { + return nil, err + } + + return &wirev1.WatchDebugResponse{ + Sequence: value.Sequence, + Kind: kind, + Session: snapshot, + }, nil +} + +func debugEventKind(value wiredebugger.EventKind) (wirev1.DebugEventKind, error) { + switch value { + case wiredebugger.EventStarted: + return wirev1.DebugEventKind_DEBUG_EVENT_KIND_STARTED, nil + case wiredebugger.EventContinued: + return wirev1.DebugEventKind_DEBUG_EVENT_KIND_CONTINUED, nil + case wiredebugger.EventStopped: + return wirev1.DebugEventKind_DEBUG_EVENT_KIND_STOPPED, nil + case wiredebugger.EventCompleted: + return wirev1.DebugEventKind_DEBUG_EVENT_KIND_COMPLETED, nil + case wiredebugger.EventFailed: + return wirev1.DebugEventKind_DEBUG_EVENT_KIND_FAILED, nil + case wiredebugger.EventTerminated: + return wirev1.DebugEventKind_DEBUG_EVENT_KIND_TERMINATED, nil + case wiredebugger.EventCreated: + return wirev1.DebugEventKind_DEBUG_EVENT_KIND_CREATED, nil + } + + return 0, runtimeConversionError("runtime returned an invalid debug event kind") +} + +func breakpoint(value debugger.Breakpoint) (*wirev1.Breakpoint, error) { + id, err := debuggerIDToProto(value.ID, "breakpoint ID", false) + if err != nil { + return nil, err + } + + pointID, err := debuggerIDToProto(value.PointID, "breakpoint point ID", true) + if err != nil { + return nil, err + } + + functionID, err := debuggerIDToProto(value.FunctionID, "breakpoint function ID", true) + if err != nil { + return nil, err + } + + requested, err := sourceLocation(value.RequestedLocation) + if err != nil { + return nil, err + } + + if requested == nil { + return nil, runtimeConversionError("runtime returned no requested breakpoint location") + } + + resolved, err := sourceRange(value.Location) + if err != nil { + return nil, err + } + + if value.Bound && resolved == nil { + return nil, runtimeConversionError("runtime returned a bound breakpoint with no resolved location") + } + + bindingMode, err := breakpointBindingMode(value.BindingMode) + if err != nil { + return nil, err + } + + return &wirev1.Breakpoint{ + Id: id, + RequestedLocation: requested, + Location: resolved, + PointId: pointID, + FunctionId: functionID, + BindingMode: bindingMode, + Bound: value.Bound, + }, nil +} + +func breakpointBindingMode(value debugger.BreakpointBindingMode) (wirev1.BreakpointBindingMode, error) { + switch value { + case debugger.BreakpointBindNextExecutableInSource: + return wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_SOURCE, nil + case debugger.BreakpointBindExact: + return wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_EXACT, nil + case debugger.BreakpointBindNextExecutableInFunction: + return wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_FUNCTION, nil + default: + return wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_UNSPECIFIED, + runtimeConversionError("runtime returned an invalid breakpoint binding mode") + } +} + +func breakpointOptions(value *wirev1.BreakpointOptions) (debugger.BreakpointOptions, error) { + mode := wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_UNSPECIFIED + if value != nil { + mode = value.GetBindingMode() + } + + switch mode { + case wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_UNSPECIFIED, + wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_SOURCE: + return debugger.BreakpointOptions{BindingMode: debugger.BreakpointBindNextExecutableInSource}, nil + case wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_EXACT: + return debugger.BreakpointOptions{BindingMode: debugger.BreakpointBindExact}, nil + case wirev1.BreakpointBindingMode_BREAKPOINT_BINDING_MODE_NEXT_EXECUTABLE_IN_FUNCTION: + return debugger.BreakpointOptions{BindingMode: debugger.BreakpointBindNextExecutableInFunction}, nil + default: + return debugger.BreakpointOptions{}, &core.DomainError{ + Kind: core.ErrorKindInvalidRequest, + Message: "breakpoint binding mode is invalid", + } + } +} + +func frame(value debugger.Frame) (*wirev1.Frame, error) { + functionID, err := debuggerIDToProto(value.FunctionID, "frame function ID", true) + if err != nil { + return nil, err + } + + location, err := sourceLocation(value.Location) + if err != nil { + return nil, err + } + + return &wirev1.Frame{Name: value.Name, Location: location, FunctionId: functionID}, nil +} + +func debugValue(value debugger.Value) (*wirev1.DebugValue, error) { + reference, err := debuggerIDToProto(value.Reference, "debug value reference", true) + if err != nil { + return nil, err + } + + return &wirev1.DebugValue{Type: value.Type, Display: value.Display, Reference: reference}, nil +} + +func variable(value debugger.Variable) (*wirev1.Variable, error) { + converted, err := debugValue(value.Value) + if err != nil { + return nil, err + } + + return &wirev1.Variable{ + Name: value.Name, + Value: converted, + Mutable: value.Mutable, + Parameter: value.Param, + }, nil +} + +func debuggerIDFromProto[T ~int](value uint64, name string) (T, error) { + if value == 0 { + return 0, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " must be positive"} + } + + if value > uint64(^uint(0)>>1) { + return 0, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " is out of range"} + } + + return T(value), nil +} + +func debuggerIDToProto[T ~int](value T, name string, zeroAllowed bool) (uint64, error) { + if value < 0 || (!zeroAllowed && value == 0) { + return 0, runtimeConversionError("runtime returned an invalid %s", name) + } + + return uint64(value), nil +} + +func variablesToProto(values []debugger.Variable) ([]*wirev1.Variable, error) { + result := make([]*wirev1.Variable, len(values)) + for i, value := range values { + converted, err := variable(value) + if err != nil { + return nil, err + } + + result[i] = converted + } + + return result, nil +} diff --git a/server/internal/grpcserver/debug_conversion_test.go b/server/internal/grpcserver/debug_conversion_test.go index c2f90d7..c2adac1 100644 --- a/server/internal/grpcserver/debug_conversion_test.go +++ b/server/internal/grpcserver/debug_conversion_test.go @@ -69,13 +69,13 @@ func TestUnifiedDebuggerTypesPreservePortableProtocolFields(t *testing.T) { t.Fatalf("unexpected variable transport projection: %#v", convertedVariable) } - convertedSession, err := debugSession(core.DebugSessionRecord{Snapshot: wiredebugger.Snapshot{ + convertedSession, err := debugSession("", wiredebugger.Snapshot{ State: wiredebugger.StateStopped, StopReason: debugger.ReasonBreakpoint, Location: &resolved, HitBreakpointIDs: []debugger.BreakpointID{7}, Depth: 3, - }}) + }) if err != nil { t.Fatal(err) } @@ -252,9 +252,9 @@ func TestDebuggerBoundaryRejectsMalformedRepresentations(t *testing.T) { name: "non-nil empty debug location", err: func() error { empty := source.Range{} - _, err := debugSession(core.DebugSessionRecord{Snapshot: wiredebugger.Snapshot{ + _, err := debugSession("", wiredebugger.Snapshot{ State: wiredebugger.StateStopped, Location: &empty, - }}) + }) return err }(), @@ -263,7 +263,7 @@ func TestDebuggerBoundaryRejectsMalformedRepresentations(t *testing.T) { { name: "negative runtime debug depth", err: func() error { - _, err := debugSession(core.DebugSessionRecord{Snapshot: wiredebugger.Snapshot{State: wiredebugger.StateStopped, Depth: -1}}) + _, err := debugSession("", wiredebugger.Snapshot{State: wiredebugger.StateStopped, Depth: -1}) return err }(), diff --git a/server/internal/grpcserver/debug_service.go b/server/internal/grpcserver/debug_service.go index 45493ce..99aaf56 100644 --- a/server/internal/grpcserver/debug_service.go +++ b/server/internal/grpcserver/debug_service.go @@ -10,9 +10,7 @@ import ( // DebugService adapts debugger lifecycle, commands, inspection, and events. type DebugService struct { wirev1.UnimplementedDebugServiceServer - debugger *core.Debugger - lifecycle *core.Lifecycle - operations *operationContextFactory + connections *core.ConnectionRegistry } var _ wirev1.DebugServiceServer = (*DebugService)(nil) @@ -21,28 +19,29 @@ func (s *DebugService) CreateDebugSession( ctx context.Context, request *wirev1.CreateDebugSessionRequest, ) (*wirev1.CreateDebugSessionResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - parameters, err := decodeParameters(request.GetParameters()) + options, err := decodeSessionOptions(request.GetParameters(), request.GetOutputContentType()) if err != nil { - return nil, rpcError(&core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: err.Error()}) + return nil, rpcError(err) + } + + parent, err := resources.Plan(operation, core.PlanID(request.GetPlanId().GetValue())) + if err != nil { + return nil, rpcError(err) } - snapshot, err := s.debugger.Create(operation, core.OpenDebugInput{ - PlanID: core.PlanID(request.GetPlanId().GetValue()), - Parameters: parameters, - OutputContentType: request.GetOutputContentType(), - }) + created, err := parent.NewDebugSession(operation, options...) if err != nil { return nil, rpcError(err) } - converted, err := debugSession(snapshot) + converted, err := debugSession(created.ID(), created.Snapshot()) if err != nil { return nil, rpcError(err) } @@ -54,14 +53,14 @@ func (s *DebugService) ReleaseDebugSession( ctx context.Context, request *wirev1.ReleaseDebugSessionRequest, ) (*wirev1.ReleaseDebugSessionResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - if err := s.lifecycle.ReleaseDebugSession(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())); err != nil { + if err := resources.ReleaseDebugSession(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())); err != nil { return nil, rpcError(err) } diff --git a/server/internal/grpcserver/debug_service_commands.go b/server/internal/grpcserver/debug_service_commands.go index 69a2e14..16d5837 100644 --- a/server/internal/grpcserver/debug_service_commands.go +++ b/server/internal/grpcserver/debug_service_commands.go @@ -12,13 +12,13 @@ func (s *DebugService) debugCommand( ctx context.Context, connectionID *wirev1.ConnectionId, sessionID *wirev1.DebugSessionId, -) (*core.Context, context.CancelFunc, *core.DebugSession, error) { - operation, cancel, err := s.operations.New(ctx, connectionID) +) (context.Context, context.CancelFunc, *core.DebugSession, error) { + operation, resources, cancel, err := prepareOperation(ctx, s.connections, connectionID) if err != nil { return nil, nil, nil, err } - session, err := s.debugger.Session(operation, core.DebugSessionID(sessionID.GetValue())) + session, err := resources.DebugSession(operation, core.DebugSessionID(sessionID.GetValue())) if err != nil { cancel() diff --git a/server/internal/grpcserver/debug_service_events.go b/server/internal/grpcserver/debug_service_events.go index 4b9898f..24d0a79 100644 --- a/server/internal/grpcserver/debug_service_events.go +++ b/server/internal/grpcserver/debug_service_events.go @@ -6,14 +6,14 @@ import ( ) func (s *DebugService) WatchDebug(request *wirev1.WatchDebugRequest, stream wirev1.DebugService_WatchDebugServer) error { - operation, cancel, err := s.operations.New(stream.Context(), request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(stream.Context(), s.connections, request.GetConnectionId()) if err != nil { return err } defer cancel() - session, err := s.debugger.Session(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) + session, err := resources.DebugSession(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) if err != nil { return rpcError(err) } diff --git a/server/internal/grpcserver/debug_service_inspection.go b/server/internal/grpcserver/debug_service_inspection.go index 4ad367a..640bb0e 100644 --- a/server/internal/grpcserver/debug_service_inspection.go +++ b/server/internal/grpcserver/debug_service_inspection.go @@ -9,14 +9,14 @@ import ( ) func (s *DebugService) Frames(ctx context.Context, request *wirev1.FramesRequest) (*wirev1.FramesResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - session, err := s.debugger.Session(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) + session, err := resources.DebugSession(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) if err != nil { return nil, rpcError(err) } @@ -40,14 +40,14 @@ func (s *DebugService) Frames(ctx context.Context, request *wirev1.FramesRequest } func (s *DebugService) FrameLocals(ctx context.Context, request *wirev1.FrameLocalsRequest) (*wirev1.FrameLocalsResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - session, err := s.debugger.Session(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) + session, err := resources.DebugSession(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) if err != nil { return nil, rpcError(err) } @@ -57,21 +57,16 @@ func (s *DebugService) FrameLocals(ctx context.Context, request *wirev1.FrameLoc return nil, rpcError(err) } - result := make([]*wirev1.Variable, len(values)) - for i, value := range values { - converted, err := variable(value) - if err != nil { - return nil, rpcError(err) - } - - result[i] = converted + result, err := variablesToProto(values) + if err != nil { + return nil, rpcError(err) } return &wirev1.FrameLocalsResponse{Variables: result}, nil } func (s *DebugService) Variables(ctx context.Context, request *wirev1.VariablesRequest) (*wirev1.VariablesResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } @@ -83,7 +78,7 @@ func (s *DebugService) Variables(ctx context.Context, request *wirev1.VariablesR return nil, rpcError(err) } - session, err := s.debugger.Session(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) + session, err := resources.DebugSession(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) if err != nil { return nil, rpcError(err) } @@ -93,28 +88,23 @@ func (s *DebugService) Variables(ctx context.Context, request *wirev1.VariablesR return nil, rpcError(err) } - result := make([]*wirev1.Variable, len(values)) - for i, value := range values { - converted, err := variable(value) - if err != nil { - return nil, rpcError(err) - } - - result[i] = converted + result, err := variablesToProto(values) + if err != nil { + return nil, rpcError(err) } return &wirev1.VariablesResponse{Variables: result}, nil } func (s *DebugService) EvaluateFrame(ctx context.Context, request *wirev1.EvaluateFrameRequest) (*wirev1.EvaluateFrameResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - session, err := s.debugger.Session(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) + session, err := resources.DebugSession(operation, core.DebugSessionID(request.GetDebugSessionId().GetValue())) if err != nil { return nil, rpcError(err) } diff --git a/server/internal/grpcserver/errors.go b/server/internal/grpcserver/errors.go index 46582db..f19acb6 100644 --- a/server/internal/grpcserver/errors.go +++ b/server/internal/grpcserver/errors.go @@ -3,8 +3,8 @@ package grpcserver import ( "context" "errors" + "fmt" - "github.com/MontFerret/api/diagnostics" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/server/internal/core" "google.golang.org/grpc/codes" @@ -70,7 +70,15 @@ func rpcError(err error) error { ) } - return statusWithDiagnostics(code, message, errorCategory(domain.Kind), diagnosticSet) + category := wirev1.ErrorCategory_ERROR_CATEGORY_UNSPECIFIED + if value := domain.Category(); value != 0 { + category, conversionErr = failureCategory(value) + if conversionErr != nil { + return statusWithCategory(codes.Internal, "internal runtime failure", wirev1.ErrorCategory_ERROR_CATEGORY_INTERNAL_RUNTIME_FAILURE) + } + } + + return statusWithDiagnostics(code, message, category, diagnosticSet) } func statusWithCategory(code codes.Code, message string, category wirev1.ErrorCategory) error { @@ -106,44 +114,13 @@ func statusWithDiagnostics( } func diagnosticSetFromError(err error) (*wirev1.DiagnosticSet, error) { - var values diagnostics.Diagnostics - if errors.As(err, &values) { - return diagnosticsToProto(values) - } - - var pointer *diagnostics.Diagnostics - if errors.As(err, &pointer) && pointer != nil { - return diagnosticsToProto(*pointer) - } - - return nil, nil + return diagnosticsToProto(core.DiagnosticsFromError(err)) } -func errorCategory(value core.ErrorKind) wirev1.ErrorCategory { - switch value { - case core.ErrorKindCompilation: - return wirev1.ErrorCategory_ERROR_CATEGORY_COMPILATION_FAILURE - case core.ErrorKindExecution: - return wirev1.ErrorCategory_ERROR_CATEGORY_EXECUTION_FAILURE - case core.ErrorKindPlanNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_PLAN_NOT_FOUND - case core.ErrorKindExecutionNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_EXECUTION_NOT_FOUND - case core.ErrorKindDebugSessionNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_DEBUG_SESSION_NOT_FOUND - case core.ErrorKindConnectionNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_CONNECTION_NOT_FOUND - case core.ErrorKindInvalidState: - return wirev1.ErrorCategory_ERROR_CATEGORY_INVALID_STATE - case core.ErrorKindWatcherLagged: - return wirev1.ErrorCategory_ERROR_CATEGORY_WATCHER_LAGGED - case core.ErrorKindBreakpointNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_BREAKPOINT_NOT_FOUND - case core.ErrorKindInternal: - return wirev1.ErrorCategory_ERROR_CATEGORY_INTERNAL_RUNTIME_FAILURE - case core.ErrorKindSessionNotFound: - return wirev1.ErrorCategory_ERROR_CATEGORY_SESSION_NOT_FOUND - default: - return wirev1.ErrorCategory_ERROR_CATEGORY_UNSPECIFIED +func runtimeConversionError(format string, args ...any) error { + return &core.DomainError{ + Kind: core.ErrorKindInternal, + Message: "internal runtime failure", + Cause: fmt.Errorf(format, args...), } } diff --git a/server/internal/grpcserver/execution_conversion.go b/server/internal/grpcserver/execution_conversion.go new file mode 100644 index 0000000..fdcc6ca --- /dev/null +++ b/server/internal/grpcserver/execution_conversion.go @@ -0,0 +1,53 @@ +package grpcserver + +import ( + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + wireexecution "github.com/MontFerret/wire/pkg/execution" + "github.com/MontFerret/wire/server/internal/core" +) + +func execution(id core.ExecutionID, value wireexecution.Snapshot) (*wirev1.Execution, error) { + state, err := executionState(value.State) + if err != nil { + return nil, err + } + + convertedFailure, err := failure(value.Failure) + if err != nil { + return nil, err + } + + return &wirev1.Execution{ + Id: &wirev1.ExecutionId{Value: string(id)}, + State: state, + Output: output(value.Output), + Failure: convertedFailure, + }, nil +} + +func executionState(value wireexecution.State) (wirev1.ExecutionState, error) { + switch value { + case wireexecution.StateRunning: + return wirev1.ExecutionState_EXECUTION_STATE_RUNNING, nil + case wireexecution.StateCompleted: + return wirev1.ExecutionState_EXECUTION_STATE_COMPLETED, nil + case wireexecution.StateFailed: + return wirev1.ExecutionState_EXECUTION_STATE_FAILED, nil + case wireexecution.StateCancelled: + return wirev1.ExecutionState_EXECUTION_STATE_CANCELLED, nil + } + + return 0, runtimeConversionError("runtime returned an invalid execution state") +} + +func executionEvent(id core.ExecutionID, value wireexecution.Event) (*wirev1.WatchExecutionResponse, error) { + snapshot, err := execution(id, value.Snapshot) + if err != nil { + return nil, err + } + + return &wirev1.WatchExecutionResponse{ + Sequence: value.Sequence, + Execution: snapshot, + }, nil +} diff --git a/server/internal/grpcserver/execution_service.go b/server/internal/grpcserver/execution_service.go index 1c8b570..b70e9cd 100644 --- a/server/internal/grpcserver/execution_service.go +++ b/server/internal/grpcserver/execution_service.go @@ -4,42 +4,42 @@ import ( "context" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + wireexecution "github.com/MontFerret/wire/pkg/execution" "github.com/MontFerret/wire/server/internal/core" ) // ExecutionService adapts the execution RPC contract to its core owners. type ExecutionService struct { wirev1.UnimplementedExecutionServiceServer - executor *core.Executor - lifecycle *core.Lifecycle - operations *operationContextFactory + connections *core.ConnectionRegistry } var _ wirev1.ExecutionServiceServer = (*ExecutionService)(nil) func (s *ExecutionService) Execute(ctx context.Context, request *wirev1.ExecuteRequest) (*wirev1.ExecuteResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - parameters, err := decodeParameters(request.GetParameters()) + options, err := decodeSessionOptions(request.GetParameters(), request.GetOutputContentType()) if err != nil { - return nil, rpcError(&core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: err.Error()}) + return nil, rpcError(err) + } + + parent, err := resources.Plan(operation, core.PlanID(request.GetPlanId().GetValue())) + if err != nil { + return nil, rpcError(err) } - snapshot, err := s.executor.Execute(operation, core.ExecuteInput{ - PlanID: core.PlanID(request.GetPlanId().GetValue()), - Parameters: parameters, - OutputContentType: request.GetOutputContentType(), - }) + created, err := parent.Execute(operation, options...) if err != nil { return nil, rpcError(err) } - converted, err := execution(snapshot) + converted, err := execution(created.ID(), created.Snapshot()) if err != nil { return nil, rpcError(err) } @@ -51,19 +51,24 @@ func (s *ExecutionService) RunSession( ctx context.Context, request *wirev1.RunSessionRequest, ) (*wirev1.RunSessionResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - snapshot, err := s.executor.RunSession(operation, core.SessionID(request.GetSessionId().GetValue())) + session, err := resources.Session(operation, core.SessionID(request.GetSessionId().GetValue())) + if err != nil { + return nil, rpcError(err) + } + + created, err := session.Execute(operation) if err != nil { return nil, rpcError(err) } - converted, err := execution(snapshot) + converted, err := execution(created.ID(), wireexecution.Snapshot{State: wireexecution.StateRunning}) if err != nil { return nil, rpcError(err) } @@ -72,14 +77,14 @@ func (s *ExecutionService) RunSession( } func (s *ExecutionService) CancelExecution(ctx context.Context, request *wirev1.CancelExecutionRequest) (*wirev1.CancelExecutionResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - execution, err := s.executor.Execution(operation, core.ExecutionID(request.GetExecutionId().GetValue())) + execution, err := resources.Execution(operation, core.ExecutionID(request.GetExecutionId().GetValue())) if err != nil { return nil, rpcError(err) } @@ -90,14 +95,14 @@ func (s *ExecutionService) CancelExecution(ctx context.Context, request *wirev1. } func (s *ExecutionService) ReleaseExecution(ctx context.Context, request *wirev1.ReleaseExecutionRequest) (*wirev1.ReleaseExecutionResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - if err := s.lifecycle.ReleaseExecution(operation, core.ExecutionID(request.GetExecutionId().GetValue())); err != nil { + if err := resources.ReleaseExecution(operation, core.ExecutionID(request.GetExecutionId().GetValue())); err != nil { return nil, rpcError(err) } @@ -105,14 +110,14 @@ func (s *ExecutionService) ReleaseExecution(ctx context.Context, request *wirev1 } func (s *ExecutionService) WatchExecution(request *wirev1.WatchExecutionRequest, stream wirev1.ExecutionService_WatchExecutionServer) error { - operation, cancel, err := s.operations.New(stream.Context(), request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(stream.Context(), s.connections, request.GetConnectionId()) if err != nil { return err } defer cancel() - execution, err := s.executor.Execution(operation, core.ExecutionID(request.GetExecutionId().GetValue())) + execution, err := resources.Execution(operation, core.ExecutionID(request.GetExecutionId().GetValue())) if err != nil { return rpcError(err) } diff --git a/server/internal/grpcserver/failure_conversion.go b/server/internal/grpcserver/failure_conversion.go new file mode 100644 index 0000000..9d96782 --- /dev/null +++ b/server/internal/grpcserver/failure_conversion.go @@ -0,0 +1,57 @@ +package grpcserver + +import ( + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + wirefailure "github.com/MontFerret/wire/pkg/failure" +) + +func failure(value *wirefailure.Failure) (*wirev1.Failure, error) { + if value == nil { + return nil, nil + } + + diagnosticSet, err := diagnosticsToProto(value.Diagnostics) + if err != nil { + return nil, err + } + + category, err := failureCategory(value.Category) + if err != nil { + return nil, err + } + + return &wirev1.Failure{ + Category: category, + Message: value.Message, + DiagnosticSet: diagnosticSet, + }, nil +} + +func failureCategory(value wirefailure.Category) (wirev1.ErrorCategory, error) { + switch value { + case wirefailure.CategoryCompilation: + return wirev1.ErrorCategory_ERROR_CATEGORY_COMPILATION_FAILURE, nil + case wirefailure.CategoryExecution: + return wirev1.ErrorCategory_ERROR_CATEGORY_EXECUTION_FAILURE, nil + case wirefailure.CategoryPlanNotFound: + return wirev1.ErrorCategory_ERROR_CATEGORY_PLAN_NOT_FOUND, nil + case wirefailure.CategoryExecutionNotFound: + return wirev1.ErrorCategory_ERROR_CATEGORY_EXECUTION_NOT_FOUND, nil + case wirefailure.CategoryDebugSessionNotFound: + return wirev1.ErrorCategory_ERROR_CATEGORY_DEBUG_SESSION_NOT_FOUND, nil + case wirefailure.CategoryConnectionNotFound: + return wirev1.ErrorCategory_ERROR_CATEGORY_CONNECTION_NOT_FOUND, nil + case wirefailure.CategoryInvalidState: + return wirev1.ErrorCategory_ERROR_CATEGORY_INVALID_STATE, nil + case wirefailure.CategoryInternalRuntime: + return wirev1.ErrorCategory_ERROR_CATEGORY_INTERNAL_RUNTIME_FAILURE, nil + case wirefailure.CategoryWatcherLagged: + return wirev1.ErrorCategory_ERROR_CATEGORY_WATCHER_LAGGED, nil + case wirefailure.CategoryBreakpointNotFound: + return wirev1.ErrorCategory_ERROR_CATEGORY_BREAKPOINT_NOT_FOUND, nil + case wirefailure.CategorySessionNotFound: + return wirev1.ErrorCategory_ERROR_CATEGORY_SESSION_NOT_FOUND, nil + } + + return 0, runtimeConversionError("runtime returned an invalid failure category") +} diff --git a/server/internal/grpcserver/handshake.go b/server/internal/grpcserver/handshake.go new file mode 100644 index 0000000..27a7ac2 --- /dev/null +++ b/server/internal/grpcserver/handshake.go @@ -0,0 +1,26 @@ +package grpcserver + +import wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + +// Handshake is immutable transport metadata supplied by the server composition root. +type Handshake struct { + ProtocolName string + ProtocolVersion string + RuntimeName string + RuntimeVersion string + RuntimeInstanceID string +} + +func protocolInfo(value Handshake) *wirev1.ProtocolInfo { + return &wirev1.ProtocolInfo{Name: value.ProtocolName, Version: value.ProtocolVersion} +} + +func runtimeIdentity(value Handshake) *wirev1.RuntimeIdentity { + if value.RuntimeName == "" { + return nil + } + + return &wirev1.RuntimeIdentity{ + Name: value.RuntimeName, Version: value.RuntimeVersion, InstanceId: value.RuntimeInstanceID, + } +} diff --git a/server/internal/grpcserver/operation_context.go b/server/internal/grpcserver/operation_context.go index b9a3b28..20de757 100644 --- a/server/internal/grpcserver/operation_context.go +++ b/server/internal/grpcserver/operation_context.go @@ -7,22 +7,13 @@ import ( "github.com/MontFerret/wire/server/internal/core" ) -// operationContextFactory resolves the logical owner and combines its lifetime -// with the request. Callers must cancel the returned context after the operation. -type operationContextFactory struct { - connections *core.ConnectionRegistry -} - -func (f *operationContextFactory) New( - parent context.Context, - id *wirev1.ConnectionId, -) (*core.Context, context.CancelFunc, error) { - connection, err := f.connections.Get(core.ConnectionID(id.GetValue())) +func prepareOperation(parent context.Context, connections *core.ConnectionRegistry, id *wirev1.ConnectionId) (context.Context, *core.ResourceStore, context.CancelFunc, error) { + connection, err := connections.Get(core.ConnectionID(id.GetValue())) if err != nil { - return nil, nil, rpcError(err) + return nil, nil, nil, rpcError(err) } - ctx, cancel := core.NewContext(parent, connection) + ctx, cancel := core.OperationContext(parent, connection.Context()) - return ctx, cancel, nil + return ctx, connection.Resources(), cancel, nil } diff --git a/server/internal/grpcserver/operation_context_test.go b/server/internal/grpcserver/operation_context_test.go index b429672..556f172 100644 --- a/server/internal/grpcserver/operation_context_test.go +++ b/server/internal/grpcserver/operation_context_test.go @@ -14,7 +14,7 @@ import ( ) func TestOperationContextRejectsInvalidAndUnknownConnections(t *testing.T) { - factory := &operationContextFactory{connections: core.NewConnectionRegistry(1)} + registry := core.NewConnectionRegistry(1, core.ResourceLimits{}) for _, test := range []struct { name string id *wirev1.ConnectionId @@ -26,8 +26,8 @@ func TestOperationContextRejectsInvalidAndUnknownConnections(t *testing.T) { {name: "unknown", id: &wirev1.ConnectionId{Value: uuid.NewString()}, code: codes.NotFound}, } { t.Run(test.name, func(t *testing.T) { - operation, cancel, err := factory.New(context.Background(), test.id) - if status.Code(err) != test.code || operation != nil || cancel != nil { + operation, resources, cancel, err := prepareOperation(context.Background(), registry, test.id) + if status.Code(err) != test.code || operation != nil || resources != nil || cancel != nil { t.Fatalf("context result = (%v, %v, %v), want %v", operation, cancel == nil, err, test.code) } }) @@ -37,18 +37,16 @@ func TestOperationContextRejectsInvalidAndUnknownConnections(t *testing.T) { func TestOperationContextCombinesLifetimesAndPreservesValues(t *testing.T) { for _, lifetime := range []string{"request", "connection", "operation"} { t.Run(lifetime, func(t *testing.T) { - registry := core.NewConnectionRegistry(1) - connection := core.NewConnection() - if err := registry.Register(connection); err != nil { + registry := core.NewConnectionRegistry(1, core.ResourceLimits{}) + connection, err := registry.Open() + if err != nil { t.Fatal(err) } - - lifecycle := core.NewLifecycle(registry, core.NewPlanRegistry(1), core.NewSessionRegistry(1), core.NewExecutionRegistry(1, 1), core.NewDebugSessionRegistry(1, 1, 1)) t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := lifecycle.Close(ctx); err != nil { + if err := registry.Close(ctx); err != nil { t.Error(err) } }) @@ -56,9 +54,8 @@ func TestOperationContextCombinesLifetimesAndPreservesValues(t *testing.T) { type contextKey struct{} request, cancelRequest := context.WithTimeout(context.WithValue(context.Background(), contextKey{}, "retained"), 5*time.Second) defer cancelRequest() - factory := &operationContextFactory{connections: registry} id := &wirev1.ConnectionId{Value: string(connection.ID())} - operation, cancel, err := factory.New(request, id) + operation, resources, cancel, err := prepareOperation(request, registry, id) if err != nil { t.Fatal(err) } @@ -67,7 +64,7 @@ func TestOperationContextCombinesLifetimesAndPreservesValues(t *testing.T) { deadline, _ := request.Deadline() operationDeadline, present := operation.Deadline() - if operation.Connection() != connection || operation.Value(contextKey{}) != "retained" || !present || !operationDeadline.Equal(deadline) { + if resources != connection.Resources() || operation.Value(contextKey{}) != "retained" || !present || !operationDeadline.Equal(deadline) { t.Fatal("operation lost its connection, request value, or deadline") } @@ -75,11 +72,11 @@ func TestOperationContextCombinesLifetimesAndPreservesValues(t *testing.T) { case "request": cancelRequest() case "connection": - if err := lifecycle.CloseConnection(request, connection.ID()); err != nil { + if err := registry.CloseConnection(request, connection.ID()); err != nil { t.Fatal(err) } - if _, _, err := factory.New(request, id); status.Code(err) != codes.NotFound { + if _, _, _, err := prepareOperation(request, registry, id); status.Code(err) != codes.NotFound { t.Fatalf("closed connection was resolved: %v", err) } case "operation": diff --git a/server/internal/grpcserver/output_conversion.go b/server/internal/grpcserver/output_conversion.go new file mode 100644 index 0000000..f3e5bfa --- /dev/null +++ b/server/internal/grpcserver/output_conversion.go @@ -0,0 +1,14 @@ +package grpcserver + +import ( + "github.com/MontFerret/api" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" +) + +func output(value *api.Output) *wirev1.Output { + if value == nil { + return nil + } + + return &wirev1.Output{ContentType: value.ContentType, Content: append([]byte(nil), value.Content...)} +} diff --git a/server/internal/grpcserver/plan_conversion.go b/server/internal/grpcserver/plan_conversion.go new file mode 100644 index 0000000..54c5091 --- /dev/null +++ b/server/internal/grpcserver/plan_conversion.go @@ -0,0 +1,13 @@ +package grpcserver + +import ( + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "github.com/MontFerret/wire/server/internal/core" +) + +func plan(value *core.Plan) *wirev1.Plan { + return &wirev1.Plan{ + Id: &wirev1.PlanId{Value: string(value.ID())}, + Parameters: value.Params(), + } +} diff --git a/server/internal/grpcserver/plan_service.go b/server/internal/grpcserver/plan_service.go index e00c602..7d82afd 100644 --- a/server/internal/grpcserver/plan_service.go +++ b/server/internal/grpcserver/plan_service.go @@ -11,9 +11,8 @@ import ( // PlanService adapts the plan RPC contract to its core owners. type PlanService struct { wirev1.UnimplementedPlanServiceServer - compiler *core.Compiler - lifecycle *core.Lifecycle - operations *operationContextFactory + runtime api.Runtime + connections *core.ConnectionRegistry } var _ wirev1.PlanServiceServer = (*PlanService)(nil) @@ -43,43 +42,35 @@ func (s *PlanService) compile( options *wirev1.CompileOptions, debug bool, ) (*wirev1.Plan, error) { - operation, cancel, err := s.operations.New(ctx, connectionID) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, connectionID) if err != nil { return nil, err } defer cancel() - optimization, present, err := optimizationLevel(options) + planOptions, err := decodeCompileOptions(options) if err != nil { return nil, rpcError(err) } - snapshot, err := s.compiler.Compile(operation, core.CompileInput{ - Source: api.Source{ - Name: source.GetName(), - Content: source.GetContent(), - }, - Debuggable: debug, - OptimizationLevel: optimization, - HasOptimizationLevel: present, - }) + compiled, err := core.CompilePlan(operation, s.runtime, resources, decodeSource(source), debug, planOptions...) if err != nil { return nil, rpcError(err) } - return plan(snapshot), nil + return plan(compiled), nil } func (s *PlanService) ReleasePlan(ctx context.Context, request *wirev1.ReleasePlanRequest) (*wirev1.ReleasePlanResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - if err := s.lifecycle.ReleasePlan(operation, core.PlanID(request.GetPlanId().GetValue())); err != nil { + if err := resources.ReleasePlan(operation, core.PlanID(request.GetPlanId().GetValue())); err != nil { return nil, rpcError(err) } diff --git a/server/internal/grpcserver/runtime_service.go b/server/internal/grpcserver/runtime_service.go index 448b1b7..facda39 100644 --- a/server/internal/grpcserver/runtime_service.go +++ b/server/internal/grpcserver/runtime_service.go @@ -5,35 +5,34 @@ import ( "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + wireexecution "github.com/MontFerret/wire/pkg/execution" "github.com/MontFerret/wire/server/internal/core" ) // RuntimeService adapts the runtime RPC contract to its core owners. type RuntimeService struct { wirev1.UnimplementedRuntimeServiceServer - info core.RuntimeInfo + info Handshake + runtime api.Runtime connections *core.ConnectionRegistry - executor *core.Executor - lifecycle *core.Lifecycle - operations *operationContextFactory } var _ wirev1.RuntimeServiceServer = (*RuntimeService)(nil) func (s *RuntimeService) Connect(_ *wirev1.ConnectRequest, stream wirev1.RuntimeService_ConnectServer) error { - connection := core.NewConnection() - if err := s.connections.Register(connection); err != nil { + connection, err := s.connections.Open() + if err != nil { return rpcError(err) } defer func() { - _ = s.lifecycle.CloseConnection(context.Background(), connection.ID()) + _ = s.connections.CloseConnection(context.Background(), connection.ID()) }() response := &wirev1.ConnectResponse{ ConnectionId: &wirev1.ConnectionId{Value: string(connection.ID())}, Protocol: protocolInfo(s.info), - RuntimeIdentity: runtimeIdentity(s.info.RuntimeIdentity), + RuntimeIdentity: runtimeIdentity(s.info), } if err := stream.Send(response); err != nil { @@ -49,7 +48,7 @@ func (s *RuntimeService) Connect(_ *wirev1.ConnectRequest, stream wirev1.Runtime } func (s *RuntimeService) CloseConnection(ctx context.Context, request *wirev1.CloseConnectionRequest) (*wirev1.CloseConnectionResponse, error) { - err := s.lifecycle.CloseConnection(ctx, core.ConnectionID(request.GetConnectionId().GetValue())) + err := s.connections.CloseConnection(ctx, core.ConnectionID(request.GetConnectionId().GetValue())) if err != nil { return nil, rpcError(err) } @@ -61,31 +60,24 @@ func (s *RuntimeService) Run( ctx context.Context, request *wirev1.RunRequest, ) (*wirev1.RunResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - parameters, err := decodeParameters(request.GetParameters()) + options, err := decodeSessionOptions(request.GetParameters(), request.GetOutputContentType()) if err != nil { - return nil, rpcError(&core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: err.Error()}) + return nil, rpcError(err) } - snapshot, err := s.executor.Run(operation, core.RunInput{ - Source: api.Source{ - Name: request.GetSource().GetName(), - Content: request.GetSource().GetContent(), - }, - Parameters: parameters, - OutputContentType: request.GetOutputContentType(), - }) + created, err := core.Run(operation, s.runtime, resources, decodeSource(request.GetSource()), options...) if err != nil { return nil, rpcError(err) } - converted, err := execution(snapshot) + converted, err := execution(created.ID(), wireexecution.Snapshot{State: wireexecution.StateRunning}) if err != nil { return nil, rpcError(err) } diff --git a/server/internal/grpcserver/server.go b/server/internal/grpcserver/server.go index 57e6622..45f6cd7 100644 --- a/server/internal/grpcserver/server.go +++ b/server/internal/grpcserver/server.go @@ -1,6 +1,7 @@ package grpcserver import ( + "github.com/MontFerret/api" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" "github.com/MontFerret/wire/server/internal/core" "google.golang.org/grpc" @@ -15,22 +16,13 @@ type Server struct { debug *DebugService } -func New( - info core.RuntimeInfo, - connections *core.ConnectionRegistry, - compiler *core.Compiler, - executor *core.Executor, - debugger *core.Debugger, - lifecycle *core.Lifecycle, -) *Server { - operations := &operationContextFactory{connections: connections} - +func New(runtime api.Runtime, info Handshake, connections *core.ConnectionRegistry) *Server { return &Server{ - runtime: &RuntimeService{info: info, connections: connections, executor: executor, lifecycle: lifecycle, operations: operations}, - plans: &PlanService{compiler: compiler, lifecycle: lifecycle, operations: operations}, - sessions: &SessionService{executor: executor, lifecycle: lifecycle, operations: operations}, - executions: &ExecutionService{executor: executor, lifecycle: lifecycle, operations: operations}, - debug: &DebugService{debugger: debugger, lifecycle: lifecycle, operations: operations}, + runtime: &RuntimeService{runtime: runtime, info: info, connections: connections}, + plans: &PlanService{runtime: runtime, connections: connections}, + sessions: &SessionService{connections: connections}, + executions: &ExecutionService{connections: connections}, + debug: &DebugService{connections: connections}, } } diff --git a/server/internal/grpcserver/server_test.go b/server/internal/grpcserver/server_test.go index 65bbe48..3b2d8ff 100644 --- a/server/internal/grpcserver/server_test.go +++ b/server/internal/grpcserver/server_test.go @@ -5,12 +5,11 @@ import ( "testing" wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" - "github.com/MontFerret/wire/server/internal/core" "google.golang.org/grpc" ) func TestServerRegistersDedicatedProtocolServices(t *testing.T) { - server := New(core.RuntimeInfo{}, nil, nil, nil, nil, nil) + server := New(nil, Handshake{}, nil) registrar := ®istrationRecorder{services: make(map[string]any)} server.Register(registrar) diff --git a/server/internal/grpcserver/session_options.go b/server/internal/grpcserver/session_options.go new file mode 100644 index 0000000..baf0c50 --- /dev/null +++ b/server/internal/grpcserver/session_options.go @@ -0,0 +1,21 @@ +package grpcserver + +import ( + "github.com/MontFerret/api" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "github.com/MontFerret/wire/server/internal/core" +) + +func decodeSessionOptions(parameters *wirev1.Parameters, contentType string) ([]api.SessionOption, error) { + values, err := decodeParameters(parameters) + if err != nil { + return nil, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: err.Error()} + } + + options := []api.SessionOption{api.WithParams(values)} + if contentType != "" { + options = append(options, api.WithOutputContentType(contentType)) + } + + return options, nil +} diff --git a/server/internal/grpcserver/session_service.go b/server/internal/grpcserver/session_service.go index 19dc4b2..4de34e0 100644 --- a/server/internal/grpcserver/session_service.go +++ b/server/internal/grpcserver/session_service.go @@ -10,9 +10,7 @@ import ( // SessionService adapts the session RPC contract to its core owners. type SessionService struct { wirev1.UnimplementedSessionServiceServer - executor *core.Executor - lifecycle *core.Lifecycle - operations *operationContextFactory + connections *core.ConnectionRegistry } var _ wirev1.SessionServiceServer = (*SessionService)(nil) @@ -21,29 +19,30 @@ func (s *SessionService) CreateSession( ctx context.Context, request *wirev1.CreateSessionRequest, ) (*wirev1.CreateSessionResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - parameters, err := decodeParameters(request.GetParameters()) + options, err := decodeSessionOptions(request.GetParameters(), request.GetOutputContentType()) if err != nil { - return nil, rpcError(&core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: err.Error()}) + return nil, rpcError(err) + } + + parent, err := resources.Plan(operation, core.PlanID(request.GetPlanId().GetValue())) + if err != nil { + return nil, rpcError(err) } - id, err := s.executor.CreateSession(operation, core.CreateSessionInput{ - PlanID: core.PlanID(request.GetPlanId().GetValue()), - Parameters: parameters, - OutputContentType: request.GetOutputContentType(), - }) + created, err := parent.NewSession(operation, options...) if err != nil { return nil, rpcError(err) } return &wirev1.CreateSessionResponse{Session: &wirev1.Session{ - Id: &wirev1.SessionId{Value: string(id)}, + Id: &wirev1.SessionId{Value: string(created.ID())}, }}, nil } @@ -51,14 +50,14 @@ func (s *SessionService) ReleaseSession( ctx context.Context, request *wirev1.ReleaseSessionRequest, ) (*wirev1.ReleaseSessionResponse, error) { - operation, cancel, err := s.operations.New(ctx, request.GetConnectionId()) + operation, resources, cancel, err := prepareOperation(ctx, s.connections, request.GetConnectionId()) if err != nil { return nil, err } defer cancel() - if err := s.lifecycle.ReleaseSession(operation, core.SessionID(request.GetSessionId().GetValue())); err != nil { + if err := resources.ReleaseSession(operation, core.SessionID(request.GetSessionId().GetValue())); err != nil { return nil, rpcError(err) } diff --git a/server/internal/grpcserver/source_conversion.go b/server/internal/grpcserver/source_conversion.go new file mode 100644 index 0000000..dfb62ba --- /dev/null +++ b/server/internal/grpcserver/source_conversion.go @@ -0,0 +1,141 @@ +package grpcserver + +import ( + "github.com/MontFerret/api" + "github.com/MontFerret/api/diagnostics" + "github.com/MontFerret/api/source" + wirev1 "github.com/MontFerret/wire/gen/ferret/wire/v1" + "github.com/MontFerret/wire/server/internal/core" +) + +func diagnosticsToProto(values diagnostics.Diagnostics) (*wirev1.DiagnosticSet, error) { + if values == nil { + return nil, nil + } + + result := &wirev1.DiagnosticSet{Diagnostics: make([]*wirev1.Diagnostic, len(values))} + for i, value := range values { + annotations := make([]*wirev1.DiagnosticAnnotation, len(value.Annotations)) + for j, annotation := range value.Annotations { + convertedRange, err := sourceRange(annotation.Range) + if err != nil { + return nil, err + } + + if convertedRange == nil { + return nil, runtimeConversionError("runtime returned a diagnostic annotation with no range") + } + + annotations[j] = &wirev1.DiagnosticAnnotation{ + Range: convertedRange, + Message: annotation.Message, + Primary: annotation.Primary, + } + } + + result.Diagnostics[i] = &wirev1.Diagnostic{ + Kind: value.Kind.String(), + Message: value.Message, + Hint: value.Hint, + Note: value.Note, + Source: &wirev1.Source{Name: value.Source.Name, Content: value.Source.Content}, + Annotations: annotations, + } + } + + return result, nil +} + +func sourceLocation(value source.Location) (*wirev1.Location, error) { + if value == (source.Location{}) { + return nil, nil + } + + if value.SourceName == "" { + return nil, runtimeConversionError("runtime returned a source location with no source name") + } + + if value.Line <= 0 || value.Column < 0 { + return nil, runtimeConversionError("runtime returned an invalid source location") + } + + return &wirev1.Location{ + SourceName: value.SourceName, + Position: &wirev1.Position{ + Line: int64(value.Line), + Column: int64(value.Column), + }, + }, nil +} + +func sourceRange(value source.Range) (*wirev1.Range, error) { + if value == (source.Range{}) { + return nil, nil + } + + location, err := sourceLocation(value.Location) + if err != nil { + return nil, err + } + + if location == nil { + return nil, runtimeConversionError("runtime returned a source range with no location") + } + + if value.Span.Start < 0 || value.Span.End < value.Span.Start { + return nil, runtimeConversionError("runtime returned an invalid source span") + } + + return &wirev1.Range{ + Location: location, + Span: &wirev1.Span{ + Start: int64(value.Span.Start), + End: int64(value.Span.End), + }, + }, nil +} + +func sourceLocationFromProto(value *wirev1.Location, name string) (source.Location, error) { + if value == nil { + return source.Location{}, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " is required"} + } + + if value.GetSourceName() == "" { + return source.Location{}, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " source name is required"} + } + + position := value.GetPosition() + if position == nil || position.GetLine() <= 0 || position.GetColumn() < 0 { + return source.Location{}, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " position is invalid"} + } + + line, err := intFromProto(position.GetLine(), name+" line") + if err != nil { + return source.Location{}, err + } + + column, err := intFromProto(position.GetColumn(), name+" column") + if err != nil { + return source.Location{}, err + } + + return source.Location{ + SourceName: value.GetSourceName(), + Position: source.Position{ + Line: line, + Column: column, + }, + }, nil +} + +func intFromProto(value int64, name string) (int, error) { + if value < 0 || uint64(value) > uint64(^uint(0)>>1) { + return 0, &core.DomainError{Kind: core.ErrorKindInvalidRequest, Message: name + " is out of range"} + } + + return int(value), nil +} + +func decodeSource(value *wirev1.Source) api.Source { + return api.Source{Name: value.GetName(), Content: value.GetContent()} +} diff --git a/server/options.go b/server/options.go index 7d8a788..3967313 100644 --- a/server/options.go +++ b/server/options.go @@ -1,12 +1,16 @@ package server -import ( - "errors" - - "github.com/MontFerret/wire/pkg/execution" -) +import "errors" type ( + // RuntimeIdentity is optional host-supplied handshake metadata. Wire does + // not derive it from the runtime implementation or process environment. + RuntimeIdentity struct { + Name string + Version string + InstanceID string + } + // Option configures a Server without transferring host ownership. Option interface { apply(*config) error @@ -15,7 +19,7 @@ type ( serverOptionFunc func(*config) error config struct { - runtimeIdentity execution.Identity + runtimeIdentity RuntimeIdentity limits Limits } ) @@ -27,7 +31,7 @@ func (option serverOptionFunc) apply(cfg *config) error { // WithRuntimeIdentity publishes optional host application identity during the // Connect handshake. Name is required; Wire does not derive identity from the // process or environment. -func WithRuntimeIdentity(identity execution.Identity) Option { +func WithRuntimeIdentity(identity RuntimeIdentity) Option { return serverOptionFunc(func(cfg *config) error { if identity.Name == "" { return errors.New("runtime identity name is required") diff --git a/server/protocol_ownership_test.go b/server/protocol_ownership_test.go new file mode 100644 index 0000000..18aecf5 --- /dev/null +++ b/server/protocol_ownership_test.go @@ -0,0 +1,141 @@ +package server_test + +import ( + "context" + "testing" + + "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) { + limits := server.DefaultLimits() + limits.MaxPlansPerConnection = 1 + limits.MaxSessionsPerConnection = 1 + limits.MaxExecutionsPerConnection = 1 + limits.MaxDebugSessionsPerConnection = 1 + env := newIntegrationEnv(t, &contractRuntime{compile: func(context.Context, api.Source, bool, contractPlanOptions) (api.Plan, error) { + return &contractPlan{ + newSession: func(context.Context, apiSessionOptions) (api.Session, error) { + return &apiSessionSpy{}, nil + }, + newDebugSession: func(context.Context, apiSessionOptions) (debugger.Session, error) { + return &unstartedProtocolDebugger{}, nil + }, + }, nil + }}, server.WithLimits(limits)) + ctx := testContext(t) + runtimes := wirev1.NewRuntimeServiceClient(env.conn) + plans := wirev1.NewPlanServiceClient(env.conn) + sessions := wirev1.NewSessionServiceClient(env.conn) + executions := wirev1.NewExecutionServiceClient(env.conn) + debuggers := wirev1.NewDebugServiceClient(env.conn) + type resources struct { + connection *wirev1.ConnectionId + plan *wirev1.PlanId + session *wirev1.SessionId + execution *wirev1.ExecutionId + debug *wirev1.DebugSessionId + } + var owners [2]resources + for i := range owners { + connectCtx, cancel := context.WithCancel(ctx) + defer cancel() + stream, err := runtimes.Connect(connectCtx, &wirev1.ConnectRequest{}) + if err != nil { + t.Fatal(err) + } + + handshake, err := stream.Recv() + if err != nil { + t.Fatal(err) + } + + owner := &owners[i] + owner.connection = handshake.GetConnectionId() + defer func() { + if _, err := runtimes.CloseConnection(testContext(t), &wirev1.CloseConnectionRequest{ConnectionId: owner.connection}); err != nil { + t.Error(err) + } + }() + 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) + } + } + + 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) + } + + if _, err := executions.ReleaseExecution(ctx, &wirev1.ReleaseExecutionRequest{ConnectionId: connection, ExecutionId: target.execution}); status.Code(err) != codes.NotFound { + t.Fatalf("foreign or stale execution was accessible: %v", err) + } + + if _, err := debuggers.ReleaseDebugSession(ctx, &wirev1.ReleaseDebugSessionRequest{ConnectionId: connection, DebugSessionId: target.debug}); status.Code(err) != codes.NotFound { + t.Fatalf("foreign or stale debugger was accessible: %v", err) + } + } + 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) + } + + if _, err := plans.ReleasePlan(ctx, &wirev1.ReleasePlanRequest{ConnectionId: owners[0].connection, PlanId: owners[0].plan}); err != nil { + t.Fatal(err) + } + + 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) + } + + if _, err := executions.RunSession(ctx, &wirev1.RunSessionRequest{ConnectionId: owners[1].connection, SessionId: owners[1].session}); err != nil { + t.Fatalf("other connection lost its reusable session: %v", err) + } +} diff --git a/server/runtime.go b/server/runtime.go new file mode 100644 index 0000000..db56b02 --- /dev/null +++ b/server/runtime.go @@ -0,0 +1,21 @@ +package server + +import ( + "reflect" + + "github.com/MontFerret/api" +) + +func isNilRuntime(runtime api.Runtime) bool { + if runtime == nil { + return true + } + + value := reflect.ValueOf(runtime) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return value.IsNil() + default: + return false + } +} diff --git a/server/runtime_contract_test.go b/server/runtime_contract_test.go index b964858..3e4d3cc 100644 --- a/server/runtime_contract_test.go +++ b/server/runtime_contract_test.go @@ -5,8 +5,8 @@ import ( "github.com/MontFerret/wire/server" ) -// Check exact type identity as well as the public constructor signature. +// Server construction accepts the canonical runtime and server-owned identity. var ( - _ func() server.Runtime = (func() api.Runtime)(nil) - _ func(server.Runtime, ...server.Option) (*server.Server, error) = server.NewServer + _ func(server.RuntimeIdentity) server.Option = server.WithRuntimeIdentity + _ func(api.Runtime, ...server.Option) (*server.Server, error) = server.NewServer ) diff --git a/server/server.go b/server/server.go index d156a98..7133262 100644 --- a/server/server.go +++ b/server/server.go @@ -8,33 +8,30 @@ import ( "time" "github.com/MontFerret/api" - "github.com/MontFerret/wire/pkg/execution" "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" ) -type ( - // Runtime is the canonical api.Runtime contract accepted by NewServer. - // The host configures and owns the implementation; Wire never closes it. - Runtime = api.Runtime +// Server hosts Ferret Wire over a caller-supplied listener. It borrows the +// runtime passed to NewServer and never closes it. +type Server struct { + grpcServer *grpc.Server + connections *core.ConnectionRegistry - // Server hosts Ferret Wire over a caller-supplied listener. It borrows the - // runtime passed to NewServer and never closes it. - Server struct { - grpcServer *grpc.Server - lifecycle *core.Lifecycle - - serveMu sync.Mutex - serving bool - shutdown lifecycle.Close - } -) + serveMu sync.Mutex + serving bool + shutdown lifecycle.Close +} // NewServer adapts a caller-configured runtime without taking ownership // or creating a listener. Limits default to DefaultLimits. -func NewServer(runtime Runtime, options ...Option) (*Server, error) { +func NewServer(runtime api.Runtime, options ...Option) (*Server, error) { + if isNilRuntime(runtime) { + return nil, errors.New("runtime is required") + } + configured := config{limits: DefaultLimits()} for _, option := range options { if option == nil { @@ -46,45 +43,30 @@ func NewServer(runtime Runtime, options ...Option) (*Server, error) { } } - info := core.RuntimeInfo{ - ProtocolName: protocolName, - ProtocolVersion: protocolVersion, - RuntimeIdentity: execution.Identity{ - Name: configured.runtimeIdentity.Name, - Version: configured.runtimeIdentity.Version, - InstanceID: configured.runtimeIdentity.InstanceID, - }, + info := grpcserver.Handshake{ + ProtocolName: protocolName, + ProtocolVersion: protocolVersion, + RuntimeName: configured.runtimeIdentity.Name, + RuntimeVersion: configured.runtimeIdentity.Version, + RuntimeInstanceID: configured.runtimeIdentity.InstanceID, } - connections := core.NewConnectionRegistry(configured.limits.MaxConnections) - plans := core.NewPlanRegistry(configured.limits.MaxPlansPerConnection) - sessions := core.NewSessionRegistry(configured.limits.MaxSessionsPerConnection) - executions := core.NewExecutionRegistry( - configured.limits.MaxExecutionsPerConnection, - configured.limits.MaxWatchersPerResource, - ) - debugSessions := core.NewDebugSessionRegistry( - configured.limits.MaxDebugSessionsPerConnection, - configured.limits.MaxWatchersPerResource, - configured.limits.MaxBreakpointsPerDebugSession, - ) - - compiler, err := core.NewCompiler(runtime, plans) - if err != nil { - return nil, err - } - - executor := core.NewExecutor(runtime, plans, sessions, executions) - debugger := core.NewDebugger(plans, debugSessions) - lifecycleManager := core.NewLifecycle(connections, plans, sessions, executions, debugSessions) + connections := core.NewConnectionRegistry(configured.limits.MaxConnections, core.ResourceLimits{ + Plans: configured.limits.MaxPlansPerConnection, + Sessions: configured.limits.MaxSessionsPerConnection, + Executions: configured.limits.MaxExecutionsPerConnection, + DebugSessions: configured.limits.MaxDebugSessionsPerConnection, + Watchers: configured.limits.MaxWatchersPerResource, + Breakpoints: configured.limits.MaxBreakpointsPerDebugSession, + }) grpcServer := grpc.NewServer( grpc.MaxRecvMsgSize(configured.limits.MaxInboundMessageBytes), grpc.MaxSendMsgSize(configured.limits.MaxOutboundMessageBytes), grpc.UnaryInterceptor(grpcserver.UnaryRecoveryInterceptor), grpc.StreamInterceptor(grpcserver.StreamRecoveryInterceptor), ) - grpcserver.New(info, connections, compiler, executor, debugger, lifecycleManager).Register(grpcServer) + grpcserver.New(runtime, info, connections).Register(grpcServer) - return &Server{grpcServer: grpcServer, lifecycle: lifecycleManager}, nil + return &Server{grpcServer: grpcServer, connections: connections}, nil } // Serve serves the caller-owned listener until it fails, ctx is cancelled, or @@ -171,7 +153,7 @@ func (s *Server) settleShutdown(deadline time.Time) { }() } - err = s.lifecycle.Close(context.Background()) + err = s.connections.Close(context.Background()) s.grpcServer.GracefulStop() }