diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 189a761..9fca74b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -28,7 +28,7 @@ jobs: - name: Run govulncheck uses: golang/govulncheck-action@v1.1.0 with: - go-version-input: 1.25.x + go-version-input: stable check-latest: true go-package: ./... diff --git a/README.md b/README.md index 155c900..10aa238 100644 --- a/README.md +++ b/README.md @@ -40,42 +40,194 @@ and cleanup when descendants use parent-owned resources. Close is idempotent and retains the completed cleanup result, without requiring identical error-wrapper pointers. Ordinary execution owners cancel and settle -running work before closing the session. Debugger session closure terminates -and settles active commands. `Runtime.Run` owns its temporary session and plan -and returns execution and cleanup errors together, preserving available output. -Output and inspection snapshots belong to their callers. +running work and its output before reusing or closing the session, unless the +implementation explicitly supports a stronger contract. Debugger session closure +terminates and settles active commands. -`Runtime.Run` and `Session.Run` return `(*Output, error)`. Output presence is -independent of the error: +`Runtime.Run` and `Session.Run` return `(Output, error)`. A successful `Run` obtains +a usable, caller-owned handle; it does not certify execution or delivery success. +Execution starts before the handle is returned and is not deferred until consumption. +Admission/preparation failures return a nil handle and an error, preserving cleanup +failures for resources acquired before failure. Implementations must not return a +usable handle alongside a `Run` error, a typed-nil handle, or `(nil, nil)`. -| Output | Error | Meaning | +Once a usable handle is returned, terminal execution, encoding, delivery, and +output-owned cleanup errors are reported through `Consume` or `Collect`. The handle +is usable even when no content is ultimately available. Callers running scripts only +for side effects must still consume output to observe completion. `Close` is abandonment; +its success does not certify successful execution. + +`Runtime.Run` owns its temporary session and plan. Resources still needed by output +transfer to that output and are finalized by consumption or closure. Resources already +independent of consumption may be released earlier. `Session.Run` output never closes +the caller-owned session. These rules do not introduce cascading parent closure. + +## Consumable output + +The `result` package defines `Output`, `Content`, `Metadata`, and `Consumer`, also +exported as root `api` aliases: + +```text +Runtime.Run / Session.Run -> Output + Consume: receive borrowed encoded chunks + Collect: obtain detached *Content + Close: abandon and release owned resources +``` + +`Output.Metadata()` returns immutable, local metadata without I/O, including during +consumption and after closure. `Run` establishes it before exposing the handle without +waiting for or buffering the complete payload solely to determine length. `ContentType` +identifies the encoded representation. When `LengthKnown` is true, `Length` is the +nonnegative, exact total encoded payload byte count, not records, transport frames, +remaining bytes, or progress. Unknown length is normal. Known zero length does not +establish presence, and advertised length must not require unrestricted preallocation. + +`Consume` and `Collect` are alternative one-shot terminal operations. Admission validates +a non-nil context, then a non-nil consumer for `Consume`, then already-canceled consumption +and invocation contexts, before atomically claiming the handle. Invalid or rejected calls +do not claim it or disrupt another consumer. A rejected consumption context may be retried +within the invocation's lifetime. Once admitted, an operation cannot be resumed or +repeated; failure or cancellation finalizes its owned resources before return. + +Valid competing consumption calls match `result.ErrInUse` while delivery is active and +stopping has not begun. After closure or finalization begins, new valid calls match +`result.ErrClosed`. Root convenience exports `api.ErrOutputInUse` and `api.ErrOutputClosed` +reference those same sentinel values. Use `errors.Is`; wrappers and joined failures are +allowed. There is no separate consumed or finalized error. + +`Close` abandons unread output without silently draining arbitrarily large payloads. +During active consumption it requests cancellation and waits for the callback and cleanup. +If explicit closure wins before the terminal outcome is committed, the active operation +matches `ErrClosed`, preserving other observed failures. Closure after commitment waits +for cleanup without changing that outcome. Automatic finalization does not add `ErrClosed` +to the original result; caller-context cancellation retains its context error. Repeated +or concurrent `Close` calls return the recorded cleanup outcome, usually nil, rather than +a lifecycle error simply because the output was already closed. + +Callbacks are synchronous, ordered, and non-overlapping. No callback remains active or +is invoked after `Consume` returns. Chunks are borrowed, read-only byte slices valid only +during the callback; copy retained bytes. Boundaries need not align with JSON values, +lines, records, or characters. Empty chunks are not EOF. A callback error stops further +delivery and initiates finalization. Panic unwinding also finalizes resources without +swallowing the panic; cleanup failures remain available through `Close`. + +Metadata reads are safe concurrently and from callbacks. Reentrant consumption is +rejected under the same admission rules. A callback must not call `Close` synchronously +on its own output because `Close` waits for that callback. Cancellation cannot forcibly +interrupt arbitrary callback code or release borrowed buffers still in use. + +The invocation context bounds the output's lifetime after `Run` returns. A consumption +context may shorten that lifetime, not extend or revive it. The effective context passed +to callbacks observes cancellation from either context and their earliest deadline. +Keep any locally created invocation context alive until output is settled; do not defer +its cancellation in a helper that returns a live handle. + +### Content presence and errors + +`Content` is detached, recipient-owned data that survives output closure. Inspect available +content independently of the collection error: + +| Situation | `Collect` | `Consume` | | --- | --- | --- | -| nil | non-nil | No output was produced. | -| non-nil | nil | Execution succeeded, including empty output. | -| non-nil | non-nil | Output was produced, but cleanup or other processing also failed. | +| Absent content | nil content | No callback | +| Present empty content | Non-nil `*Content`, even with nil `Data` | At least one empty chunk | +| Available content plus terminal error | Content and error together | Delivered bytes and terminal error | +| Failure after receiving a prefix | Prefix and error together | Delivered prefix and error | -A non-nil `&Output{}` is present output; the zero value is not an absence -sentinel. Inspect output independently of the error: +Neither zero length, nil `Data`, nor empty content type is an absence sentinel. +`Content.Metadata` preserves the complete-payload descriptor even after partial failure; +`len(Content.Data)` counts bytes actually collected. An otherwise complete delivery or +collection that mismatches a known length must return an error. Joined errors preserve +execution, consumer, and cleanup causes through standard Go error traversal. + +Consumable delivery permits bounded buffers and reuse without requiring incremental +query evaluation or native encoding. A buffered adapter may transfer suitable detached +owned bytes directly from `Collect`; a generic chunk accumulator or another copy is not +required. Detached content must not alias borrowed or reusable implementation buffers. + +### Collecting + +This helper returns available content even when collection fails. Fallback closure also +preserves cleanup failures; `Close` is idempotent after collection. These examples use +only the portable API and have compiling counterparts in `output_example_test.go`. ```go -output, err := runtime.Run(ctx, api.NewAnonymousSource("RETURN 42")) -if output != nil { - consume(output.ContentType, output.Content) +package example + +import ( + "context" + "errors" + "fmt" + + "github.com/MontFerret/api" +) + +func collectQuery(ctx context.Context, runtime api.Runtime, src api.Source) (content *api.Content, err error) { + output, err := runtime.Run(ctx, src) + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, output.Close()) }() + return output.Collect(ctx) } -if err != nil { + +func inspectQuery(ctx context.Context, runtime api.Runtime, src api.Source) error { + content, err := collectQuery(ctx, runtime, src) + if content != nil { + fmt.Printf("%s: %q\n", content.Metadata.ContentType, content.Data) + } return err } ``` -The output fields and their serialized representation are unchanged. Transports -preserve output presence through their own representations. +### Forwarding chunks + +The destination handles each borrowed chunk synchronously. Destination errors stop +delivery; a short write without an error becomes `io.ErrShortWrite`. This helper retains +no payload. A destination may choose to buffer it, write a file, or forward it elsewhere. + +```go +package example + +import ( + "context" + "errors" + "io" + + "github.com/MontFerret/api" +) + +func streamQuery(ctx context.Context, runtime api.Runtime, src api.Source, dst io.Writer) (err error) { + output, err := runtime.Run(ctx, src) + if err != nil { + return err + } + defer func() { err = errors.Join(err, output.Close()) }() + return output.Consume(ctx, func(ctx context.Context, chunk []byte) error { + if err := ctx.Err(); err != nil { + return err + } + n, err := dst.Write(chunk) + if err != nil { + return err + } + if n != len(chunk) { + return io.ErrShortWrite + } + return nil + }) +} +``` + +For side-effect-only execution, consume with a callback that returns nil without +retaining bytes. Observe the consumption error; merely closing the handle abandons it. Non-nil caller contexts control cancellation of `Run`, `Compile`, `CompileDebug`, `NewSession`, `NewDebugSession`, `Plan.Params`, and `Runtime.Version`. All of these operations require a non-nil context. Cancellation errors preserve `context.Canceled` and `context.DeadlineExceeded` through `errors.Is`. -Implementations need not derive operation contexts to coordinate parent Close. -They may use internal contexts for their own resources and may translate portable +Parent Close does not require deriving operation contexts to coordinate descendants. +Implementations may use internal contexts for their own resources and may translate portable option callbacks before validating the operation context. ## Options @@ -98,6 +250,9 @@ resource acquisition, or execution. `Runtime.Run` may compile, create a session, then execute. Output codec availability may be validated during result encoding, after the query has run. Implementations document validation timing and when mutable inputs are converted or snapshotted. +`WithOutputContentType` and `SetOutputContentType` retain their names. The selected +representation is reported by `Output.Metadata().ContentType`; encoding failures after +a usable handle is returned are observed through `Consume` or `Collect`. `WithOptimizationLevel` rejects values outside the portable enum during callback application. Each runtime defines which known optimization levels it supports @@ -108,9 +263,10 @@ and any restrictions for debug compilation. Native Ferret produces one-based lines and byte columns, with zero-based, half-open byte spans. Source names are identities and need not be filesystem paths; anonymous sources have an empty name. Adapters translate native indexed -source text into the portable `Source` representation. Portable coordinates, -encoded output, debugger values, variables, frames, breakpoints, reasons, and -events preserve their existing fields and JSON representations. +source text into the portable `Source` representation. Portable coordinates, debugger +values, variables, frames, breakpoints, and reasons retain their existing representations. +Encoded content uses the new nested metadata shape described below. Debugger events +retain their field names, including `output`, whose value is materialized `*Content`. `debugger.ValueReference.Valid` accepts positive references. References are scoped to a paused state and become stale when execution resumes. `debugger.NoFunction` @@ -135,6 +291,38 @@ A canceled pause request must not request a stop. including after Close. Nil and canceled contexts return errors. Metadata and listing errors can be reported without conflating failure with an empty result. -A completed event and its output can accompany a later cleanup error. Error projections should preserve each -diagnostic's source, annotation order, joined branches, and native causes through -standard Go error traversal. +A completed event and its detached content can accompany a later cleanup error. +Retained events and snapshots must not store live output handles or mutable implementation +buffers; their bytes remain readable after debugger cleanup. Inspecting one observer's +event cannot consume another observer's data. Recipients coordinate mutations of shared +materialized content themselves. Error projections should preserve each diagnostic's +source, annotation order, joined branches, and native causes through standard Go error +traversal. +`Event.Error` remains a Go error; this change defines no portable JSON error codec. +Adapters continue to own serialized error projections. + +## Migration from materialized output + +This is an intentional breaking change to both the Go API and encoded-content JSON: + +| Previous API | Current API | +| --- | --- | +| Materialized `result.Output` / `api.Output` struct | Detached `result.Content` / `api.Content` struct | +| `Run(...) (*Output, error)` | `Run(...) (Output, error)`, followed by `Consume` or `Collect` | +| `output.ContentType` | `output.Metadata().ContentType` or `content.Metadata.ContentType` | +| Materialized payload field `Content` | `Content.Data` | +| Flat `contentType` and `content` JSON keys | Nested `metadata` object and `data` key | +| Execution/cleanup result observed at `Run` return | Handle admission at `Run`; terminal result through consumption | +| Debugger event `Output` containing old output struct | Same field/key containing detached `*Content` | + +For example, populated materialized content serializes as: + +```json +{"metadata":{"contentType":"application/json","length":2,"lengthKnown":true},"data":"NDI="} +``` + +Payload bytes use standard base64 byte-slice encoding; they are not interpreted as a +JSON document. A nil `*Content` serializes as `null`. A present content object with nil +`Data` has `"data":null`; a non-nil empty byte slice has `"data":""`. All descriptor fields +remain present, including unknown length. Old JSON keys are not emitted. Live output +handles must not be serialized, and marshaling must never consume them. diff --git a/debugger/content_test.go b/debugger/content_test.go new file mode 100644 index 0000000..67330b4 --- /dev/null +++ b/debugger/content_test.go @@ -0,0 +1,79 @@ +package debugger_test + +import ( + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MontFerret/api/debugger" + "github.com/MontFerret/api/result" +) + +var _ *result.Content = debugger.Event{}.Output + +func TestEventJSONRetainsMaterializedContentAndPresence(t *testing.T) { + for _, tc := range []struct { + name string + content *result.Content + json string + }{ + {name: "absent", json: `null`}, + {name: "zero-valued present", content: &result.Content{}, json: `{"metadata":{"contentType":"","length":0,"lengthKnown":false},"data":null}`}, + {name: "present empty slice", content: &result.Content{Data: []byte{}}, json: `{"metadata":{"contentType":"","length":0,"lengthKnown":false},"data":""}`}, + {name: "populated", content: &result.Content{Metadata: result.Metadata{ContentType: "application/octet-stream", Length: 2, LengthKnown: true}, Data: []byte{0xff, 0}}, json: `{"metadata":{"contentType":"application/octet-stream","length":2,"lengthKnown":true},"data":"/wA="}`}, + } { + t.Run(tc.name, func(t *testing.T) { + want := debugger.Event{Output: tc.content, Reason: debugger.ReasonCompleted} + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if string(fields["output"]) != tc.json { + t.Fatalf("event output JSON = %s, want %s", fields["output"], tc.json) + } + if len(fields) != 6 { + t.Fatalf("event fields = %v, want existing six fields", fields) + } + for _, key := range []string{"error", "output", "reason", "hitBreakpointIDs", "location", "depth"} { + if _, ok := fields[key]; !ok { + t.Fatalf("event JSON lost key %q", key) + } + } + var got debugger.Event + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round-trip event = %#v, want %#v", got, want) + } + }) + } +} + +func TestEventJSONIncludesContentAlongsideTerminalError(t *testing.T) { + // The existing error field has no portable JSON error codec. Verify content + // serialization with a non-nil error without claiming error round-tripping + // or adapter retention, cloning, or cleanup behavior. + failure := errors.New("debugger completion cleanup failure") + content := &result.Content{ + Metadata: result.Metadata{ContentType: "application/json", Length: 2, LengthKnown: true}, + Data: []byte("4"), + } + event := debugger.Event{Output: content, Error: failure, Reason: debugger.ReasonCompleted} + data, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if string(fields["output"]) != `{"metadata":{"contentType":"application/json","length":2,"lengthKnown":true},"data":"NA=="}` || string(fields["error"]) == "null" { + t.Fatalf("event JSON lost partial content or error: %s", data) + } +} diff --git a/debugger/doc.go b/debugger/doc.go index d6caffc..0418644 100644 --- a/debugger/doc.go +++ b/debugger/doc.go @@ -1,4 +1,7 @@ // Package debugger defines portable contracts and values for controlling and // inspecting Ferret debug sessions, including events, breakpoints, frames, and // values. +// Retained event output is detached result.Content, preserving absent versus +// present empty data and remaining readable without consuming another observer's +// output or retaining a live result.Output handle. package debugger diff --git a/debugger/session.go b/debugger/session.go index 698f535..cb56653 100644 --- a/debugger/session.go +++ b/debugger/session.go @@ -15,7 +15,9 @@ import ( // repeated closes retain the cleanup result without requiring identical // error-wrapper pointers. Inspection references expire on resume. // A command can return an event and an error, including available completion -// output when subsequent cleanup fails. Context arguments must be non-nil. +// content when subsequent cleanup fails. Event.Output holds detached materialized +// Content that remains readable after cleanup; inspecting a retained event never +// consumes a live output handle. Context arguments must be non-nil. // Inspection checks cancellation before and after command admission; cancellation // need not interrupt the admission wait. A canceled Pause must not request a stop. // Breakpoint mutations observe their request context through publication; canceling diff --git a/debugger/types.go b/debugger/types.go index f60f801..0734340 100644 --- a/debugger/types.go +++ b/debugger/types.go @@ -66,13 +66,21 @@ type ( } // Event reports a debugger stop, completion, or termination. + // Retained data is materialized; reading an event does not consume another + // observer's output. Available completion content may accompany an error. Event struct { - Error error `json:"error"` - Output *result.Output `json:"output"` - Reason Reason `json:"reason"` - HitBreakpointIDs []BreakpointID `json:"hitBreakpointIDs"` - Location source.Range `json:"location"` - Depth int `json:"depth"` + Error error `json:"error"` + + // Output is detached encoded content, not a live consumable handle. + // Nil means absent; a non-nil pointer means present, including empty data. + // Retained snapshots must remain valid after debugger cleanup and must + // not share mutable implementation buffers. Recipients coordinate any + // mutation of shared Content themselves. + Output *result.Content `json:"output"` + Reason Reason `json:"reason"` + HitBreakpointIDs []BreakpointID `json:"hitBreakpointIDs"` + Location source.Range `json:"location"` + Depth int `json:"depth"` } ) diff --git a/doc.go b/doc.go index d25d668..87c7ae3 100644 --- a/doc.go +++ b/doc.go @@ -1,6 +1,13 @@ // Package api defines implementation-independent contracts for compiling, // executing, and debugging Ferret queries. // +// Runtime.Run and Session.Run return caller-owned, one-shot Output handles. +// A successful Run obtains a usable handle; Consume or Collect observes terminal +// execution, delivery, and cleanup errors. Close abandons unread output. Invocation +// contexts remain in effect until output is settled. Output.Metadata is immutable +// and local; Content is detached materialized data, also used by debugger events. +// See result.Output for consumption, presence, cancellation, and closure contracts. +// // Plan.Params and Runtime.Version retrieve metadata and may involve remote I/O. // Both require non-nil caller contexts, and cancellation errors must preserve // context.Canceled and context.DeadlineExceeded through errors.Is. The API imposes diff --git a/options.go b/options.go index db974ca..5ea63af 100644 --- a/options.go +++ b/options.go @@ -16,7 +16,7 @@ type ( // // Invalid settings must fail the operation no later than their relevant point of use. // Validation need not precede compilation, resource acquisition, or all query - // execution. Output codec availability may be checked during result encoding, + // execution. Content codec availability may be checked during result encoding, // after the query has run. Implementations document validation and mutable-input // conversion or snapshot timing. // @@ -25,7 +25,12 @@ type ( SessionOptions interface { SetParam(string, any) error SetParams(map[string]any) error + + // SetOutputContentType selects the encoded representation's media type, + // reported by Output.Metadata().ContentType. Codec availability may be + // validated during encoding and then reported through output consumption. SetOutputContentType(string) error + SetFSRoot(string) error } @@ -72,6 +77,8 @@ func WithParams(params map[string]any) SessionOption { // WithOutputContentType selects the output codec content type for session results. // Codec availability may be checked when output is encoded, after query execution. +// The selected representation is described by Output.Metadata().ContentType; +// encoding failures after a usable handle is returned belong to Consume or Collect. func WithOutputContentType(contentType string) SessionOption { return func(opts SessionOptions) error { return opts.SetOutputContentType(contentType) diff --git a/output_contract_fixture_test.go b/output_contract_fixture_test.go index af41639..631eb80 100644 --- a/output_contract_fixture_test.go +++ b/output_contract_fixture_test.go @@ -2,32 +2,47 @@ package api_test import ( "context" + "errors" "github.com/MontFerret/api" ) type ( outputRuntime struct { - output *api.Output + output api.Output err error } outputSession struct { - output *api.Output + output api.Output err error } + + // scriptedOutput supplies predetermined outcomes to caller examples. It is + // deliberately not a state machine or an adapter conformance implementation: + // these tests verify caller handling, not one-shot, lifetime, or cleanup rules. + scriptedOutput struct { + content *api.Content + chunks [][]byte + terminalErr error + cleanupErr error + collectCalls int + consumeCalls int + closeCalls int + } ) var ( _ api.Runtime = (*outputRuntime)(nil) _ api.Session = (*outputSession)(nil) + _ api.Output = (*scriptedOutput)(nil) ) func (r *outputRuntime) Version(context.Context) (api.Version, error) { return api.Version(""), nil } -func (r *outputRuntime) Run(context.Context, api.Source, ...api.SessionOption) (*api.Output, error) { +func (r *outputRuntime) Run(context.Context, api.Source, ...api.SessionOption) (api.Output, error) { return r.output, r.err } @@ -43,10 +58,41 @@ func (r *outputRuntime) Close() error { return nil } -func (s *outputSession) Run(context.Context) (*api.Output, error) { +func (s *outputSession) Run(context.Context) (api.Output, error) { return s.output, s.err } func (s *outputSession) Close() error { return nil } + +func (o *scriptedOutput) Metadata() api.Metadata { + if o.content == nil { + return api.Metadata{} + } + return o.content.Metadata +} + +func (o *scriptedOutput) Consume(ctx context.Context, consumer api.Consumer) error { + o.consumeCalls++ + chunks := o.chunks + if chunks == nil && o.content != nil { + chunks = [][]byte{o.content.Data} + } + for _, chunk := range chunks { + if err := consumer(ctx, chunk); err != nil { + return errors.Join(err, o.terminalErr, o.cleanupErr) + } + } + return errors.Join(o.terminalErr, o.cleanupErr) +} + +func (o *scriptedOutput) Collect(context.Context) (*api.Content, error) { + o.collectCalls++ + return o.content, errors.Join(o.terminalErr, o.cleanupErr) +} + +func (o *scriptedOutput) Close() error { + o.closeCalls++ + return o.cleanupErr +} diff --git a/output_contract_test.go b/output_contract_test.go index febf5b5..f368ee7 100644 --- a/output_contract_test.go +++ b/output_contract_test.go @@ -1,44 +1,106 @@ package api_test import ( + "context" "errors" + "fmt" "testing" "github.com/MontFerret/api" + "github.com/MontFerret/api/debugger" + "github.com/MontFerret/api/result" ) -func TestExecutionPreservesOutputPresence(t *testing.T) { - failure := errors.New("execution or cleanup failed") - populated := &api.Output{ContentType: "application/json", Content: []byte("42")} +// Method expressions catch pointer-to-interface execution signatures and stale +// buffered return types. Assignments in both directions require exact aliases. +var ( + _ func(api.Runtime, context.Context, api.Source, ...api.SessionOption) (api.Output, error) = api.Runtime.Run + _ func(api.Session, context.Context) (api.Output, error) = api.Session.Run + _ func(api.Output) api.Metadata = api.Output.Metadata + _ func(api.Output, context.Context, api.Consumer) error = api.Output.Consume + _ func(api.Output, context.Context) (*api.Content, error) = api.Output.Collect + _ func(api.Output) error = api.Output.Close + _ func(result.Output, context.Context, result.Consumer) error = api.Output.Consume + _ func(api.Output, context.Context) (*api.Content, error) = result.Output.Collect + _ api.Metadata = result.Metadata{} + _ result.Metadata = api.Metadata{} + _ *api.Content = (*result.Content)(nil) + _ *result.Content = (*api.Content)(nil) + _ api.Consumer = result.Consumer(nil) + _ result.Consumer = api.Consumer(nil) + _ func(context.Context, []byte) error = api.Consumer(nil) + _ *api.Content = debugger.Event{}.Output +) + +// The module declares interfaces only. This matrix verifies the collection +// example's caller behavior with scripted outcomes, not adapter conformance. +func TestCollectQueryPreservesContentPresenceAndErrors(t *testing.T) { + failure := errors.New("terminal execution failure") + cleanupFailure := errors.New("output cleanup failure") + populated := &api.Content{ + Metadata: api.Metadata{ContentType: "application/json", Length: 2, LengthKnown: true}, + Data: []byte("42"), + } + prefix := &api.Content{Metadata: populated.Metadata, Data: []byte("4")} for _, tc := range []struct { - name string - output *api.Output - err error + name string + content *api.Content + terminal error + cleanupErr error }{ - {name: "absent output", err: failure}, - {name: "zero-valued output", output: &api.Output{}}, - {name: "zero-valued output with error", output: &api.Output{}, err: failure}, - {name: "populated output", output: populated}, - {name: "populated output with error", output: populated, err: failure}, + {name: "absent"}, + {name: "absent with error", terminal: failure}, + {name: "zero-valued present", content: &api.Content{}}, + {name: "zero-valued present with error", content: &api.Content{}, terminal: failure}, + {name: "present empty slice", content: &api.Content{Data: []byte{}}}, + {name: "populated", content: populated}, + {name: "populated with error", content: populated, terminal: failure}, + {name: "prefix with error", content: prefix, terminal: failure}, + {name: "cleanup failure", content: populated, cleanupErr: cleanupFailure}, + {name: "multiple failures", content: prefix, terminal: failure, cleanupErr: cleanupFailure}, } { t.Run(tc.name, func(t *testing.T) { - var runtime api.Runtime = &outputRuntime{output: tc.output, err: tc.err} - var session api.Session = &outputSession{output: tc.output, err: tc.err} - - t.Run("runtime", func(t *testing.T) { - output, err := runtime.Run(t.Context(), api.NewAnonymousSource("RETURN 42")) - if output != tc.output || err != tc.err { - t.Fatalf("got output=%p err=%v, want output=%p err=%v", output, err, tc.output, tc.err) - } - }) - - t.Run("session", func(t *testing.T) { - output, err := session.Run(t.Context()) - if output != tc.output || err != tc.err { - t.Fatalf("got output=%p err=%v, want output=%p err=%v", output, err, tc.output, tc.err) + output := &scriptedOutput{content: tc.content, terminalErr: tc.terminal, cleanupErr: tc.cleanupErr} + content, err := collectQuery(t.Context(), &outputRuntime{output: output}, api.NewAnonymousSource("RETURN 42")) + if content != tc.content { + t.Fatalf("content = %p, want available content %p", content, tc.content) + } + if tc.terminal == nil && tc.cleanupErr == nil && err != nil { + t.Fatalf("unexpected collection error: %v", err) + } + for _, cause := range []error{tc.terminal, tc.cleanupErr} { + if cause != nil && !errors.Is(err, cause) { + t.Fatalf("collection error %v lost cause %v", err, cause) } - }) + } + if output.collectCalls != 1 || output.consumeCalls != 0 || output.closeCalls != 1 { + t.Fatalf("caller calls: collect=%d consume=%d fallback close=%d", output.collectCalls, output.consumeCalls, output.closeCalls) + } }) } } + +func TestOutputSentinelsShareIdentityAndMatchThroughTraversal(t *testing.T) { + if api.ErrOutputInUse != result.ErrInUse || api.ErrOutputClosed != result.ErrClosed { + t.Fatal("root exports must reference the canonical result sentinels") + } + other := errors.New("another observed failure") + for _, sentinel := range []error{api.ErrOutputInUse, api.ErrOutputClosed} { + for _, err := range []error{sentinel, fmt.Errorf("consume: %w", sentinel), errors.Join(other, fmt.Errorf("collect: %w", sentinel))} { + if !errors.Is(err, sentinel) { + t.Fatalf("errors.Is(%v, %v) = false", err, sentinel) + } + } + if errors.Is(errors.New(sentinel.Error()), sentinel) { + t.Fatal("matching text must not substitute for sentinel identity") + } + } + joined := errors.Join(api.ErrOutputClosed, other) + if !errors.Is(joined, api.ErrOutputClosed) || !errors.Is(joined, other) { + t.Fatal("joined closure failure lost an observed cause") + } + if errors.Is(api.ErrOutputInUse, api.ErrOutputClosed) || errors.Is(api.ErrOutputClosed, api.ErrOutputInUse) { + t.Fatal("in-use and closed sentinels must be distinct") + } +} diff --git a/output_example_test.go b/output_example_test.go new file mode 100644 index 0000000..633774e --- /dev/null +++ b/output_example_test.go @@ -0,0 +1,81 @@ +package api_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + + "github.com/MontFerret/api" +) + +// collectQuery illustrates obtaining a handle, establishing fallback closure, +// and returning available content independently of the terminal error. +func collectQuery(ctx context.Context, runtime api.Runtime, src api.Source) (content *api.Content, err error) { + output, err := runtime.Run(ctx, src) + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, output.Close()) }() + return output.Collect(ctx) +} + +// streamQuery illustrates forwarding borrowed chunks without retaining them. +// The writer must complete each write before returning from the callback. +func streamQuery(ctx context.Context, runtime api.Runtime, src api.Source, dst io.Writer) (err error) { + output, err := runtime.Run(ctx, src) + if err != nil { + return err + } + defer func() { err = errors.Join(err, output.Close()) }() + return output.Consume(ctx, func(ctx context.Context, chunk []byte) error { + if err := ctx.Err(); err != nil { + return err + } + n, err := dst.Write(chunk) + if err != nil { + return err + } + if n != len(chunk) { + return io.ErrShortWrite + } + return nil + }) +} + +func ExampleOutput_Collect() { + // An application supplies its native or remote Runtime. This scripted runtime + // illustrates caller handling of available content plus a terminal error. + failure := errors.New("execution failed after a prefix") + runtime := &outputRuntime{output: &scriptedOutput{ + content: &api.Content{ + Metadata: api.Metadata{ContentType: "application/json", Length: 2, LengthKnown: true}, + Data: []byte("4"), + }, + terminalErr: failure, + }} + content, err := collectQuery(context.Background(), runtime, api.NewAnonymousSource("RETURN 42")) + if content != nil { + fmt.Printf("%s: %q\n", content.Metadata.ContentType, content.Data) + } + if err != nil { + fmt.Println("terminal failure:", errors.Is(err, failure)) + } + // Output: + // application/json: "4" + // terminal failure: true +} + +func ExampleOutput_Consume() { + // Chunk boundaries are arbitrary. The example destination retains bytes so + // its result can be shown; streamQuery itself does not accumulate the payload. + runtime := &outputRuntime{output: &scriptedOutput{chunks: [][]byte{[]byte("4"), []byte("2")}}} + var dst bytes.Buffer + if err := streamQuery(context.Background(), runtime, api.NewAnonymousSource("RETURN 42"), &dst); err != nil { + fmt.Println(err) + return + } + fmt.Println(dst.String()) + // Output: 42 +} diff --git a/output_examples_test.go b/output_examples_test.go new file mode 100644 index 0000000..3b1b160 --- /dev/null +++ b/output_examples_test.go @@ -0,0 +1,84 @@ +package api_test + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/MontFerret/api" +) + +type writerFunc func([]byte) (int, error) + +func (f writerFunc) Write(data []byte) (int, error) { return f(data) } + +// These tests exercise the example's writer and error handling, not the output +// adapter's lifecycle. Scripted delivery is intentional; adapter tests belong +// in the native and remote implementation repositories. +func TestStreamQueryHandlesWriterAndTerminalFailures(t *testing.T) { + destinationFailure := errors.New("destination failure") + executionFailure := errors.New("terminal execution failure") + cleanupFailure := errors.New("output cleanup failure") + for _, tc := range []struct { + name string + writer io.Writer + terminalErr error + cleanupErr error + causes []error + wantWrites int + }{ + {name: "complete writes", writer: io.Discard, wantWrites: 2}, + {name: "short write", writer: writerFunc(func(data []byte) (int, error) { return len(data) - 1, nil }), causes: []error{io.ErrShortWrite}, wantWrites: 1}, + {name: "writer failure", writer: writerFunc(func([]byte) (int, error) { return 0, destinationFailure }), causes: []error{destinationFailure}, wantWrites: 1}, + {name: "partial write with cause", writer: writerFunc(func(data []byte) (int, error) { return len(data) - 1, destinationFailure }), causes: []error{destinationFailure}, wantWrites: 1}, + {name: "terminal failure", writer: io.Discard, terminalErr: executionFailure, causes: []error{executionFailure}, wantWrites: 2}, + {name: "cleanup failure", writer: io.Discard, cleanupErr: cleanupFailure, causes: []error{cleanupFailure}, wantWrites: 2}, + {name: "multiple failures", writer: writerFunc(func([]byte) (int, error) { return 0, destinationFailure }), terminalErr: executionFailure, cleanupErr: cleanupFailure, causes: []error{destinationFailure, executionFailure, cleanupFailure}, wantWrites: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + output := &scriptedOutput{chunks: [][]byte{[]byte("first"), []byte("second")}, terminalErr: tc.terminalErr, cleanupErr: tc.cleanupErr} + writes := 0 + dst := writerFunc(func(data []byte) (int, error) { + writes++ + return tc.writer.Write(data) + }) + err := streamQuery(t.Context(), &outputRuntime{output: output}, api.NewAnonymousSource("RETURN 42"), dst) + if len(tc.causes) == 0 && err != nil { + t.Fatalf("unexpected forwarding error: %v", err) + } + for _, cause := range tc.causes { + if !errors.Is(err, cause) { + t.Fatalf("forwarding error %v lost cause %v", err, cause) + } + } + if writes != tc.wantWrites || output.consumeCalls != 1 || output.collectCalls != 0 || output.closeCalls != 1 { + t.Fatalf("caller calls: writes=%d consume=%d collect=%d fallback close=%d", writes, output.consumeCalls, output.collectCalls, output.closeCalls) + } + }) + } +} + +func TestStreamQueryReconstructsChunks(t *testing.T) { + var dst bytes.Buffer + output := &scriptedOutput{chunks: [][]byte{{0, 0xff}, {}, {'x'}, {0x80, '\n'}}} + err := streamQuery(t.Context(), &outputRuntime{output: output}, api.NewAnonymousSource("RETURN 42"), &dst) + if err != nil { + t.Fatal(err) + } + if want := []byte{0, 0xff, 'x', 0x80, '\n'}; !bytes.Equal(dst.Bytes(), want) { + t.Fatalf("forwarded bytes = %v, want %v", dst.Bytes(), want) + } +} + +func TestQueryExamplesReturnPreparationErrors(t *testing.T) { + failure := errors.New("preparation failure") + runtime := &outputRuntime{err: failure} + content, err := collectQuery(t.Context(), runtime, api.NewAnonymousSource("RETURN 42")) + if content != nil || !errors.Is(err, failure) { + t.Fatalf("collection preparation result = (%v, %v)", content, err) + } + if err := streamQuery(t.Context(), runtime, api.NewAnonymousSource("RETURN 42"), io.Discard); !errors.Is(err, failure) { + t.Fatalf("streaming preparation error = %v", err) + } +} diff --git a/result/doc.go b/result/doc.go index ce60572..0b88c24 100644 --- a/result/doc.go +++ b/result/doc.go @@ -1,3 +1,12 @@ -// Package result defines portable encoded outputs returned by Ferret query -// execution. +// Package result defines consumable encoded Output handles and detached Content. +// +// Output is live and one-shot: Consume receives borrowed chunks, Collect obtains +// detached materialized bytes, and Close abandons unread output. Metadata describes +// the complete encoded representation locally and remains unchanged after closure. +// A usable handle does not imply content presence or completed execution; terminal +// errors and any available content are observed during consumption. +// +// This package declares contracts and data, not an execution or transport adapter. +// Consumable delivery permits bounded buffers without requiring incremental query +// evaluation or encoding. Only detached Content and Metadata are serialized. package result diff --git a/result/errors.go b/result/errors.go new file mode 100644 index 0000000..6af7a10 --- /dev/null +++ b/result/errors.go @@ -0,0 +1,18 @@ +package result + +import "errors" + +var ( + // ErrInUse indicates that another Consume or Collect operation already owns + // consumption and stopping has not begun. The rejected call leaves it unaffected. + // Implementations may wrap or join this error; callers use errors.Is. + ErrInUse = errors.New("output is already being consumed") + + // ErrClosed indicates that closure or finalization has begun or completed and + // the output no longer accepts consumption. It also identifies active consumption + // interrupted by explicit Close when closure wins before outcome commitment. + // Automatic finalization does not add it to the original consumption result, + // and repeated Close returns the cleanup outcome rather than this lifecycle error. + // Implementations may wrap or join this error; callers use errors.Is. + ErrClosed = errors.New("output is closed") +) diff --git a/result/types.go b/result/types.go index 0b9bddc..cfc5e09 100644 --- a/result/types.go +++ b/result/types.go @@ -1,8 +1,140 @@ package result +import ( + "context" + "io" +) + type ( - Output struct { + // Metadata describes the encoded representation of a complete payload. + // It does not establish whether content is present. Output metadata is an + // immutable descriptor, not a remaining-byte count or progress counter. + Metadata struct { + // ContentType identifies the encoded representation's media type. ContentType string `json:"contentType"` - Content []byte `json:"content"` + + // Length is the exact total payload length in bytes, as exposed + // to the consumer, not a record count or transport-frame size. + // It is meaningful only when LengthKnown is true. Implementations + // must not require unrestricted preallocation based on this value. + Length int64 `json:"length"` + + // LengthKnown distinguishes unknown length from known zero length. + // Unknown length is normal; it must not require buffering the payload + // to discover its size. When true, Length must be nonnegative. + // Known zero length does not establish content presence. + LengthKnown bool `json:"lengthKnown"` + } + + // Content is detached, materialized encoded data owned by its recipient. + // It remains valid after the Output and its resources are closed. A nil + // *Content means no content is available; a non-nil pointer, including + // &Content{}, means content is present regardless of metadata or data length. + // + // JSON nests the descriptor under "metadata" and encodes Data under "data" + // using the standard byte-slice representation, without interpreting the + // payload as JSON. Nil and empty Data slices encode as null and "", respectively; + // neither means the containing Content is absent. + Content struct { + // Metadata preserves the original complete-payload descriptor, even + // when Data contains only a prefix received before failure. + Metadata Metadata `json:"metadata"` + + // Data contains the bytes actually collected. On successful complete + // collection, its length must match Metadata.Length when known. + Data []byte `json:"data"` + } + + // Consumer receives ordered encoded chunks synchronously and without overlap. + // Chunks are borrowed, read-only, and valid only during the call; copy bytes + // that must be retained. Boundaries need not align with values, lines, records, + // or character boundaries. Empty chunks are not EOF. + // + // ctx is the effective consumption context, bounded by both the invocation + // and consumption contexts. Returning an error stops further delivery and + // initiates finalization. Cancellation cannot forcibly interrupt arbitrary + // callback code or invalidate borrowed buffers while the callback uses them. + Consumer func(ctx context.Context, chunk []byte) error + + // Output is a caller-owned, one-shot handle to encoded query output. + // Consume and Collect are alternative terminal operations. A handle is usable + // even when no content is available; content presence is determined during + // consumption, not by the handle, metadata, or byte-slice length. + // + // Execution starts before the handle is returned; it is not deferred until + // consumption. Terminal execution, encoding, delivery, and output-owned cleanup + // errors are observed through Consume or Collect. Callers running queries only + // for side effects must still consume to observe completion. Streaming-capable + // consumption does not require incremental query evaluation or native encoding. + // Serialize detached Content, not a live Output handle. + // + // Consumption admission validates a non-nil context, then a non-nil Consumer + // for Consume, then cancellation of the consumption and invocation contexts, + // before atomically claiming the handle. Invalid or already-canceled calls + // return errors without claiming it or disturbing another consumer. A fresh + // context can retry a rejected call only within the invocation's lifetime. + // After admission, cancellation or any other failure finalizes the output; + // consumption cannot be resumed or repeated. + // + // Valid competing consumption attempts match ErrInUse while another consumer + // owns delivery and stopping has not begun. Once closure or finalization begins, + // new attempts match ErrClosed. Rejected calls never disrupt active consumption. + // Metadata may be read concurrently, including from a consumer. Reentrant + // consumption is rejected by the same admission rules. A consumer must not call + // Close synchronously on this output because Close waits for that callback. + // + // The invocation context bounds the output's lifetime. The consumption context + // may shorten but never extend or revive it. The effective context observes both + // cancellations and their earliest deadline. Cancellation errors preserve + // context.Canceled and context.DeadlineExceeded through errors.Is. + // + // Admitted operations finalize owned resources before returning, including on + // failure and during consumer panic unwinding, without swallowing the panic. + // Errors preserve all observed execution, consumer, and cleanup causes through + // standard error traversal. Cleanup errors remain available through Close. + Output interface { + // Close abandons unread output without draining arbitrarily large content + // and releases owned resources. It does not certify successful execution. + // During active consumption it requests cancellation and waits for the + // active callback and finalization; no borrowed buffer is released early. + // + // If explicit closure wins before the consumption outcome is committed, + // that operation's error matches ErrClosed and preserves other observed + // failures. Closure after commitment only waits for cleanup. Automatic + // finalization does not itself add ErrClosed to the original operation; + // caller-context cancellation remains identifiable as a context error. + // + // Close is safe concurrently and idempotent, returning the recorded cleanup + // outcome, usually nil, even after finalization. Repeated calls need not + // return identical error-wrapper pointers. Being closed is not itself a + // Close error. It does not close caller-owned sessions or borrowed parents. + io.Closer + + // Metadata returns the immutable complete-payload descriptor locally, + // without I/O, including during consumption and after closure. Run must + // establish it before exposing the handle without waiting for or buffering + // the complete payload solely to determine length. + Metadata() Metadata + + // Consume delivers borrowed chunks according to the admission and lifetime + // rules above, then finalizes resources before returning its terminal error. + // No callback remains active or is invoked after return. Absent content + // invokes no callback; present empty content invokes at least one empty + // chunk. A consumer error stops further delivery and is preserved alongside + // other observed failures. An otherwise complete delivery whose total bytes + // differ from a known length must return an error. + Consume(ctx context.Context, consumer Consumer) error + + // Collect materializes detached content under the same admission, lifetime, + // and finalization rules as Consume. Nil content means none is available; + // present empty content returns a non-nil pointer. Available content, + // including a received prefix, is returned alongside any terminal error. + // Metadata remains the original descriptor; len(Data) is the collected size. + // Complete successful collection must match a known length. + // + // Implementations may transfer suitable owned bytes directly rather than + // copying them through a generic chunk accumulator. Detached data must not + // alias borrowed or reusable implementation buffers. + Collect(ctx context.Context) (*Content, error) } ) diff --git a/result/types_test.go b/result/types_test.go new file mode 100644 index 0000000..21af3fc --- /dev/null +++ b/result/types_test.go @@ -0,0 +1,87 @@ +package result_test + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" + + "github.com/MontFerret/api/result" +) + +func TestContentJSONShapeAndPresence(t *testing.T) { + for _, tc := range []struct { + name string + content *result.Content + json string + }{ + {name: "absent", json: `null`}, + {name: "zero-valued present", content: &result.Content{}, json: `{"metadata":{"contentType":"","length":0,"lengthKnown":false},"data":null}`}, + {name: "present empty slice", content: &result.Content{Data: []byte{}}, json: `{"metadata":{"contentType":"","length":0,"lengthKnown":false},"data":""}`}, + {name: "known empty with nil data", content: &result.Content{Metadata: result.Metadata{ContentType: "application/json", LengthKnown: true}}, json: `{"metadata":{"contentType":"application/json","length":0,"lengthKnown":true},"data":null}`}, + {name: "known empty with empty slice", content: &result.Content{Metadata: result.Metadata{ContentType: "application/json", LengthKnown: true}, Data: []byte{}}, json: `{"metadata":{"contentType":"application/json","length":0,"lengthKnown":true},"data":""}`}, + {name: "populated", content: &result.Content{Metadata: result.Metadata{ContentType: "application/json", Length: 2, LengthKnown: true}, Data: []byte("42")}, json: `{"metadata":{"contentType":"application/json","length":2,"lengthKnown":true},"data":"NDI="}`}, + {name: "unknown length", content: &result.Content{Metadata: result.Metadata{ContentType: "application/json"}, Data: []byte("42")}, json: `{"metadata":{"contentType":"application/json","length":0,"lengthKnown":false},"data":"NDI="}`}, + {name: "partial content preserves descriptor", content: &result.Content{Metadata: result.Metadata{ContentType: "application/json", Length: 2, LengthKnown: true}, Data: []byte("4")}, json: `{"metadata":{"contentType":"application/json","length":2,"lengthKnown":true},"data":"NA=="}`}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := json.Marshal(tc.content) + if err != nil { + t.Fatal(err) + } + if string(data) != tc.json { + t.Fatalf("content JSON = %s, want %s", data, tc.json) + } + var decoded *result.Content + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decoded, tc.content) { + t.Fatalf("decoded content = %#v, want %#v", decoded, tc.content) + } + }) + } +} + +func TestContentJSONRoundTripsArbitraryPayloadBytes(t *testing.T) { + for _, payload := range [][]byte{ + {0, 1, 0x80, 0xff, '{', '"', '\n'}, + []byte("not a JSON document"), + []byte(`{"value":42}`), + } { + want := result.Content{ + Metadata: result.Metadata{ContentType: "application/octet-stream", Length: int64(len(payload)), LengthKnown: true}, + Data: payload, + } + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got result.Content + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.Metadata != want.Metadata || !bytes.Equal(got.Data, payload) { + t.Fatalf("round-trip = %#v, want %#v", got, want) + } + } +} + +func TestMetadataJSONSupportsLengthsBeyond32Bits(t *testing.T) { + const length int64 = 1 << 40 + want := result.Metadata{ContentType: "application/octet-stream", Length: length, LengthKnown: true} + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + if string(data) != `{"contentType":"application/octet-stream","length":1099511627776,"lengthKnown":true}` { + t.Fatalf("metadata JSON = %s", data) + } + var got result.Metadata + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("metadata = %#v, want %#v", got, want) + } +} diff --git a/runtime.go b/runtime.go index 707d5c0..18ae748 100644 --- a/runtime.go +++ b/runtime.go @@ -17,8 +17,10 @@ import ( // // Run, Compile, CompileDebug, and Version use non-nil caller contexts for cancellation. // Callers coordinate work and cleanup when descendants use parent-owned -// resources. Run closes its temporary session and plan, preserving execution -// and cleanup errors together with any available encoded output. +// resources. Run owns its temporary session and plan; resources needed for +// consumption transfer to the returned Output, which finalizes them. Resources +// independent of consumption may be released earlier. Caller-owned descendants +// and borrowed parents retain their existing ownership. type Runtime interface { io.Closer @@ -32,11 +34,23 @@ type Runtime interface { // no transport-specific behavior or caching requirements. Version(ctx context.Context) (Version, error) - // Run returns nil output with an error when no output was produced. - // A non-nil output with a nil error indicates success, including empty output. - // A non-nil output may accompany an error from cleanup or other processing; - // callers must inspect output independently of the error. - Run(ctx context.Context, src Source, opts ...SessionOption) (*Output, error) + // Run starts execution and returns a usable, caller-owned Output with nil + // error. Success means a handle was obtained, not that execution or delivery + // completed. Execution is not deferred until consumption. Metadata is reliable + // before return without buffering solely to determine length. + // + // Admission or preparation failures return nil output and an error, preserving + // cleanup failures for resources already acquired. Never return a usable handle + // alongside a Run error or use a typed-nil implementation as an absent handle. + // Once a handle is returned, terminal execution, encoding, delivery, and + // output-owned cleanup errors are reported by Consume or Collect, preserving + // available content. Even absent content is observed through a usable handle. + // + // ctx must be non-nil and bounds the output's lifetime after return. Callers + // must not cancel it before settling the output. Consume or Collect finalizes + // resources; Close abandons unread output without certifying completion. + // Side-effect-only callers must consume to observe completion. See Output. + Run(ctx context.Context, src Source, opts ...SessionOption) (Output, error) Compile(ctx context.Context, src Source, opts ...PlanOption) (Plan, error) CompileDebug(ctx context.Context, src Source, opts ...PlanOption) (Plan, error) } diff --git a/session.go b/session.go index 8c3208f..3fd1b9f 100644 --- a/session.go +++ b/session.go @@ -6,15 +6,29 @@ import ( ) // Session executes a compiled plan with per-session configuration. Run observes -// its non-nil context and returns caller-owned encoded output. Unless documented -// otherwise, callers serialize Run and settle it before Close. Close is -// idempotent and retains its cleanup result, without requiring identical -// error-wrapper pointers. +// its non-nil context and returns a caller-owned consumable Output. Unless a +// stronger implementation contract explicitly allows otherwise, callers serialize +// Run and settle its output before reusing or closing the session. Output cleanup +// never closes this caller-owned session. Close is idempotent and retains its +// cleanup result, without requiring identical error-wrapper pointers. type Session interface { io.Closer - // Run returns nil output with an error when no output was produced. - // A non-nil output with a nil error indicates success, including empty output. - // A non-nil output may accompany an error from cleanup or other processing; - // callers must inspect output independently of the error. - Run(c context.Context) (*Output, error) + + // Run starts execution and returns a usable Output with nil error, rather than + // certifying execution or delivery success. Execution is not deferred until + // consumption. Metadata is reliable before return without buffering solely + // to determine length. + // + // Admission or preparation failures return nil output and an error, preserving + // cleanup failures for resources acquired by this invocation. Never return a + // usable handle alongside an error or represent absence using a typed nil. + // After a handle is returned, terminal execution, encoding, delivery, and + // output-owned cleanup errors belong to Consume or Collect, alongside any + // available content. An absent payload still has a usable output handle. + // + // ctx must be non-nil and bounds the output's lifetime after return. Keep it + // alive until the output is settled. Consume or Collect observes completion + // and finalizes output-owned resources without closing this session. Close on + // the output abandons it; side-effect-only callers must consume. See Output. + Run(ctx context.Context) (Output, error) } diff --git a/types.go b/types.go index a28efa5..6b7093d 100644 --- a/types.go +++ b/types.go @@ -21,12 +21,36 @@ type ( // Range represents a range of characters in a source file, including the location and span. Range = source.Range - // Output is the encoded result returned from session or runtime execution. - // Execution returns a pointer: nil means no output was produced, while a - // non-nil pointer to a zero-valued Output still represents produced output. + // Metadata describes the complete encoded payload independently of content + // presence. See result.Metadata for length and immutability requirements. + Metadata = result.Metadata + + // Content is detached, caller-owned encoded data returned by Output.Collect + // or retained debugger events. A nil *Content means no content is available; + // a non-nil pointer, including &Content{}, means present content. See result.Content. + Content = result.Content + + // Consumer receives borrowed, read-only chunks during Output.Consume. + // Copy retained bytes; see result.Consumer for callback and context requirements. + Consumer = result.Consumer + + // Output is the caller-owned, one-shot consumable handle returned by execution. + // Consume or Collect observes completion and finalizes resources; Close abandons + // unread output. See result.Output for lifetime, presence, and error requirements. Output = result.Output ) +var ( + // ErrOutputInUse is result.ErrInUse. Use errors.Is to identify a consumption + // attempt rejected because another consumer owns the output. + ErrOutputInUse = result.ErrInUse + + // ErrOutputClosed is result.ErrClosed. Use errors.Is to identify consumption + // rejected after stopping begins, or interrupted by an explicit Close that + // wins before outcome commitment. Close itself returns its cleanup outcome. + ErrOutputClosed = result.ErrClosed +) + // NewSource creates a new Source instance with the given name and content. func NewSource(name, content string) Source { return source.New(name, content)