diff --git a/C114-ACCEPTANCE.md b/C114-ACCEPTANCE.md new file mode 100644 index 000000000..ecd1ad9f2 --- /dev/null +++ b/C114-ACCEPTANCE.md @@ -0,0 +1,59 @@ +# Icod.Terminal 1.11.0 C114 Acceptance + +**Tranche:** C114 — persistent resource creation/upload +**Accepted head:** `5a4e8860322eb18505e2d77baa18c3a81d93b7af` +**Pull-request workflow:** run `34659060708` / `#1561` +**Configuration:** `Staging` + +C114 is accepted because the exact unchanged head above passed the full pull-request matrix on Windows, Linux, and macOS, including package candidate construction, every package-contract shard, and the validated package artifact. + +## Accepted contract + +C114 introduces the first public persistent-raster ownership operation while preserving the C110 opacity boundary. + +The accepted behavior is: + +- `TerminalSession.CreateRasterResourceAsync(...)` requires current verified `PersistentRasterGraphics` capability and performs no hidden support probe; +- Sixel is never used as a persistent-resource fallback; +- resource capacity is reserved in the bounded C113 registry before protocol output begins; +- persistent Kitty direct upload uses the private nonzero image number and publishes no public resource until a matching acknowledgement is received; +- successful acknowledgement binds the terminal-assigned nonzero image id privately; +- well-formed `ENOENT` creation response returns controlled `Unavailable`; +- other well-formed Kitty negative responses return controlled `Failed`; +- duplicate, overflowing, or otherwise malformed correlated acknowledgement fields surface `FormatException`; +- response correlation can route a malformed matching frame without terminating the shared input pump; strict bounded validation occurs immediately after routing; +- post-commit caller cancellation does not truncate the remaining upload frames; +- canceled/timed-out committed transactions retain local identity ownership until the existing late-response ownership interval ends, after which rollback releases the reservation; +- failed or ambiguous creation never publishes a usable `TerminalRasterResource`; +- the registry continues to retain no source `TerminalRasterImage` or pixel payload after creation completes. + +The accepted C114 public API fingerprint is: + +```text +fbef4700d29613be7f6224a5349426c731412a851127bd529fc190ac3a562aeb +``` + +It is identical across `net8.0`, `net9.0`, and `net10.0` and covers the C112 capability plus the C114 opaque resource/session creation surface. + +## TDD evidence + +The C114 RED checkpoint was commit `1bd3c04adfed8d5a8faa26ac1b4472b8a2eb37d5`. Runtime compilation failed on all three target frameworks because `TerminalRasterResource` and `TerminalSession.CreateRasterResourceAsync(...)` did not yet exist, with no unrelated warnings. + +The initial GREEN implementation was commit `213de5e6ca1eb4a7d05a0b9a5df0f6a707bb97d5`. Qualification then exposed two real contract issues: + +1. the interim C112 public API fingerprint correctly rejected the intentional C114 surface addition; and +2. strict persistent-response matching needed to preserve C111 duplicate/overflow validation without throwing inside the shared response-routing pump. + +Those issues were corrected while preserving the frozen behavior. Exact head `5a4e8860322eb18505e2d77baa18c3a81d93b7af` then passed workflow `#1561 / 34659060708` across: + +- Runtime Windows; +- Runtime Linux; +- Runtime macOS; +- Package candidate / public API freeze; +- Package Foundation; +- Package Presentation; +- Package Semantic and hardening; +- Package Stable 1.x release line; +- Validated package artifact. + +C114 is therefore closed. Placement creation and multi-placement ownership remain C115; placement update and deterministic terminal cleanup remain C116; lifecycle invalidation and teardown remain C117. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4d85649..08f3ff28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ Notable changes to `Icod.Terminal` are recorded here for consumers who need a concise release history. Detailed design evidence remains in the versioned roadmaps, tranche records, and public-API baseline documents. +## 1.11.0 + +### Persistent raster resources and placements + +- Adds `TerminalCapability.PersistentRasterGraphics = 9` while preserving all existing `TerminalCapability` numeric values. +- Adds opaque `TerminalRasterResource` and `TerminalRasterPlacement` ownership plus `TerminalRasterPlacementOptions` and `TerminalSession.CreateRasterResourceAsync(...)`. +- Supports multiple placements per terminal-resident resource, current-cursor placement, independently optional `Columns` / `Rows` in `1..16384`, and semantic placement replacement through `TerminalRasterPlacement.UpdateAsync(...)`. +- Keeps Kitty image ids, image numbers, placement ids, APC control dictionaries, and backend selection private to the implementation; the common public API remains semantic and backend-neutral. +- Keeps ordinary `RasterGraphics` separate from `PersistentRasterGraphics`, so verified Sixel can satisfy ephemeral raster display without pretending to provide persistent terminal-resident ownership. + +### Acknowledgement, lifecycle, and bounded ownership + +- Requires correlated acknowledgement before publishing a persistent resource and correlates placement create/update replies by the private terminal image id and placement id through the existing authoritative query/input path. +- Treats correlated terminal replies as untrusted input: wrong identities do not complete another transaction, malformed/duplicate identity fields are rejected, and response processing remains bounded. +- Treats a well-formed correlated `ENOENT` as loss of terminal-resident certainty, invalidating the affected resource/placement so later operations return controlled `Unavailable` without emitting stale identifiers. +- Bounds live ownership to 256 persistent resources and 4096 placements per session with nonzero collision-safe private identities and explicit wraparound handling. +- Makes persistent identities session-generation scoped: explicit invalidation and lifecycle generation changes stale existing handles without automatic replay, re-upload, or hidden raster retention. +- Makes placement/resource disposal locally idempotent, deletes children before resource data while current, performs local-only cleanup when stale, and surfaces/aggregates cleanup transport failures without uncertain retry ownership. +- Retains Kitty direct transfer only and introduces no file/temp-file/shared-memory transport or source-image cache. + +### Qualification and compatibility + +- Adds repeated create/place/update/delete stress, repeated generation invalidation, and 8,192-placement registry churn coverage alongside the existing capacity, wraparound, cancellation, transport-failure, redirected-output, and malformed-response tests. +- Adds a fresh NuGet-only persistent-raster consumer and generated XML-documentation validation for `net8.0`, `net9.0`, and `net10.0`. +- Adds `Icod.Terminal.PersistentRaster.Sample`, demonstrating capability verification plus create/place/update/dispose without Kitty/Sixel/backend/id branching. +- Retains Windows/Linux/macOS Staging validation, current `Icod.DCurses` downstream acceptance/soak, and the stable `1.0.0` compatibility floor. +- Finalizes the 1.11 public API fingerprint as `9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2` while retaining all historical baselines unchanged. +- Deliberately excludes automatic replay, Sixel persistence emulation, public protocol ids, source rectangles, z-order, Unicode placeholders, relative/pixel placement, animation, scene-graph ownership, image decoding/transcoding, and PTY/ConPTY hosting. + +See `docs/releases/1.11.0.md`, `docs/Persistent-Raster-Ownership.md`, `docs/Public-API-Baseline-1.11.md`, `docs/C118-1.11.0-Persistent-Raster-Adversarial-Downstream-and-Package-Qualification.md`, `docs/C119-1.11.0-Release-Closure.md`, and `Icod.Terminal-1.11.0-Development-Roadmap.md` for the complete 1.11 contract. + ## 1.10.0 ### Dependency decoupling and capability planning diff --git a/Directory.Build.props b/Directory.Build.props index 382611e94..d771b0499 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 1.10.0 + 1.11.0 $(VersionPrefix) $(VersionPrefix)-$(VersionSuffix) diff --git a/Icod.Terminal-1.11.0-Development-Roadmap.md b/Icod.Terminal-1.11.0-Development-Roadmap.md new file mode 100644 index 000000000..d9fb8f12b --- /dev/null +++ b/Icod.Terminal-1.11.0-Development-Roadmap.md @@ -0,0 +1,227 @@ +# Icod.Terminal 1.11.0 Development Roadmap + +**Release:** `1.11.0` +**Theme:** persistent raster resources and placements +**Status:** complete; C119 accepted and ready for maintainer review +**Development version:** `1.11.0` +**Stable compatibility floor:** `1.0.0` +**Prior release:** `1.10.0` + +## Why this release exists + +Versions 1.7 and 1.8 established a backend-neutral ephemeral raster contract through `TerminalRasterImage` and `TerminalSession.DisplayRasterAsync(...)`. Version 1.10 added protocol-neutral capability inspection and explicit bounded verification without widening graphics ownership. + +Version 1.11 introduces a separate persistent graphics ownership domain so callers can upload terminal-resident raster data, create one or more placements, update placement size/location through ordinary terminal positioning plus placement replacement, and deterministically dispose those objects. + +The release deliberately does **not** turn `Icod.Terminal` into a virtual-screen or scene-graph library. + +The approved architectural specification is: + +[`docs/superpowers/specs/2026-09-11-1.11.0-persistent-raster-design.md`](docs/superpowers/specs/2026-09-11-1.11.0-persistent-raster-design.md) + +The implementation plan is: + +[`docs/superpowers/plans/2026-09-11-1.11.0-persistent-raster.md`](docs/superpowers/plans/2026-09-11-1.11.0-persistent-raster.md) + +## Architectural objectives + +The 1.11 contract must: + +1. expose opaque session-owned resource and placement objects rather than Kitty numeric identifiers; +2. distinguish ordinary `RasterGraphics` from new `PersistentRasterGraphics` capability truth; +3. keep Sixel valid for ordinary ephemeral raster display without pretending it supports terminal-resident persistent resources; +4. reuse the existing authoritative query/input path for Kitty acknowledgements; +5. retain committed-output semantics and avoid blind replay/backend switching after partial failure; +6. invalidate terminal-resident identity across lifecycle-generation changes rather than retaining hidden image copies for automatic replay; +7. bound all resource and placement bookkeeping; +8. preserve the stable 1.x compatibility floor and existing enum numerics; +9. keep higher-level layout/cell/window/scene policy in `Icod.DCurses`. + +## Frozen public direction + +The final public model is: + +```text +TerminalCapability.PersistentRasterGraphics = 9 + +TerminalRasterResource : IAsyncDisposable +TerminalRasterPlacement : IAsyncDisposable +TerminalRasterPlacementOptions + +TerminalSession.CreateRasterResourceAsync(...) + -> TerminalControlResult + +TerminalRasterResource.CreatePlacementAsync(...) + -> TerminalControlResult + +TerminalRasterPlacement.UpdateAsync(...) + -> TerminalControlMutationResult +``` + +`TerminalRasterPlacementOptions` contains nullable `Columns` and `Rows`, each accepted from `1..16384`; null means protocol/default behavior. Position remains the terminal's current cursor location, and placement output uses no-cursor-movement semantics. + +No public Kitty image id, image number, placement id, backend selector, raw APC control dictionary, or scene graph is introduced. + +The internal live-registry ceilings are: + +```text +256 persistent resources per session +4096 persistent placements per session +``` + +Persistent resource upload is acknowledged. It uses a private Kitty image number (`I`) so the terminal can return the assigned nonzero image id (`i`). Existing ephemeral raster bytes remain unchanged. + +## Tranche plan + +```text +C110 architecture and public API regret gate complete +C111 persistent Kitty protocol foundation complete +C112 persistent capability integration complete +C113 session-owned resource/placement registries complete +C114 persistent resource creation/upload complete +C115 placement creation and multi-placement ownership complete +C116 placement update and deterministic disposal complete +C117 lifecycle invalidation, teardown, and failure semantics complete +C118 adversarial/downstream/package qualification complete +C119 public API/documentation/compatibility/release closure complete +``` + +## C118 feature acceptance + +C111–C117 implemented the frozen persistent-raster protocol, capability, ownership, lifecycle, and cleanup contracts. C118 requalified that complete surface under adversarial responses, repeated lifetime churn, package-only consumption, generated documentation, the backend-neutral sample, downstream DCurses acceptance, and all supported target frameworks. + +The C118 acceptance record is: + +[`docs/C118-1.11.0-Persistent-Raster-Adversarial-Downstream-and-Package-Qualification.md`](docs/C118-1.11.0-Persistent-Raster-Adversarial-Downstream-and-Package-Qualification.md) + +Validated feature head: + +```text +bc838f7011a2a584f958a0478fd800bf508a4832 +``` + +Acceptance workflow: + +```text +#1602 / 34695867877 +``` + +That unchanged feature head passed Windows, Linux, macOS, package/API candidate, all four package-contract shards, and the validated package artifact. + +## C110 — architecture and public API regret gate + +**Accepted.** C110 froze the ownership model, public API, capability separation, generation-scoped certainty, cancellation/commit semantics, resource bounds, and exclusions before production implementation. + +The record is: + +[`docs/C110-Persistent-Raster-Architecture-and-API-Regret-Gate.md`](docs/C110-Persistent-Raster-Architecture-and-API-Regret-Gate.md) + +## C111 — persistent Kitty protocol foundation + +**Complete.** Typed internal Kitty Graphics encoding/parsing supports transmit-only acknowledged resource upload, placement create/update, targeted placement deletion, resource-data deletion, direct transfer, bounded Base64 chunking, private ids, duplicate/overflow rejection, and unchanged ephemeral raster bytes. + +## C112 — persistent capability integration + +**Complete.** `PersistentRasterGraphics = 9` is additive. `InspectCapability(...)` remains side-effect free; `VerifyCapabilityAsync(...)` uses only the reviewed bounded Kitty support path; Sixel remains valid for ordinary raster without implying persistent ownership. + +## C113 — session-owned resource and placement registries + +**Complete.** Bounded internal registries provide nonzero collision-safe resource/image-number and placement identities, explicit wraparound, 256/4096 capacity ceilings, generation stamping, parent/child association, concurrency-safe transitions, and no arbitrary source-image cache. + +## C114 — persistent resource creation/upload + +**Complete.** `CreateRasterResourceAsync(...)` validates capability/state/cancellation, uploads direct raw raster data with a private image number, correlates the terminal acknowledgement, publishes a handle only after nonzero terminal identity is established, preserves committed-output semantics, and does not replay or switch backend after partial failure. + +## C115 — placement creation and multi-placement ownership + +**Complete.** Resource placement creation uses current-cursor positioning, `Columns`/`Rows` in `1..16384`, no-cursor-movement semantics, multiple placements per resource, opaque handles, bounded reservation, and stale/disposed behavior without public protocol ids. + +## C116 — placement update and deterministic disposal + +**Complete.** `TerminalRasterPlacement.UpdateAsync(...)` replaces the same private `(image, placement)` identity at the current cursor. Placement/resource disposal is locally idempotent, releases ownership exactly once, cleans children before resource data, and surfaces cleanup transport failures without restoring uncertain local ownership. + +The public API fingerprint frozen here and retained through release closure is: + +```text +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 +``` + +## C117 — lifecycle invalidation, teardown, and failure semantics + +**Complete.** Persistent identities are generation-scoped. `InvalidateState()` and managed lifecycle changes stale existing handles; no automatic replay/source-image retention occurs; stale disposal is local-only; current session teardown deletes placements before resources and integrates failures with existing cleanup/restoration. + +## C118 — adversarial, downstream, and package qualification + +**Accepted.** Qualification covers malformed/oversized acknowledgements, wrong ids, duplicate fields, capacity/wraparound boundaries, `ENOENT`, repeated ownership cycles, generation invalidation, registry churn, transport/cancellation/redirection boundaries, fresh NuGet-only package consumption, all TFMs, generated XML docs, DCurses acceptance/soak, and the backend-neutral persistent-raster sample. + +See the C118 acceptance record linked above. + +## C119 — public API/documentation/compatibility/release closure + +**Accepted.** + +The release-facing state includes: + +- final multi-TFM public API fingerprint `9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2`; +- stable `1.11.0` package identity with no prerelease suffix; +- final package release notes; +- final README and changelog entry; +- curated `docs/releases/1.11.0.md`; +- permanent `docs/Persistent-Raster-Ownership.md`; +- 1.11 Architecture/Security/Compatibility authority updates; +- final sample catalog and backend-neutral sample verification; +- long-range roadmap handoff to conditional 1.12 advanced placement/lifecycle work; +- release-closure record at [`docs/C119-1.11.0-Release-Closure.md`](docs/C119-1.11.0-Release-Closure.md). + +C119 code/release acceptance is frozen on exact head: + +```text +66002280a3e5c800b9b8d230de483f63945d571d +``` + +with pull-request workflow: + +```text +#1611 / 34698727655 +``` + +Workflow #1611 completed successfully on that unchanged SHA. Windows, Linux, macOS, package candidate/API freeze, Package Foundation, Package Presentation, Package Semantic and hardening, Package Stable 1.x release line, and the validated package artifact all passed. The successful macOS rerun used the same SHA and introduced no source change. + +Merge, `main` Release validation, tagging, GitHub Release creation, and NuGet publication remain maintainer actions. + +## Explicit exclusions + +Version 1.11 does not include: + +- public Kitty image ids/image numbers/placement ids; +- caller-selected graphics backend; +- Sixel persistent-resource emulation; +- automatic replay after lifecycle invalidation; +- retained source-image caches for hidden restoration; +- source rectangles; +- z-order; +- Unicode placeholders; +- relative placements; +- pixel-coordinate placement; +- animation; +- scene-graph ownership; +- image-file decoding/transcoding; +- Kitty file/temp-file/shared-memory transport; +- PTY/ConPTY hosting; +- DCurses cell/window/layout/damage policy. + +These remain excluded unless separately reviewed in a later release. + +## Release gate + +The established repository policy remains: + +```text +reviewed contract + -> focused tests + -> implementation + -> multi-TFM/runtime/package validation + -> exact-head acceptance evidence +``` + +The final 1.11 code/release head satisfied the complete Windows/Linux/macOS Staging matrix before maintainer handoff. This documentation-only acceptance commit is the final repository-state qualification step before PR #52 leaves draft. diff --git a/Icod.Terminal-Development-Roadmap.md b/Icod.Terminal-Development-Roadmap.md index 7c4e9a1a2..26449b413 100644 --- a/Icod.Terminal-Development-Roadmap.md +++ b/Icod.Terminal-Development-Roadmap.md @@ -4,8 +4,8 @@ - **Package:** `Icod.Terminal` - **Language:** C# 13 - **Target frameworks:** `net8.0`; `net9.0`; `net10.0` -- **Current stable release:** `1.10.0` -- **Next development line:** `1.11.0` — persistent raster resources and placements +- **Current stable release:** `1.11.0` +- **Next development line:** `1.12.0` — conditional advanced raster placement/lifecycle work - **Stable compatibility floor:** `1.0.0` ## Purpose @@ -32,48 +32,31 @@ terminal applications ``` - `Icod.TermInfo` owns immutable terminal capability data and expansion. -- `Icod.Terminal` owns the live terminal conversation, native terminal modes, input decoding, unsolicited semantic events, lifecycle, query routing, semantic capability evidence/planning, terminal output, raster output, protocol framing/routing, and reversible/scoped terminal state. -- `Icod.DCurses` owns cells, windows, virtual-screen state, refresh/diff policy, and higher-level curses presentation abstractions. +- `Icod.Terminal` owns the live terminal conversation, native modes, input decoding, semantic events, lifecycle, query routing, semantic capability evidence/planning, terminal output, ephemeral raster routing, persistent raster resource/placement ownership, protocol framing/routing, and reversible/scoped terminal state. +- `Icod.DCurses` owns cells, windows, virtual-screen state, layout, refresh/diff policy, damage, and higher-level curses presentation abstractions. - PTY/process hosting remains orthogonal to the `Icod.Terminal` runtime contract. ## Published 1.8 result -The 1.5–1.8 feature program is complete: +The 1.5–1.8 program established normalized control families, complete CSI grammar/pixel geometry, backend-neutral raster images, Sixel, and Kitty Graphics routing. ```text 1.5.0 normalized control families / capability evidence / semantic routing 1.6.0 complete CSI grammar / terminal and cell pixel geometry 1.7.0 DCS / Sixel / public backend-neutral raster contract 1.8.0 APC / Kitty Graphics / verified multi-backend raster routing -1.8.1 documentation and sample maintenance; no runtime/API expansion +1.8.1 documentation and sample maintenance ``` -The public raster intent introduced in 1.7 resolves through two reviewed internal backends: - -```text -TerminalSession.DisplayRasterAsync(...) - -> capability/evidence resolution - -> verified Kitty Graphics / APC - -> verified Sixel / DCS -``` - -Versions 1.8.0 and 1.8.1 add no public API and retain the 1.7 fingerprint: +The 1.7/1.8 raster fingerprint is: ```text 847441fb4a8cdc89979aca9e96178f939895b93ec19a973232210af09716f700 ``` -Detailed 1.8 design and qualification evidence is preserved in: - -- [`Icod.Terminal-1.8.0-Development-Roadmap.md`](Icod.Terminal-1.8.0-Development-Roadmap.md) -- [`docs/releases/1.8.0.md`](docs/releases/1.8.0.md) -- `docs/A180-*` through `docs/A189-*` - -The 1.8.1 maintenance release is documented in [`docs/releases/1.8.1.md`](docs/releases/1.8.1.md). - ## Published 1.9 result -Version 1.9 establishes this authoritative input-routing order: +Version 1.9 established unsolicited semantic-event ownership through the unified session event stream: ```text active query response @@ -81,28 +64,17 @@ active query response -> ordinary application input ``` -The public event stream gains `TerminalEventKind.Semantic` and protocol-neutral `TerminalSemanticEvent` / `TerminalNotificationEvent` payloads. The first semantic family is interactive notification reporting with distinct activation, one-based button activation, close, and close-tracking-unavailable observations. - -The existing typed Kitty OSC 99 notification options gain explicit opt-in activation/button reporting, close reporting, and bounded button labels. Existing noninteractive notification behavior remains compatible when those options are unused. +It added the protocol-neutral semantic event envelope and interactive notification reporting while retaining one authoritative input reader. -The final 1.9 public API fingerprint is: +Final 1.9 fingerprint: ```text e652e6fd65cd43422ca84b7c4c2a1815ee7ead9b2a64285e0e17cf39614b0315 ``` -Detailed 1.9 authorities: - -- [`Icod.Terminal-1.9.0-Development-Roadmap.md`](Icod.Terminal-1.9.0-Development-Roadmap.md) -- [`docs/releases/1.9.0.md`](docs/releases/1.9.0.md) -- [`docs/Public-API-Baseline-1.9.md`](docs/Public-API-Baseline-1.9.md) -- `docs/E190-*` through `docs/E199-*` - ## Published 1.10 result -Version 1.10 adds a deliberately reduced public semantic capability-inspection/planning surface over the evidence/routing architecture established in 1.5–1.9. - -The public distinction is: +Version 1.10 added protocol-neutral semantic capability inspection and explicit bounded verification: ```text inspect @@ -111,151 +83,132 @@ inspect verify explicit and bounded - attempts to strengthen current knowledge only where a reviewed live probe exists + strengthens knowledge only through reviewed live probes ``` -The stable public types are: - -```text -TerminalCapability -TerminalCapabilitySupport -TerminalCapabilityEndpointAvailability -TerminalCapabilityEvidenceKind -TerminalCapabilityStatus -``` +It also froze loose dependency coupling: `Icod.Terminal.csproj` remains the direct dependency authority, while restore/build is the compatibility witness for the package graph. -The stable public operations are: +Final 1.10 fingerprint: ```text -TerminalSession.InspectCapability(...) -TerminalSession.VerifyCapabilityAsync(...) +ee705250d19d51df92645e5020f188646dd2dbf38483278e6e57ce6fbbc1e9fb ``` -Public evidence remains protocol- and dependency-neutral. Callers see `StaticDescription` or `LiveObservation`, not `Icod.TermInfo`, protocol family, backend identity, routing score, or terminal brand. +## Published 1.11 result -Version 1.10 also freezes the loose dependency-coupling policy: `Icod.Terminal.csproj` remains the package authority for direct dependency requirements while tests, samples, package smoke consumers, and verification tools avoid independently pinning transitive runtime dependency versions merely to duplicate package metadata. Successful restore/build remains the compatibility witness for the package graph. +Version 1.11 adds a separate persistent raster ownership domain above the existing `TerminalRasterImage` and capability-planning contracts. -The final 1.10 public API fingerprint is: +The stable semantic flow is: ```text -ee705250d19d51df92645e5020f188646dd2dbf38483278e6e57ce6fbbc1e9fb +PersistentRasterGraphics verification + -> CreateRasterResourceAsync(...) + -> TerminalRasterResource + -> CreatePlacementAsync(...) + -> TerminalRasterPlacement + -> UpdateAsync(...) + -> DisposeAsync() + -> DisposeAsync() ``` -Detailed 1.10 authorities: +The public model remains opaque. Kitty image ids, image numbers, placement ids, raw APC command dictionaries, and backend selection stay internal. -- [`Icod.Terminal-1.10.0-Development-Roadmap.md`](Icod.Terminal-1.10.0-Development-Roadmap.md) -- [`docs/releases/1.10.0.md`](docs/releases/1.10.0.md) -- [`docs/Capability-Inspection-and-Planning.md`](docs/Capability-Inspection-and-Planning.md) -- [`docs/Public-API-Baseline-1.10.md`](docs/Public-API-Baseline-1.10.md) -- [`docs/C105-C108-Capability-Lifecycle-Samples-Hardening-and-API-Freeze.md`](docs/C105-C108-Capability-Lifecycle-Samples-Hardening-and-API-Freeze.md) +Stable 1.11 guarantees include: -## Approved post-1.10 release train +- `TerminalCapability.PersistentRasterGraphics = 9` with values `0..8` unchanged; +- explicit separation between ephemeral `RasterGraphics` and persistent ownership; +- acknowledged resource creation before a public handle is returned; +- correlated placement create/update responses through the existing one-reader/query path; +- current-cursor placement with optional `Columns` / `Rows` in `1..16384` and no text-cursor movement; +- 256 live resources and 4096 live placements per session; +- nonzero private collision-safe identities with wraparound handling; +- generation-scoped terminal-resident certainty; +- no hidden source-image retention or automatic replay after invalidation/resume; +- `ENOENT` invalidation when the terminal no longer recognizes a believed-current resource; +- child-first cleanup, locally idempotent disposal, and stale local-only cleanup; +- direct transfer only; no file/temp-file/shared-memory transport; +- no scene-graph, cell/window/layout, z-order, animation, source-rectangle, or PTY ownership. -The feature sequence remains frozen at the roadmap level: +Final 1.11 public API fingerprint: ```text -1.11.0 persistent raster resource and placement lifecycle - + backend-neutral ownership above Kitty image/placement identifiers - -1.12.0 conditional advanced raster placement/lifecycle work - only where downstream requirements and measurements justify it +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 ``` -The themes remain ordered by architectural dependency rather than novelty. - -## 1.11.0 — persistent raster resources and placements - -Version 1.7/1.8 intentionally limits raster output to ephemeral `DisplayRasterAsync(...)` semantics. Version 1.10 deliberately does not widen that contract while introducing capability planning. +Detailed 1.11 authorities: -Persistent graphics require a new ownership domain covering: +- [`Icod.Terminal-1.11.0-Development-Roadmap.md`](Icod.Terminal-1.11.0-Development-Roadmap.md) +- [`docs/releases/1.11.0.md`](docs/releases/1.11.0.md) +- [`docs/Persistent-Raster-Ownership.md`](docs/Persistent-Raster-Ownership.md) +- [`docs/Public-API-Baseline-1.11.md`](docs/Public-API-Baseline-1.11.md) +- [`docs/C118-1.11.0-Persistent-Raster-Adversarial-Downstream-and-Package-Qualification.md`](docs/C118-1.11.0-Persistent-Raster-Adversarial-Downstream-and-Package-Qualification.md) +- [`docs/C119-1.11.0-Release-Closure.md`](docs/C119-1.11.0-Release-Closure.md) -- opaque raster resource identity; -- opaque placement identity; -- terminal-resident lifetime and uncertainty; -- acknowledgement/correlation where available; -- create/upload and placement creation; -- move/resize/update behavior; -- explicit deletion/disposal; -- lifecycle generation invalidation; -- teardown/cleanup authority; -- capability/evidence integration through the 1.10 planning model. +## Next development line: 1.12.0 -The public shape should remain semantic and opaque rather than publishing Kitty numeric image/placement identifiers as the common API. - -The first persistent release should remain narrow: - -```text -create/upload a resource -create a placement -reposition/resize a placement -delete/dispose placement/resource -``` +Advanced raster placement/lifecycle features remain candidates rather than promises. -Automatic replay across suspend/resume is not assumed. A lifecycle generation change should initially invalidate terminal-resident certainty rather than require the library to retain arbitrarily large source images for hidden replay. +Potential areas include: -The 1.11 design must also preserve the established committed-output rule: after partial terminal output, the library must not blindly replay or switch backends when terminal state may be uncertain. - -## 1.12.0 — conditional advanced placement - -Advanced Kitty capabilities such as source rectangles, z-order, Unicode placeholders, relative placement, animation, and richer scene behavior remain candidates rather than promises. +- source rectangles; +- z-order; +- Unicode placeholders; +- relative placement; +- richer placement geometry; +- animation or frame lifecycle; +- additional terminal-resident resource operations. They should enter the core only when: -- the semantic abstraction remains useful beyond one protocol or is deliberately isolated as optional protocol-specific functionality; -- a concrete downstream requirement exists; -- ownership/lifecycle semantics remain supportable; -- bounded-resource behavior can be specified and tested; -- the feature does not turn `Icod.Terminal` into a virtual-screen or scene-graph library. +1. a concrete downstream requirement exists; +2. ownership/lifecycle semantics can be stated precisely; +3. bounded-resource behavior can be specified and tested; +4. the semantic abstraction is useful beyond raw vendor command exposure, or is deliberately isolated as optional protocol-specific functionality; +5. the feature does not turn `Icod.Terminal` into a virtual-screen or scene-graph library. + +The default position after 1.11 is therefore **measure and justify**, not automatically expand. ## Parallel evidence tracks ### Graphics performance -Kitty direct transfer remains the portability/security default. ZLIB/deflate compression may be valuable, but it should be adopted only after benchmark evidence across representative workloads such as icons, flat diagrams, screenshots, gradients, photographs, and high-entropy rasters. +Kitty direct transfer remains the portability/security default. Compression or alternate transport should be adopted only after benchmark evidence across representative icons, diagrams, screenshots, gradients, photographs, and high-entropy rasters. -Measure at least wire bytes, CPU time, allocations, first-frame latency, and total transfer latency. File, temporary-file, and shared-memory Kitty transports remain excluded unless direct-transfer measurements demonstrate a concrete problem that justifies their filesystem/IPC complexity. +Measure wire bytes, CPU time, allocations, first-frame latency, and total transfer latency. File, temporary-file, and shared-memory transports remain excluded unless direct-transfer measurements demonstrate a concrete problem that justifies their filesystem/IPC complexity. ### Diagnostics and observability -As capability evidence, live probes, semantic routing, lifecycle generations, query ownership, rollback, and graphics transactions become more sophisticated, maintainers need a way to explain decisions without logging private terminal data. - -A future diagnostic surface may report semantic operations, support-state changes, probe lifecycle, backend selection, lifecycle-generation changes, restoration failures, and committed graphics failures. It must not expose keyboard text, paste contents, clipboard data, notification contents, hyperlinks, shell command lines, or raster payload bytes by default. +Future diagnostic surfaces may explain semantic operations, support-state changes, probe lifecycle, backend selection, lifecycle generations, restoration failures, and committed graphics failures. -Any such surface must preserve the 1.10 capability-planning abstraction rather than leaking internal evidence/protocol identifiers into ordinary consumer APIs. +They must not expose keyboard text, paste contents, clipboard data, notification contents, hyperlinks, shell command lines, or raster payload bytes by default, and must preserve the protocol-neutral public planning model. ## Long-range architectural guardrails -The post-1.10 program preserves the stable layer boundaries: +The stable 1.x program preserves these boundaries: - no process-global current terminal; - no second live input reader; -- no raw control-family dispatcher as the normal public extension mechanism; -- no generic vendor-event/raw-frame stream as the ordinary semantic extension mechanism; -- no backend selection based solely on terminal brand, `TERM`, host OS, or environment variables; -- no test/verifier dependency policy that independently pins a specific `Icod.TermInfo` release when ordinary restore/build already proves compatibility; +- no raw control-family dispatcher as the ordinary extension mechanism; +- no generic vendor-event/raw-frame stream as the ordinary semantic model; +- no support selection based solely on terminal brand, `TERM`, host OS, or environment variables; +- no tests/verifiers that duplicate exact transitive dependency pins merely to restate package metadata; - no PTY/ConPTY process hosting in `Icod.Terminal`; - no cells/windows/damage/layout/widget ownership that belongs in `Icod.DCurses`; -- no image-file decoding/transcoding requirement in the core terminal package merely to support a vendor image protocol; -- no hidden replay of persistent terminal state without a separately reviewed ownership contract; +- no image-file decoding/transcoding requirement in the core terminal package; +- no hidden replay of persistent terminal state without a separately reviewed contract; - no unbounded terminal-controlled input, event buffering, query state, graphics state, resource registry, or placement registry; -- no public persistent-graphics abstraction that exposes backend-specific numeric identifiers as its common identity model. - -A third graphics backend is not a priority merely because another protocol exists. It must fit an established semantic contract or justify a separate optional boundary. +- no public persistent-graphics abstraction exposing backend-specific numeric identifiers as its common identity model. ## Sequencing rationale -The approved order is: - ```text -event ownership completed in 1.9 - -> capability visibility completed in 1.10 - -> persistent graphics ownership next in 1.11 - -> advanced placement only if justified +event ownership completed in 1.9 + -> capability visibility completed in 1.10 + -> persistent ownership completed in 1.11 + -> advanced placement only if justified in 1.12+ ``` -Version 1.9 closed the known event-ownership gap. Version 1.10 made capability knowledge safely visible without exposing protocol internals. Version 1.11 can therefore build persistent graphics ownership on a stable one-reader, lifecycle, evidence, and public planning foundation. - -`Icod.DCurses` remains an important downstream witness, but it should consume persistent raster semantics rather than force `Icod.Terminal` to absorb virtual-screen or scene-graph responsibilities. +`Icod.DCurses` remains the primary downstream witness for richer presentation needs. It should consume `Icod.Terminal` semantic resource/placement ownership rather than force the terminal layer to absorb virtual-screen or scene-graph responsibilities. ## Permanent 1.x authorities @@ -270,8 +223,9 @@ Current contract authorities include: - [`docs/Semantic-Output-Protocols.md`](docs/Semantic-Output-Protocols.md) - [`docs/Control-Language-Normalization-and-Graphics-Roadmap.md`](docs/Control-Language-Normalization-and-Graphics-Roadmap.md) - [`docs/Capability-Inspection-and-Planning.md`](docs/Capability-Inspection-and-Planning.md) +- [`docs/Persistent-Raster-Ownership.md`](docs/Persistent-Raster-Ownership.md) - [`docs/Security-and-Privacy.md`](docs/Security-and-Privacy.md) - [`docs/Compatibility-and-Versioning.md`](docs/Compatibility-and-Versioning.md) - [`samples/README.md`](samples/README.md) -Versioned roadmaps and tranche documents remain historical design evidence and should not be rewritten merely to make their pre-release status language look current after publication. The 1.10 versioned roadmap is explicitly closed by C109 because that closure itself is part of the release record. +Versioned roadmaps and tranche documents remain historical design evidence and should not be rewritten merely to make their pre-release status language look current after publication. diff --git a/Icod.Terminal.csproj b/Icod.Terminal.csproj index 73253aec1..ba3ce31f0 100644 --- a/Icod.Terminal.csproj +++ b/Icod.Terminal.csproj @@ -39,7 +39,7 @@ Icod.Terminal Timothy J. Bruce Managed, cross-platform live-terminal session, endpoint, mode, input, lifecycle, and terminal-control foundation for .NET. - Icod.Terminal 1.10.0 adds protocol-neutral semantic capability inspection and explicit bounded verification through TerminalCapabilityStatus, TerminalSession.InspectCapability(...), and TerminalSession.VerifyCapabilityAsync(...). It preserves the stable 1.0 compatibility floor and keeps dependency, protocol-backend, and terminal-brand details out of the public planning model. Full notes: https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.10.0.md ; compatibility: https://github.com/uniblab/Icod.Terminal/blob/main/docs/Compatibility-and-Versioning.md + Icod.Terminal 1.11.0 adds backend-neutral persistent terminal-resident raster resources and placements through TerminalCapability.PersistentRasterGraphics, TerminalRasterResource, and TerminalRasterPlacement. It keeps Kitty protocol identities private, bounds ownership, invalidates stale terminal-resident identities instead of replaying hidden image copies, and preserves the stable 1.0 compatibility floor. Full notes: https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.11.0.md ; compatibility: https://github.com/uniblab/Icod.Terminal/blob/main/docs/Compatibility-and-Versioning.md README.md icon.png https://github.com/uniblab/Icod.Terminal @@ -50,7 +50,7 @@ true true true - terminal;tty;console;termios;windows-terminal;ansi;vt100;xterm;terminfo;terminal-input;terminal-control;interactive;cross-platform;dotnet;csharp;icod;clipboard;osc52;cursor;decscusr;decrqss;synchronized-output;dec-private-mode;mode-2026;osc9;osc9-4;osc9-notification;notification;desktop-notification;osc777;osc777-notification;titled-notification;urxvt;osc99;kitty-notification;notification-update;notification-query;notification-icon;notification-event;semantic-event;interactive-notification;osc9-9;current-directory;windows-current-directory;terminal-progress;progress;osc22;pointer-shape;mouse-pointer;kitty;contour;conemu;iterm2;wezterm;osc133;semantic-prompt;shell-integration;prompt;command-region;cmdline-url;prompt-click;osc633;vscode;vscode-shell-integration;osc1337;iterm2-shell-integration;semantic-history;remote-host;user-variable;shell-integration-version;osc4;osc104;terminal-color;palette;color-query;dynamic-color;osc10;osc11;osc12;osc13;osc14;osc17;osc19;color-ownership;scoped-color;lifecycle;restoration;tui;control-language;csi;dcs;apc;sixel;raster;raster-graphics;rgb24;rgba32;indexed8;kitty-graphics;semantic-routing;capability-routing;csi-grammar;csi-parameters;pixel-geometry;capability-planning;capability-inspection + terminal;tty;console;termios;windows-terminal;ansi;vt100;xterm;terminfo;terminal-input;terminal-control;interactive;cross-platform;dotnet;csharp;icod;clipboard;osc52;cursor;decscusr;decrqss;synchronized-output;dec-private-mode;mode-2026;osc9;osc9-4;osc9-notification;notification;desktop-notification;osc777;osc777-notification;titled-notification;urxvt;osc99;kitty-notification;notification-update;notification-query;notification-icon;notification-event;semantic-event;interactive-notification;osc9-9;current-directory;windows-current-directory;terminal-progress;progress;osc22;pointer-shape;mouse-pointer;kitty;contour;conemu;iterm2;wezterm;osc133;semantic-prompt;shell-integration;prompt;command-region;cmdline-url;prompt-click;osc633;vscode;vscode-shell-integration;osc1337;iterm2-shell-integration;semantic-history;remote-host;user-variable;shell-integration-version;osc4;osc104;terminal-color;palette;color-query;dynamic-color;osc10;osc11;osc12;osc13;osc14;osc17;osc19;color-ownership;scoped-color;lifecycle;restoration;tui;control-language;csi;dcs;apc;sixel;raster;raster-graphics;rgb24;rgba32;indexed8;kitty-graphics;semantic-routing;capability-routing;csi-grammar;csi-parameters;pixel-geometry;capability-planning;capability-inspection;persistent-raster;persistent-graphics;raster-resource;raster-placement https://github.com/uniblab/Icod.Terminal git false diff --git a/README.md b/README.md index 395bac643..18986ac01 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,14 @@ ## Status -`1.10.0` is the current stable feature line. It adds protocol-neutral semantic capability inspection and explicit bounded verification while preserving the existing one-reader, lifecycle, routing, restoration, and security contracts. +`1.11.0` is the current stable feature line. It adds backend-neutral persistent terminal-resident raster resources and placements above the existing raw-raster and semantic capability-planning foundations. -The stable `1.0.0` compatibility floor remains unchanged. `Icod.Terminal` treats successful restore/build of its declared package graph as dependency compatibility evidence rather than making one resolved `Icod.TermInfo` or `Icod.Timing` version a second test-level compatibility contract. +The stable `1.0.0` compatibility floor remains unchanged. `Icod.Terminal` continues to preserve one authoritative live input path, bounded query/protocol handling, lifecycle-aware ownership, deterministic cleanup, and protocol-neutral public planning surfaces. ## Installation -Install the stable package with: - ```text -dotnet add package Icod.Terminal --version 1.10.0 +dotnet add package Icod.Terminal --version 1.11.0 ``` The package targets: @@ -29,7 +27,7 @@ net9.0 net10.0 ``` -`Icod.Terminal` declares its direct NuGet requirements in package metadata. Tests, samples, and auxiliary verification tools do not independently pin those transitive dependencies merely to duplicate the package declaration. +`Icod.Terminal.csproj` is the package authority for direct NuGet dependencies. Tests, samples, and auxiliary tools do not independently pin transitive runtime versions merely to duplicate package metadata. ## Architecture @@ -47,10 +45,12 @@ terminal applications ``` - `Icod.TermInfo` owns immutable terminal capability data and terminfo expansion. -- `Icod.Terminal` owns the live terminal conversation: endpoint observation, native modes, input decoding, lifecycle, active query routing, unsolicited semantic events, semantic capability evidence/planning, semantic output, protocol framing/routing, raster output, and scoped/reversible terminal state. -- `Icod.DCurses` owns higher-level cells, windows, virtual-screen state, refresh/diff policy, and curses presentation abstractions. +- `Icod.Terminal` owns the live terminal conversation: endpoint observation, modes, input decoding, lifecycle, active queries, unsolicited semantic events, semantic capability evidence/planning, semantic output, raster output, persistent raster resource/placement ownership, protocol framing/routing, and reversible/scoped terminal state. +- `Icod.DCurses` owns higher-level cells, windows, virtual-screen state, layout, refresh/diff policy, damage, and curses presentation abstractions. - PTY/process hosting remains orthogonal to the `Icod.Terminal` runtime contract. +See [`docs/Architecture.md`](docs/Architecture.md). + ## Quick start ```csharp @@ -74,103 +74,44 @@ A live `TerminalSession` owns the authoritative input reader for its transport. For a curses-style virtual screen, prefer `Icod.DCurses` rather than rebuilding cell/window/refresh policy directly over `TerminalSession`. -## Semantic capability inspection and planning +## Semantic capability planning -Version 1.10 exposes a deliberately small semantic planning vocabulary. Callers ask about operations such as raster graphics or modern keyboard reporting without learning which protocol backend, terminal description source, or terminal brand is involved. +The stable planning API asks about semantic operations rather than protocol backends, terminal brands, or dependency provenance. -Side-effect-free inspection reads only current session knowledge: +Side-effect-free inspection reads current session knowledge only: ```csharp -TerminalCapabilityStatus raster = session.InspectCapability( +TerminalCapabilityStatus status = session.InspectCapability( TerminalCapability.RasterGraphics ); - -if ( raster.IsUsable ) { - // Offer or use the raster presentation path. -} else { - // Choose a non-raster path. -} -``` - -`TerminalCapabilityStatus` keeps support, endpoint availability, evidence lifetime, and current usability separate. Public evidence is intentionally only: - -```text -None -StaticDescription -LiveObservation ``` -When stronger evidence is worth terminal traffic, verification is explicit: +When stronger evidence is worth bounded terminal traffic, verification is explicit: ```csharp -if ( raster.Support == TerminalCapabilitySupport.Unknown - && raster.EndpointAvailability - == TerminalCapabilityEndpointAvailability.Available ) { - raster = await session.VerifyCapabilityAsync( - TerminalCapability.RasterGraphics, - cancellationToken - ); -} -``` - -Version 1.10 performs live verification only for capabilities with existing reviewed bounded probes. Initially those are `KeyboardReporting` and `RasterGraphics`. Other capabilities return current inspection status rather than inventing probe traffic. - -See [Capability Inspection and Planning](docs/Capability-Inspection-and-Planning.md) for the permanent contract. - -## Unsolicited semantic events - -Version 1.9 extended the unified event model with `TerminalEventKind.Semantic` while preserving all existing enum numeric values. - -Incoming framed traffic is assigned in this order: - -```text -active query response - -> recognized unsolicited semantic event - -> ordinary application input +status = await session.VerifyCapabilityAsync( + TerminalCapability.RasterGraphics, + cancellationToken +); ``` -The first semantic family is interactive notification reporting. A semantic notification event is available through: - -```csharp -if ( terminalEvent.Kind == TerminalEventKind.Semantic ) { - TerminalNotificationEvent? notification = - terminalEvent.Semantic?.Notification; -} -``` +`TerminalCapabilityStatus` keeps support, endpoint availability, evidence lifetime, and current usability separate. Silence is not automatically `Unsupported`, and an unavailable endpoint does not erase truthful support knowledge. -The reviewed notification event kinds are: +Version 1.11 adds: ```text -Activated -ButtonActivated -Closed -CloseTrackingUnavailable +TerminalCapability.PersistentRasterGraphics = 9 ``` -There is no second `ReadSemanticEventAsync(...)` path. Semantic events share the same bounded application-event ordering domain as ordinary input, and query responses retain first ownership of matching terminal traffic. +This is distinct from ordinary `RasterGraphics`: verified Sixel may satisfy ephemeral raster display, while persistent resource ownership requires the reviewed persistent-capable Kitty Graphics path. -## Interactive Kitty notifications - -`SendKittyNotificationAsync(...)` supports explicit opt-in reporting through `KittyNotificationOptions`: - -```csharp -await session.SendKittyNotificationAsync( - "Build", - "Compilation complete", - new KittyNotificationOptions { - Identifier = "build-42", - ReportActivation = true, - ReportClose = true, - Buttons = [ "Acknowledge", "Dismiss" ] - } -); -``` - -Reporting requires a caller-supplied identifier. Button reports are one-based. Notification identifiers and reported interactions are validated but unauthenticated terminal-controlled input; applications must not use them as an authorization boundary. +See [`docs/Capability-Inspection-and-Planning.md`](docs/Capability-Inspection-and-Planning.md). ## Raster graphics -The public raster API remains semantic and backend-neutral: +### Ephemeral display + +The public raw-raster API remains backend-neutral: ```csharp TerminalRasterImage image = TerminalRasterImage.CreateRgb24( @@ -185,37 +126,79 @@ TerminalRasterImage image = TerminalRasterImage.CreateRgb24( TerminalControlMutationResult result = await session.DisplayRasterAsync( image ); ``` -Supported public storage forms are `Rgb24`, `Rgba32`, and `Indexed8` plus an RGBA8 palette. The session resolves raster output through verified Kitty Graphics/APC first and verified Sixel/DCS as fallback. +Supported public storage forms are `Rgb24`, `Rgba32`, and `Indexed8` with an RGBA8 palette. The session can resolve ordinary raster display through verified Kitty Graphics or verified Sixel without exposing backend selection publicly. + +### Persistent resources and placements + +Version 1.11 adds a separate ownership model for terminal-resident raster data: + +```csharp +TerminalCapabilityStatus capability = await session.VerifyCapabilityAsync( + TerminalCapability.PersistentRasterGraphics +); + +if ( capability.IsUsable ) { + TerminalControlResult resourceResult = + await session.CreateRasterResourceAsync( image ); + + await using TerminalRasterResource resource = + resourceResult.GetRequiredValue(); + + TerminalControlResult placementResult = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = 24 + } + ); -The raster contract deliberately does not expose raw DCS/Sixel or APC/Kitty dispatch, backend selection, persistent Kitty image identifiers, placement/scaling, source rectangles, z-order, Unicode placeholders, deletion, animation, or image-file decoding/transcoding. + await using TerminalRasterPlacement placement = + placementResult.GetRequiredValue(); + + TerminalControlMutationResult update = await placement.UpdateAsync( + new TerminalRasterPlacementOptions { + Columns = 16 + } + ); +} +``` + +Resource and placement identity is opaque. The public API does not expose Kitty image ids, image numbers, placement ids, raw APC commands, or backend selection. + +`Columns` and `Rows` are independently optional and each supplied value is bounded to `1..16384`. Placement uses the terminal's current cursor location and does not move the text cursor. To reposition a placement, move the cursor through ordinary terminal semantics and call `UpdateAsync(...)`. + +Persistent identities are session-generation scoped. Explicit invalidation and lifecycle generation changes stale existing handles. Version 1.11 does not retain hidden raster copies for automatic replay or re-upload after suspend/resume uncertainty. + +The library bounds live ownership to 256 persistent resources and 4096 placements per session. Current cleanup deletes placements before resource data; stale cleanup is local-only and never emits stale numeric protocol identities. + +See [`docs/Persistent-Raster-Ownership.md`](docs/Persistent-Raster-Ownership.md). ## Core 1.x guarantees ### One authoritative input path -Application input, semantic events, lifecycle traffic, and terminal query responses share one coordinated session. Queries and explicit capability verification reuse that same bounded, correlation-aware path. No feature opens a competing terminal reader. +Application input, semantic events, lifecycle traffic, terminal query responses, capability verification, and persistent-raster acknowledgements share one coordinated session. No feature opens a competing terminal reader. ### Deterministic ownership -Active query responses have first refusal on matching framed traffic. Recognized unsolicited semantic reports are classified next, followed by ordinary application input. A frame is never intentionally double-delivered as both a query response and a semantic event. +Active query responses have first refusal on matching framed traffic. Recognized unsolicited semantic reports are classified next, followed by ordinary application input. A frame is never intentionally double-delivered. ### Truthful capability evidence -Static description evidence and generation-scoped live observation remain distinct. Timeout is not automatically `Unsupported`; endpoint unavailability is not rewritten as lack of terminal support; negative evidence for one backend does not erase an independent viable alternate. +Static description evidence and generation-scoped live observations remain distinct. Timeout is not automatically unsupported truth; endpoint unavailability is separate from support knowledge; negative evidence for one backend does not erase an independent viable alternate. -### Bounded protocol handling +### Bounded protocol and graphics state -Terminal-controlled input is untrusted. Control-frame parsing, query state, semantic-event buffering, raster dimensions/storage, and protocol payloads have explicit resource ceilings. Malformed/oversized owned semantic reports recover boundedly rather than leaking hostile bytes into ordinary text. +Terminal-controlled input is untrusted. Control-frame parsing, query state, semantic-event buffering, raster dimensions/storage, protocol payloads, persistent resource registries, and placement registries have explicit ceilings. -### Reversible ownership +### Reversible and generation-scoped ownership -Scoped state uses leases where overlapping ownership matters. `TerminalSession.DisposeAsync()` remains final cleanup/restoration authority for session-owned state. Ephemeral notifications and semantic observations are not replayed as reversible state across suspend/resume. +Scoped reversible terminal state uses leases where overlapping ownership matters. `TerminalSession.DisposeAsync()` remains final cleanup/restoration authority. -### Serialized semantic output +Persistent raster resources are different from exactly restorable state: they are terminal-resident, generation-scoped objects. The session cleans them while identity is current, invalidates them when terminal certainty is lost, and does not silently retain/replay source images. -Ordinary terminal-aware output should use session semantic operations. `TerminalSession.Output` is an advanced borrowed transport outside normal session serialization when used directly by callers. +### Committed output integrity -Committed multi-frame graphics operations do not intentionally truncate after commitment, and partial transport failure is surfaced without blind replay or automatic backend switching. +Committed multi-frame graphics operations do not intentionally truncate after commitment. Partial transport failure is surfaced without blind replay or automatic backend switching. ## Feature highlights @@ -224,66 +207,62 @@ The stable 1.x surface includes: - protocol-neutral semantic capability inspection and explicit bounded verification; - application text and resolved terminfo capability output; - terminal input, lifecycle events, bracketed paste, focus, mouse, and negotiated modern keyboard reporting; -- unsolicited protocol-neutral semantic events, beginning with interactive notification reports; -- bounded Primary/Secondary DA, DSR, CPR, DECRQSS, XTGETTCAP, color, pointer, clipboard, and notification queries; -- terminal titles (OSC 0/1/2) and current location (OSC 7); -- hyperlinks (OSC 8) and clipboard/selection operations (OSC 52); -- cursor-style observation/ownership (DECSCUSR/DECRQSS); -- synchronized output (DEC private mode 2026); -- terminal progress (OSC 9;4) and pointer shape (OSC 22); -- desktop notifications through OSC 9, OSC 777, and typed Kitty OSC 99, including opt-in interactive reporting; -- portable semantic prompt/command metadata (OSC 133); -- typed VS Code shell integration (OSC 633); -- typed iTerm2 shell-integration/semantic-history metadata (OSC 1337); -- indexed palette and selected dynamic terminal colors (OSC 4/104, 10–14, 17, 19 and resets); -- backend-neutral raw raster display with verified Sixel and Kitty Graphics backends. +- unsolicited protocol-neutral semantic events; +- bounded device/status/cursor/style/color/clipboard/notification queries; +- titles, current location, hyperlinks, clipboard operations, cursor style, synchronized output, progress, pointer shape, notifications, prompt/shell metadata, and terminal colors; +- backend-neutral ephemeral raster display through verified Sixel and Kitty Graphics; +- backend-neutral persistent raster resources and placements with bounded generation-scoped ownership. + +The library deliberately does not expose generic raw vendor dispatch as the ordinary extension model. ## Samples -The [`samples`](samples/README.md) directory contains focused, buildable examples grouped by consumer goal. Recommended starting points are: +The [`samples`](samples/README.md) directory contains focused examples. Recommended starting points include: -- `Icod.Terminal.Sample` — session construction and basic event reading; +- `Icod.Terminal.Sample` — session construction and event reading; - `Icod.Terminal.RichInput.Sample` — text, keys, paste, focus, mouse, lifecycle, and semantic events; - `Icod.Terminal.CapabilityPlanning.Sample` — inspect-first semantic planning plus optional explicit verification; - `Icod.Terminal.Query.Sample` — bounded terminal queries; -- `Icod.Terminal.Notification.Sample` — OSC 9/777/99 notification output plus `--kitty-interactive` semantic-event reporting; -- `Icod.Terminal.RasterGraphics.Sample` — backend-neutral raster display without image-decoder dependencies; -- `Icod.Terminal.SemanticPrompt.Sample` — portable prompt/command metadata; -- `Icod.Terminal.VsCodeShellIntegration.Sample` and `Icod.Terminal.ITerm2ShellIntegration.Sample` — explicit vendor-specific shell-integration examples. +- `Icod.Terminal.RasterGraphics.Sample` — backend-neutral ephemeral raster display; +- `Icod.Terminal.PersistentRaster.Sample` — verify, create, place, update, and dispose persistent raster ownership without protocol ids/backend branching; +- focused state, color, notification, prompt, and shell-integration samples described in the sample catalog. -Focused sample verifiers build capability planning and newer protocol/raster examples on every supported target framework during repository validation. +Focused sample verifiers build newer semantic/raster examples on every supported target framework during repository validation. ## Security and privacy -Terminal protocol traffic is external input/output and must be treated accordingly. `Icod.Terminal` validates and bounds semantic protocol data and deliberately avoids generic raw vendor-command/event APIs as the normal extension mechanism. +Terminal protocol traffic is external input/output. `Icod.Terminal` validates and bounds semantic protocol data and avoids generic raw vendor-command/event APIs as the normal extension mechanism. -`InspectCapability(...)` emits no terminal traffic. `VerifyCapabilityAsync(...)` is explicit precisely because it may send bounded queries whose responses can reveal terminal/environment characteristics. A `Verified` result is support evidence, not authentication of the terminal, desktop session, or user. +`InspectCapability(...)` emits no terminal traffic. `VerifyCapabilityAsync(...)` is explicit precisely because it may send bounded queries whose responses can reveal terminal/environment characteristics. `Verified` is support evidence, not authentication. -Notification interaction reports can be fabricated by the terminal path. An identifier is correlation data, not authentication. Successful notification emission does not prove that the desktop displayed the notification, and a later typed report does not prove a trusted user action. +Persistent raster acknowledgement correlation establishes transaction ownership, not trust. A terminal may independently evict stored image data; correlated `ENOENT` invalidates local terminal-resident certainty rather than triggering hidden replay. -Several APIs intentionally publish caller-supplied metadata, including filesystem locations, hyperlinks, clipboard contents, notification text, shell metadata, and command lines. The library does not automatically discover or redact secrets; applications decide what is appropriate to disclose to the terminal. +Kitty direct transfer remains the reviewed persistent transport. Version 1.11 does not silently use file, temporary-file, or shared-memory transport and does not retain arbitrary source images after successful creation. -See [Security and Privacy](docs/Security-and-Privacy.md). +Several APIs intentionally publish caller-supplied metadata such as filesystem locations, hyperlinks, clipboard contents, notifications, shell metadata, command lines, and raster pixels. Applications decide what is appropriate to disclose. + +See [`docs/Security-and-Privacy.md`](docs/Security-and-Privacy.md). ## Compatibility -Stable `1.0.0` remains the compatibility floor. Versions 1.1–1.4 added compatible semantic protocol surfaces; 1.5 and 1.6 normalized internal control-language/query infrastructure; 1.7 introduced the public raster contract; 1.8 added Kitty Graphics beneath that unchanged raster surface; 1.9 added the protocol-neutral semantic-event envelope plus interactive Kitty notification request options; and 1.10 adds the protocol-neutral capability-planning surface. +Stable `1.0.0` remains the compatibility floor. Versions 1.1–1.4 added compatible semantic protocol surfaces; 1.5 and 1.6 normalized internal control/query infrastructure; 1.7 introduced backend-neutral raster display; 1.8 added Kitty Graphics beneath that surface; 1.9 added protocol-neutral semantic events; 1.10 added semantic capability planning; and 1.11 adds opaque persistent raster resource/placement ownership. -The final 1.10 public API fingerprint is: +The final 1.11 public API fingerprint is: ```text -ee705250d19d51df92645e5020f188646dd2dbf38483278e6e57ce6fbbc1e9fb +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 ``` -See [Compatibility and Versioning](docs/Compatibility-and-Versioning.md). Consumers upgrading from the pre-1.0 line should also review [Migration to 1.0](docs/Migration-to-1.0.md). +See [`docs/Compatibility-and-Versioning.md`](docs/Compatibility-and-Versioning.md). Consumers upgrading from the pre-1.0 line should also review [`docs/Migration-to-1.0.md`](docs/Migration-to-1.0.md). ## Documentation Start with: -- [1.10.0 release notes](docs/releases/1.10.0.md) +- [1.11.0 release notes](docs/releases/1.11.0.md) +- [Persistent Raster Ownership](docs/Persistent-Raster-Ownership.md) - [Capability Inspection and Planning](docs/Capability-Inspection-and-Planning.md) -- [1.10.0 development roadmap](Icod.Terminal-1.10.0-Development-Roadmap.md) +- [1.11.0 development roadmap](Icod.Terminal-1.11.0-Development-Roadmap.md) - [Current development roadmap](Icod.Terminal-Development-Roadmap.md) - [Architecture](docs/Architecture.md) - [Input and Events](docs/Input-and-Events.md) @@ -294,8 +273,18 @@ Start with: - [Samples](samples/README.md) - [Changelog](CHANGELOG.md) -Historical release and tranche records remain in the repository for design evidence, while the root README and current roadmap are maintained as concise consumer/contributor entry points. +Historical release and tranche records remain in the repository for design evidence, while this README and the current roadmap are maintained as consumer/contributor entry points. + +## Authors + +Inspired by original work from Bill Joy, author of the original `termcap`; Mary Ann (born Mark) Horton, author of `terminfo`; Pavel Curtis, author of `pcurses`; and Zeyd Ben-Halim, Eric S. Raymond, and Thomas Dickey, whose work developed and maintained `libtinfo` and `ncurses`. + +Managed .NET implementation by Timothy J. Bruce . + +## Copyright + +Copyright (c) 2026 Timothy J. Bruce ## License -`Icod.Terminal` is licensed under the GNU Lesser General Public License, version 3 or later. Sample applications are licensed under the GNU General Public License, version 3 or later, as stated in their source headers. \ No newline at end of file +`Icod.Terminal` is licensed under the GNU Lesser General Public License, version 3 or later. Sample applications are licensed under the GNU General Public License, version 3 or later, as stated in their source headers. diff --git a/docs/Architecture.md b/docs/Architecture.md index 9a4913cbf..0fbd3fbab 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -4,8 +4,6 @@ This document is a permanent 1.x architecture authority for `Icod.Terminal`. His ## 1. Role in the Icod terminal stack -`Icod.Terminal` is the live-terminal/session layer between immutable terminal capability data and higher-level terminal user interfaces. - ```text Applications commands / monitors / editors / pagers / REPLs @@ -28,13 +26,11 @@ but it is not part of the Icod.Terminal runtime dependency chain. ### `Icod.TermInfo` -`Icod.TermInfo` owns immutable terminal capability data and terminfo interpretation. It answers which capabilities exist, how parameterized strings expand, and how compiled/system/built-in terminal descriptions are resolved. - -`Icod.Terminal` consumes that information; it does not maintain a competing terminal-capability database. +`Icod.TermInfo` owns immutable terminal capability data, terminfo interpretation/expansion, and description resolution. `Icod.Terminal` consumes that information; it does not maintain a competing capability database. ### `Icod.Terminal` -`Icod.Terminal` owns one live terminal conversation and the mechanics needed to use it safely: +`Icod.Terminal` owns one live terminal conversation and the mechanics required to use it safely: - endpoint observation and native platform identity; - terminal-mode capture and semantic input policy; @@ -43,20 +39,21 @@ but it is not part of the Icod.Terminal runtime dependency chain. - one authoritative input-reader/decoder path; - active query/response correlation; - unsolicited protocol-neutral semantic event routing; -- semantic capability evidence, inspection, and bounded verification; +- semantic capability evidence, side-effect-free inspection, and bounded explicit verification; - bounded incremental control-language parsing; - semantic terminal-output operations; - reversible presentation, rich-input, and color ownership; -- semantic raster graphics output; -- evidence-driven raster backend selection; +- backend-neutral ephemeral raster output; +- persistent terminal-resident raster resource and placement ownership; +- evidence-driven backend selection behind semantic operations; - serialization of session-managed terminal output; -- lifecycle-aware re-entry and deterministic cleanup. +- lifecycle-aware invalidation, re-entry, and deterministic cleanup. ### `Icod.DCurses` -`Icod.DCurses` owns two-dimensional presentation policy: cells, styles, windows, pads, virtual-screen state, Unicode display width, clipping, wrapping, scrolling, damage tracking, desired-vs-physical screen comparison, and refresh strategy. +`Icod.DCurses` owns two-dimensional presentation policy: cells, styles, windows, pads, virtual-screen state, Unicode display width, clipping, wrapping, scrolling, damage tracking, desired-vs-physical screen comparison, refresh strategy, and higher-level scene/layout policy. -DCurses may request semantic capability planning and raster output from `Icod.Terminal`; it should not reimplement terminal modes, query routing, unsolicited semantic-event routing, Sixel/Kitty framing, capability evidence, or lifecycle restoration. +It may consume semantic capability planning, ephemeral raster display, or persistent raster resources from `Icod.Terminal`; it should not reimplement terminal modes, query routing, semantic-event routing, Sixel/Kitty framing, capability evidence, persistent protocol identity, or lifecycle restoration. ### Future `Icod.Pty` @@ -70,16 +67,16 @@ This is the preferred level for applications. `TerminalSession` exposes semantic operations for: -- reading normalized terminal events, including typed unsolicited semantic observations; +- reading normalized terminal events; - inspecting current semantic capability knowledge without terminal I/O; - explicitly requesting bounded verification where a reviewed live probe exists; - querying live terminal state through typed query methods; -- writing application text; -- emitting reviewed semantic terminal metadata/control operations; +- writing application text and reviewed semantic terminal metadata/control operations; - acquiring reversible presentation/input/color state; -- displaying backend-neutral raw raster images. +- displaying backend-neutral raw raster images ephemerally; +- creating generation-scoped persistent raster resources and placements where explicitly verified. -Ordinary callers should prefer these APIs because they participate in session ordering, validation, resource bounds, capability evidence, lifecycle, and restoration semantics. +These APIs participate in session ordering, validation, resource bounds, capability evidence, lifecycle, and cleanup semantics. ### 2.2 Advanced transport/provider API @@ -91,14 +88,12 @@ These contracts do not imply that a live session can be bypassed safely. In part ### 2.3 Internal wire/platform machinery -Protocol encoders/parsers, control-family writers, Sixel quantization/encoding, Kitty Graphics adaptation/chunking, unsolicited OSC 99 recognition, query transactions, capability evidence storage, lifecycle signal sources, presentation/input managers, and OS plumbing remain implementation details unless represented separately by a public semantic contract. +Protocol encoders/parsers, control-family writers, Sixel quantization/encoding, Kitty Graphics adaptation/chunking/persistent ids, semantic-event recognition, query transactions, capability evidence storage, lifecycle signal sources, presentation/input managers, and OS plumbing remain implementation details unless represented separately by a public semantic contract. -Internal wire selectors are not compatibility promises merely because a public semantic API ultimately uses them. +Internal selectors and numeric protocol identities are not compatibility promises merely because public semantic APIs ultimately use them. ## 3. Capability-driven, not terminal-brand-driven -`Icod.Terminal` does not equate an interactive terminal with a particular emulator or `TERM` value. - The normalized model separates: ```text @@ -112,15 +107,13 @@ endpoint availability Static TermInfo/profile advertisement and generation-scoped live evidence are distinct. A terminal name, `TERM`, environment variable, host OS, emulator brand, registry order, or caller preference is not automatically capability proof. -For Sixel, Primary Device Attributes parameter `4` is positive protocol-response evidence. A valid response without `4`, or silence/timeout, remains uncertainty rather than automatic proof of unsupported Sixel. - -For Kitty Graphics in 1.8, the library uses the protocol-defined correlated Kitty query immediately followed by Primary DA as a barrier. A correlated Kitty APC response verifies `ApcKittyGraphics`; Primary DA arriving first is reviewed negative protocol-response evidence for that concrete probe. Silence before either authoritative result remains unknown. +For Sixel, Primary Device Attributes parameter `4` may provide positive support evidence. A valid response without `4`, silence, timeout, or caller cancellation does not automatically prove terminal-wide unsupported Sixel. -Capability evidence is generation-scoped where it describes live protocol behavior and expires on session invalidation/resume. +For Kitty Graphics, the reviewed support path uses the protocol-defined correlated query plus Primary DA barrier semantics. A valid correlated Kitty response verifies that backend for the current generation; Primary DA arriving first is reviewed negative evidence for that concrete probe; silence before either authoritative result remains unknown. -### 3.1 Public capability planning in 1.10 +### Public capability planning -Version 1.10 exposes a deliberately reduced public projection over this internal model: +The public vocabulary is deliberately reduced: ```text TerminalCapability @@ -130,21 +123,17 @@ TerminalCapabilityEvidenceKind TerminalCapabilityStatus ``` -The public model answers semantic questions without exposing internal backend ids or dependency provenance. Static implementation evidence projects to `StaticDescription`; generation-scoped protocol/query evidence projects to `LiveObservation`. +`TerminalSession.InspectCapability(...)` is synchronous and side-effect free. It projects existing knowledge only. -`TerminalSession.InspectCapability(...)` is side-effect free. It reads existing session knowledge only and emits no terminal bytes. +`TerminalSession.VerifyCapabilityAsync(...)` is explicit because it may emit bounded probe traffic. It reuses reviewed existing probe paths rather than inventing traffic for every semantic capability. -`TerminalSession.VerifyCapabilityAsync(...)` is explicit because verification may emit bounded query traffic. It reuses only existing reviewed support probes, initially modern keyboard reporting and raster graphics. Capabilities without a reviewed probe remain inspection-only rather than gaining invented traffic merely to make every public enum value probeable. +Public evidence is only `None`, `StaticDescription`, or `LiveObservation`; backend ids, routing scores, raw protocol frames, and `Icod.TermInfo` provenance remain private. -Support and endpoint availability remain separate dimensions. A statically advertised capability can remain `Advertised` while its required endpoint is `Unavailable`; `IsUsable` becomes false without rewriting truthful support knowledge to `Unsupported`. - -When one semantic operation has multiple viable backends, evidence is resolved per reviewed candidate. Negative evidence for one backend does not erase independent positive/static evidence for another backend that can still satisfy the semantic operation. - -The permanent public contract is documented in `Capability-Inspection-and-Planning.md`. +Version 1.11 adds `PersistentRasterGraphics = 9`. It is separate from ordinary `RasterGraphics`: Sixel may satisfy ephemeral raster display, while persistent terminal-resident resource ownership requires the reviewed persistent-capable Kitty Graphics path. ## 4. Control-language layering -Version 1.5 established the permanent control-language separation: +The permanent separation is: ```text semantic intent @@ -156,7 +145,7 @@ semantic intent The normalized framing vocabulary includes CSI, DCS, OSC, APC, PM, and SOS. -The two raster backends demonstrate the separation directly: +Raster examples make the separation concrete: ```text RasterGraphics @@ -168,143 +157,145 @@ RasterGraphics -> ApcKittyGraphics -> APC -> Kitty Graphics -``` -A caller therefore keeps the same `TerminalRasterImage` and `DisplayRasterAsync(...)` semantic contract regardless of which verified backend is selected. +PersistentRasterGraphics + -> ApcKittyGraphics + -> APC + -> private resource / placement protocol identity +``` -Version 1.9 applies the same layering to inbound unsolicited traffic: public semantic notification observations are not raw OSC frames and do not expose Kitty selector dictionaries merely because OSC 99 is the first dialect implemented beneath the semantic-event envelope. +The public raster/resource contracts remain semantic even though the current persistent implementation is Kitty-specific internally. -Version 1.10 applies the same rule to capability planning: public callers ask about `RasterGraphics` or `KeyboardReporting`, not `ApcKittyGraphics`, `DcsSixel`, OSC/CSI/DCS/APC, or a terminfo capability key. +## 5. Backend-neutral raster data -## 5. Backend-neutral raster architecture +`TerminalRasterImage`, `TerminalRasterPixelFormat`, and `TerminalRasterColor` represent bounded raw image data, not a Sixel/Kitty payload container. -Version 1.7 introduced the public raster model: +Supported storage forms are: ```text -TerminalRasterImage -TerminalRasterPixelFormat -TerminalRasterColor +Rgb24 +Rgba32 +Indexed8 + RGBA8 palette ``` -Supported raw formats are: +The raster object owns a snapshot of caller-provided pixel/palette storage. Straight alpha is preserved. Mutable backing buffers are not exposed publicly. + +Raster ceilings are explicit: ```text -Rgb24 -Rgba32 -Indexed8 + RGBA8 palette +maximum dimension 16,384 +maximum pixel count 16 Mi +maximum owned pixel data 64 MiB +maximum indexed palette 256 entries ``` -The raster object is an immutable-owned snapshot. Caller-provided pixel/palette storage is copied at creation. The public API intentionally does not expose mutable backing buffers or a Sixel/Kitty payload container. +Image-file decoding, gamma/color-profile processing, and hidden background compositing remain outside the core contract. + +## 6. Ephemeral raster display + +`TerminalSession.DisplayRasterAsync(...)` represents ephemeral display intent. -The raw model preserves straight alpha and is intentionally more expressive than any single backend. Backend limitations are handled during routing/conversion rather than by weakening the common image model. +Its reviewed internal backends are: + +```text +verified ApcKittyGraphics +verified DcsSixel +``` -Version 1.8 leaves the public surface unchanged and adds Kitty Graphics beneath it. `DisplayRasterAsync(...)` still does not expose Sixel palette registers, Kitty image/placement identifiers, raw DCS/APC framing, or an explicit backend selector. +When both are verified, Kitty Graphics is preferred; verified Sixel remains fallback. Routing decisions happen before commitment. -## 6. Bounded graphics pipelines +Once a backend commits output, transport/protocol failure is surfaced. The library does not replay through another backend because the terminal may have applied an unknown prefix. -The Sixel path is: +The Sixel pipeline is: ```text TerminalRasterImage -> deterministic bounded quantization -> SixelPaletteImage - -> bounded lazy Sixel payload segments - -> committed serialized DCS transaction + -> bounded lazy payload segments + -> serialized DCS transaction ``` -The Kitty Graphics path is: +The Kitty direct-transfer pipeline is: ```text TerminalRasterImage - -> checked raw Kitty adaptation + -> checked raw adaptation -> RGB24 or RGBA32 byte stream - -> bounded lazy Base64 payload chunks - -> committed serialized APC frame sequence + -> bounded lazy Base64 chunks + -> serialized APC frame sequence ``` -The common raster object enforces explicit limits for dimensions, total pixels, owned bytes, and indexed palette entries. Backend-specific stages add their own bounded work state and frame/chunk limits. - -Large graphics do not require one giant encoded DCS string, one giant Base64 string, or one complete multi-frame Kitty transfer allocation. +Large images do not require one giant encoded string/allocation. -Image-file decoding, gamma/color-profile processing, and hidden background compositing are outside the core raster contract. +## 7. Persistent raster ownership — 1.11 -## 7. Raster backend selection +Version 1.11 adds a separate terminal-resident ownership domain rather than widening `DisplayRasterAsync(...)` into a scene graph. -`RasterGraphics` is one semantic operation with two reviewed backends in 1.8: +The semantic ownership graph is: ```text -verified ApcKittyGraphics -verified DcsSixel +TerminalSession + -> TerminalRasterResource + -> TerminalRasterPlacement + -> TerminalRasterPlacement + -> ... ``` -When both are verified, Kitty Graphics is preferred. Verified Sixel remains fallback. When evidence is unresolved, the session may perform bounded live probing according to the reviewed routing policy. +`TerminalSession.CreateRasterResourceAsync(...)` publishes a public resource only after acknowledged upload establishes a private terminal identity. -Routing decisions occur before commitment. Once either backend commits output, a transport/protocol failure is returned to the caller; the library does not replay through the other backend because the terminal may have applied an unknown prefix of the first attempt. +`TerminalRasterResource.CreatePlacementAsync(...)` creates opaque child ownership. `TerminalRasterPlacement.UpdateAsync(...)` replaces the same private placement at the terminal's current cursor position. -Backend selection is therefore both capability-sensitive and commit-sensitive. +`TerminalRasterPlacementOptions.Columns` and `.Rows` are independently optional and bounded to `1..16384`. The reviewed backend uses no-cursor-movement placement semantics. -## 8. Alpha and image semantics +### Private identity -The public raster model preserves straight RGBA8 alpha. +Internally, resource upload uses a nonzero private image number for acknowledgement correlation and receives a nonzero terminal-assigned image id. Placement identity is likewise private and nonzero. -Kitty Graphics direct RGBA32 transfer can preserve fractional alpha. Sixel cannot represent the same semantics without an external compositing policy; therefore fractional alpha remains controlled unsupported when Sixel is the selected backend rather than being silently flattened. +Public resources/placements never expose those ids. Callers cannot manufacture raw resource/placement protocol identity. -Indexed input is expanded for Kitty only as required by its direct raw formats: +### Bounded registries -- referenced opaque palette colors permit RGB24 expansion; -- any referenced non-opaque palette color requires RGBA32 expansion preserving alpha. +Session bookkeeping is bounded: -Source width and height remain intrinsic raster dimensions. Version 1.8 does not add a common placement/scaling contract, persistent image identity, source rectangles, z-order, Unicode placeholders, deletion, animation, or cursor-normalization control. +```text +256 live persistent resources +4096 live persistent placements +``` -## 9. Session-managed output ordering +Allocation avoids live collisions and handles numeric wraparound. These are local ownership bounds, not terminal storage-quota promises. -High-level application text, semantic output operations, query requests, state-manager traffic, and raster output use session-owned serialization domains appropriate to their contracts. +### No hidden raster cache -Committed Sixel output holds the session output gate across: +After successful creation, the persistent registry retains ownership metadata, not an arbitrary hidden `TerminalRasterImage` copy. Version 1.11 therefore does not promise automatic re-upload/rebind. -```text -DCS prefix -payload segment(s) -final ST -flush -``` +See `Persistent-Raster-Ownership.md` for the full public ownership contract. -Committed Kitty output holds the same logical gate across: +## 8. Alpha and image semantics -```text -APC frame 1 -APC frame 2 -... -APC final frame -flush -``` +Kitty direct RGBA32 can preserve fractional alpha. Sixel cannot represent equivalent semantics without external compositing policy, so fractional-alpha display through Sixel is controlled unsupported rather than silently flattened. + +Indexed input expands for Kitty only as required by its raw direct formats: opaque referenced palette colors permit RGB24; any referenced non-opaque color requires RGBA32. -Each Kitty frame is structurally complete, but the entire direct transfer is one logical transaction for interleaving/cancellation purposes. +Persistent resource creation reuses the same bounded raw adaptation semantics and does not add image decoding/transcoding. -Caller cancellation is honored before commitment. Once the first backend frame commits, ordinary caller cancellation does not intentionally truncate the logical graphics transfer. Transport failure is surfaced without retry or speculative recovery. +## 9. Session-managed output ordering -Session teardown drains committed output before output-state restoration continues. +High-level application text, semantic output, query requests, reversible state traffic, ephemeral raster output, and persistent raster transactions use session-owned serialization domains appropriate to their contracts. -The library does not claim to serialize writes made directly to the borrowed `TerminalSession.Output` transport. +Committed Sixel output holds the session output gate through final ST/flush. Committed Kitty direct output holds it across all APC frames through final flush. -## 10. One authoritative input conversation +Persistent resource upload is also one logical committed transfer. Placement create/update are serialized operations coordinated with acknowledgement through the existing query manager. -Input is not a collection of independent readers. +Caller cancellation is honored before commitment. After commitment, ordinary cancellation does not intentionally truncate the logical graphics transaction. -The live session owns one incremental byte stream that may contain: +Session teardown drains committed output before final output-state restoration continues. -- UTF-8 text; -- traditional keys; -- mouse/focus/bracketed-paste reports; -- modern keyboard frames; -- responses to active terminal queries; -- unsolicited semantic reports; -- side-observed correlated Kitty Graphics probe replies; -- malformed or unknown terminal traffic. +## 10. One authoritative input conversation -The decoder/query router form one authoritative path. Public event reads and typed queries coordinate through it. +A live session owns one incremental byte stream containing ordinary text/keys/paste/mouse/focus data, lifecycle traffic, active query responses, unsolicited semantic reports, graphics probe responses, and persistent graphics acknowledgements. -Version 1.9 freezes the inbound precedence as: +The stable precedence is: ```text active query/response ownership @@ -312,140 +303,119 @@ active query/response ownership -> ordinary application-input decoding ``` -A frame accepted by an active query is never also published as a semantic event. A recognizable unsolicited semantic report does not satisfy an unrelated query merely because both share a control family or OSC number. - -Ordinary input and unsolicited semantic events share the same bounded application-event ordering domain. Their same-byte-stream order is preserved without an unbounded semantic side queue or second terminal reader. - -Both Sixel capability observation and Kitty Graphics probing reuse this existing path. Neither backend creates a graphics-specific reader. - -For the Kitty support test, the active Primary DA query remains the barrier transaction while the input coordinator side-observes only the matching Kitty APC identified by its probe image id. +No raster or persistent-resource feature creates a graphics-specific reader. -Version 1.10 verification reuses those same reviewed probe/query paths. `InspectCapability(...)` never enters the input/query path because it emits no traffic. +Resource upload and placement create/update acknowledgements are correlated through the same transaction/query authority. Wrong private identities do not satisfy another transaction. -`ReadEventAsync(...)` is the unified application event path. `ReadLifecycleEventAsync(...)` consumes the same lifecycle queue rather than a duplicated event stream; applications should choose one lifecycle-consumption ownership pattern rather than run independent competing readers. +## 11. Correlation grants ownership, not trust -## 11. Correlated and semantic input ownership +Terminal responses remain untrusted after they become transaction-owned. -Terminal responses and unsolicited reports are untrusted even after routing ownership is established. +Matching identifiers do not bypass grammar, termination, size, duplicate-field, or numeric-overflow validation. Malformed/oversized owned responses are recovered boundedly rather than leaked into ordinary application input. -For Kitty Graphics, once a complete matching `i=` field is observed in a recognizable APC prefix, that string becomes boundedly owned by the active probe. Later CAN/SUB, malformed termination, overflow, oversize, or missing ST cannot turn that identified response back into ordinary application input. - -For 1.9 unsolicited OSC 99 notifications, semantic recognition likewise grants bounded ownership, not trust. A malformed or oversized semantic candidate is consumed/recovered deterministically rather than leaked as ordinary application text. After recovery, the decoder restarts at active-query precedence so immediately following correlated traffic cannot be bypassed. - -Correlation or semantic recognition does not bypass grammar or size checks. Oversized owned control strings use bounded resynchronization, and identified/recognized but unterminated traffic is handled as malformed according to the relevant bounded contract rather than being mislabeled as ordinary text or simple silence. - -Unrelated APC/OSC/DCS/CSI traffic remains outside a particular query or semantic observation unless owned by another active routing/decoder rule. +For persistent graphics, a well-formed correlated `ENOENT` means the terminal no longer recognizes an object the session believed current. That invalidates terminal-resident certainty; it is not permission for hidden replay. ## 12. Ownership and reversible state -A `TerminalSession` owns terminal **state transitions**, not necessarily the underlying descriptor, handle, stream, or transport object. - -Consequences include: +A `TerminalSession` owns terminal state transitions, not necessarily the underlying descriptor/stream/transport. -- supplied endpoints/transports remain borrowed; -- session disposal restores state the session changed but does not close caller-owned transports; -- reversible terminal features use leases when logical consumers may overlap; -- exact restoration is based on observed/captured state rather than a guessed normal state; -- state that cannot be observed/restored truthfully is not falsely described as exactly restorable; -- invalidation marks stale beliefs untrustworthy rather than inventing certainty. +Supplied transports remain borrowed. Disposal restores state the session changed but does not close caller-owned transports. -Raster display is ephemeral output, not a claim of reversible terminal image state. Version 1.8 does not capture/restore external Sixel palettes or Kitty image/placement state and does not replay raster images automatically after resume. +Reversible terminal features use leases when consumers may overlap. Exact restoration is based on observed/captured state rather than guessed defaults. -Interactive notification requests and notification semantic events are also not reversible session state. Version 1.9 does not retain a hidden notification database, replay sent notifications after resume, synthesize interaction events, or automatically close identified notifications on session disposal. +Ephemeral raster display is output, not reversible state. -## 13. Lifecycle is a state transition +Persistent raster resources are also **not exactly restorable state**. They are explicit generation-scoped terminal-resident ownership. While identity is current, the session can target cleanup. Once lifecycle uncertainty invalidates identity, stale handles perform local-only cleanup and no stale numeric identifiers are emitted. -When supported, suspend/resume is integrated with terminal ownership. +The library does not retain/replay resources merely to simulate restoration. -Before suspension, the session restores owned terminal state needed to return control safely to the host. After resume, stale beliefs and generation-scoped live evidence are invalidated, configured/owned state is re-established, required observations are refreshed, participants resume, and normal operation continues. +## 13. Lifecycle as a trust boundary -Already-decoded semantic events remain application observations and are not retroactively invalidated or replayed by lifecycle generation changes. Notification requests are not replayed merely because reporting was enabled. +Suspend/resume and explicit invalidation are state transitions. -Lifecycle failure may invalidate session state rather than falsely report recovery. +Before suspension, reversible owned state is restored as required. After resume, generation-scoped live observations and persistent terminal-resident identity certainty expire; configured reversible state is re-established according to its own contract. -Capability evidence obtained from live protocol behavior expires with the same generation mechanism. Static description evidence remains available. Already returned `TerminalCapabilityStatus` values are immutable snapshots; callers inspect again after invalidation/resume when they need current knowledge. +Already-returned capability statuses are immutable snapshots. Callers inspect/verify again when current knowledge matters. -## 14. Failure and uncertainty +Persistent handles do not revive automatically after generation invalidation. Applications create new resources explicitly if they still need them. -The architecture prefers truthful uncertainty over optimistic advancement. +## 14. Deterministic persistent cleanup -For reversible state, partial completion is tracked and rollback failures are surfaced. For capability evidence, silence does not become unsupported truth. A known protocol barrier may provide negative evidence only where that protocol's ordering semantics make the result authoritative. +Placement disposal releases local ownership once and, while current, attempts one targeted quiet delete. It is locally idempotent even when terminal cleanup transport fails. -For committed raster output, a transport failure does not trigger automatic replay because the terminal may have received an unknown prefix of the image or transfer. +Resource disposal prevents new children, closes child ownership first, attempts child cleanup before resource-data deletion, and aggregates multiple cleanup failures if necessary. -For notification semantics, `CloseTrackingUnavailable` remains distinct from `Closed`: inability to observe a future close is represented as uncertainty rather than a fabricated close event. +Session teardown deletes current placements before current resource data. If persistent state is already stale, teardown performs local bookkeeping only. -Controlled `Unavailable` / `Unsupported` results represent capability/semantic limitations where appropriate; transport and malformed-response failures remain failures rather than being disguised as capability states. +## 15. Failure and uncertainty -Version 1.10 makes the distinction explicit in the public planning model: `Unknown`, `Unsupported`, support evidence, endpoint availability, and current usability are separate concepts rather than one overloaded boolean. +The architecture favors truthful uncertainty over optimistic advancement. -## 15. Managed-first platform model +- query silence does not automatically become unsupported truth; +- unavailable endpoints are distinct from unsupported capability; +- correlated malformed responses are failures, not support evidence; +- partial committed graphics output is not replayed automatically; +- terminal `ENOENT` invalidates current resource certainty; +- cleanup failures are surfaced rather than hidden behind invented success; +- stale resource identity is not emitted after lifecycle invalidation. -The library is managed C# with narrowly scoped native interop for terminal/console mechanics. There is no runtime dependency on native `ncurses`, `curses`, `libtinfo`, or `termcap`. +## 16. Bounded work and storage -Platform differences remain explicit. Windows does not fabricate POSIX semantics, and POSIX does not fabricate Windows console-mode semantics. +Documented bounds are part of the safety architecture: -Sixel and Kitty Graphics are terminal traffic and do not require host-native graphics APIs. Kitty 1.8 uses direct protocol data and does not introduce filesystem/shared-memory/native-image-transfer dependencies. - -Interactive Kitty notifications in 1.9 are likewise terminal protocol traffic; `Icod.Terminal` does not become a host-native desktop-notification service. - -## 16. No process-global current terminal - -The primary API is instance-based. `Icod.Terminal` does not require a process-global current session or `cur_term`-style mutable singleton. - -Multiple session objects may exist, but the library does not promise that two independently created sessions may concurrently mutate or write protocol traffic to the same physical terminal without external coordination. - -## 17. Stable exclusions - -The stable 1.x architecture continues to exclude: +```text +normal terminal response frame 4,096 bytes +small complete DCS frame 4,096 bytes +small complete APC frame 8,192 bytes +Kitty Base64 image data per APC chunk 4,096 bytes +persistent resources 256/session +persistent placements 4096/session +``` -- arbitrary raw escape-sequence dispatch as the normal application API; -- arbitrary OSC/CSI/DCS/APC/vendor command selection; -- generic raw unsolicited-event streams or arbitrary vendor-event dictionaries; -- blind activation based on terminal branding; -- virtual-screen/window/cell management owned by `Icod.Terminal`; -- PTY/ConPTY child-process ownership; -- application command policy; -- global keyboard hooks, remapping, IME control, or OS-wide hotkeys; -- image-file decoding inside the core terminal package; -- generalized graphics scene/animation ownership. +Parser, query, semantic-event, raster conversion, and ownership registries remain bounded. -Version 1.8 owns semantic raster output through Sixel and Kitty Graphics. It still excludes public protocol-specific graphics dispatch, file/temp-file/shared-memory Kitty transfer, persistent image/placement ownership, generalized placement/scaling policy, z-order, Unicode placeholder placement, deletion, and animation. +## 17. Security-sensitive transport choices -Version 1.9 owns a protocol-neutral unsolicited semantic-event envelope and typed interactive notification observations. It still excludes a second semantic reader, callback stream competing with `ReadEventAsync(...)`, raw OSC event delivery, host-native notification ownership, notification authentication, persistent notification databases, replay, and automatic close-on-dispose. +Kitty direct transfer remains the reviewed raster transport. The library does not silently choose file, temporary-file, or shared-memory transfer because those introduce path naming, permissions, lifetime, visibility, race, and cross-process concerns. -Version 1.10 owns a protocol-neutral semantic capability-planning projection. It still excludes the internal backend registry, routing preference scores, raw evidence-ledger entries, dependency-specific evidence identities, arbitrary terminfo capability names, terminal-brand heuristics, hidden/background probing, and a requirement that every semantic capability have a live probe. +Base64 is protocol framing, not encryption. -## 18. Compatibility authority +Persistent storage is owned by the terminal and may be evicted according to terminal policy; local registry bounds do not imply terminal storage reservation. -The public raster additions remain frozen by `docs/Public-API-Baseline-1.7.md` and `.sha256`. Versions 1.8.0 and 1.8.1 intentionally add no public API and retain that fingerprint. +## 18. Stable architectural exclusions -Version 1.9's additive semantic-event and interactive-notification public surface is frozen by: +Stable 1.x does not treat the following as ordinary `Icod.Terminal` responsibilities: -```text -docs/Public-API-Baseline-1.9.md -docs/Public-API-Baseline-1.9.sha256 -``` +- process-global current-terminal state; +- competing live input readers; +- generic raw vendor control/event buses; +- terminal-brand-driven capability proof; +- image-file decoding/transcoding; +- hidden graphics replay; +- unbounded graphics/query/event state; +- PTY/ConPTY process hosting; +- cells, windows, layout, damage, or scene-graph ownership. -with final fingerprint: +Advanced persistent placement features such as source rectangles, z-order, Unicode placeholders, relative/pixel placement, and animation require separate review and downstream justification. -```text -e652e6fd65cd43422ca84b7c4c2a1815ee7ead9b2a64285e0e17cf39614b0315 -``` +## 19. Dependency boundary -Version 1.10's additive semantic capability-planning surface is frozen by: +`Icod.Terminal.csproj` is the direct NuGet dependency authority. Tests, samples, package consumers, and verification tools do not independently pin exact transitive runtime dependency versions merely to duplicate package metadata. -```text -docs/Public-API-Baseline-1.10.md -docs/Public-API-Baseline-1.10.sha256 -``` +Successful restore/build of the declared package graph remains the normal dependency-compatibility witness. -with final fingerprint: +## 20. Permanent authorities -```text -ee705250d19d51df92645e5020f188646dd2dbf38483278e6e57ce6fbbc1e9fb -``` +Related 1.x authorities include: -The permanent capability-planning semantics are in `Capability-Inspection-and-Planning.md`, and the permanent versioning rules are in `Compatibility-and-Versioning.md`. +- `Terminal-Session-and-Ownership.md`; +- `Lifecycle-and-Restoration.md`; +- `Input-and-Events.md`; +- `Queries-and-Responses.md`; +- `Presentation-and-Reversible-State.md`; +- `Capability-Inspection-and-Planning.md`; +- `Persistent-Raster-Ownership.md`; +- `Security-and-Privacy.md`; +- `Compatibility-and-Versioning.md`. -Architectural improvements may replace internal classes/algorithms without changing these documented semantic, ownership, evidence, serialization, security, and compatibility commitments. \ No newline at end of file +Historical tranche records remain design evidence; these permanent documents define the current supported architecture. diff --git a/docs/C110-Persistent-Raster-Architecture-and-API-Regret-Gate.md b/docs/C110-Persistent-Raster-Architecture-and-API-Regret-Gate.md new file mode 100644 index 000000000..69db1997c --- /dev/null +++ b/docs/C110-Persistent-Raster-Architecture-and-API-Regret-Gate.md @@ -0,0 +1,221 @@ +# C110 — Persistent Raster Architecture and API Regret Gate + +- **Release:** `Icod.Terminal 1.11.0` +- **Tranche:** C110 +- **Status:** accepted +- **Theme:** persistent raster resource/placement ownership before implementation +- **Stable compatibility floor:** `1.0.0` + +## Purpose + +C110 freezes the architectural and public-API decisions required before implementing terminal-resident persistent raster resources and placements. + +The approved design is: + +- `docs/superpowers/specs/2026-09-11-1.11.0-persistent-raster-design.md` +- `docs/superpowers/plans/2026-09-11-1.11.0-persistent-raster.md` + +C110 is deliberately documentation-only. It introduces no runtime behavior or public API by itself. + +## Public capability contract + +Version 1.11 adds exactly one semantic capability value: + +```text +TerminalCapability.PersistentRasterGraphics = 9 +``` + +Existing 1.10 values `0..8` remain frozen. + +`RasterGraphics` and `PersistentRasterGraphics` are intentionally distinct. A session may support ordinary raster display through Sixel while persistent raster ownership remains unknown or unsupported. + +## Frozen public API + +The approved additive public surface is: + +```csharp +public sealed class TerminalRasterPlacementOptions { + public int? Columns { get; set; } + public int? Rows { get; set; } +} + +public sealed class TerminalRasterResource : IAsyncDisposable { + public ValueTask> CreatePlacementAsync( + TerminalRasterPlacementOptions? options = null, + CancellationToken cancellationToken = default + ); + + public ValueTask DisposeAsync(); +} + +public sealed class TerminalRasterPlacement : IAsyncDisposable { + public ValueTask UpdateAsync( + TerminalRasterPlacementOptions? options = null, + CancellationToken cancellationToken = default + ); + + public ValueTask DisposeAsync(); +} + +public sealed partial class TerminalSession { + public ValueTask> CreateRasterResourceAsync( + TerminalRasterImage image, + CancellationToken cancellationToken = default + ); +} +``` + +No public image id, image number, placement id, backend selector, raw APC/Kitty command object, registry enumeration, or mutable raster storage is approved. + +## Placement semantics + +`TerminalRasterPlacementOptions.Columns` and `.Rows` are nullable. A supplied value must be within: + +```text +1..16384 +``` + +Null means protocol/default intrinsic behavior. + +Placement occurs at the current terminal cursor position. Persistent placement commands use `C=1`, so the graphics operation itself does not move the text cursor. Repositioning is performed by the caller through ordinary terminal cursor operations followed by `UpdateAsync(...)`, which reuses the same private placement identity. + +Absolute screen coordinates, pixel offsets, source rectangles, z-order, Unicode placeholders, relative placements, animation, and scene-graph semantics are excluded from 1.11. + +## Identity and registry contract + +Public resource and placement handles are opaque. + +Internally: + +- resource upload uses a private nonzero Kitty image number (`I`); +- the terminal acknowledgement supplies the nonzero image id (`i`) used thereafter; +- placements receive private nonzero placement ids (`p`); +- zero is never allocated; +- wraparound and collision avoidance are explicit; +- no live identity is reused while still owned. + +Per-session registry ceilings are: + +```text +256 live persistent resources +4096 live persistent placements +``` + +Capacity exhaustion returns controlled `Unavailable` before terminal output. + +The registry stores ownership metadata only. It does not retain `TerminalRasterImage` or arbitrary source pixel data for replay. + +## Result and failure contract + +The existing result types are reused: + +- `TerminalControlResult` for resource/placement creation; +- `TerminalControlMutationResult` for placement updates. + +Meaning: + +```text +Available operation established under the current generation +Unavailable endpoint/generation/object cannot currently satisfy the operation +Unsupported persistent semantic capability is not supported by current verified evidence +Failed well-formed negative terminal response or other controlled failure +``` + +Existing exception semantics remain: + +- invalid argument/options -> argument exception; +- disposed public handle -> `ObjectDisposedException`; +- caller cancellation before commitment -> `OperationCanceledException`; +- bounded correlated timeout -> `TimeoutException`; +- correlated malformed terminal reply -> `FormatException`; +- transport failure -> propagated transport exception. + +A Kitty `ENOENT` for a believed-current resource/placement invalidates that terminal-resident object and produces controlled `Unavailable`. Other well-formed Kitty negative replies produce controlled `Failed` with bounded diagnostic text. + +## Protocol and acknowledgement contract + +Persistent upload uses direct Kitty transmission only: + +```text +a=t +t=d +I= +``` + +The upload is acknowledged. Therefore it deliberately **does not use `q=2` quiet mode**. The terminal must be able to return: + +```text +i=,I=;OK +``` + +Once that response is accepted, all future operations use the assigned image id (`i`). + +Existing ephemeral raster output remains unchanged, including its established quiet direct-transfer bytes. + +Placement creation/update uses `a=p` with `C=1`, assigned image id, private placement id, and optional `c`/`r`. + +Placement disposal uses the lower-case image delete selector with both image and placement ids so only that placement is removed while image data remains available. Resource cleanup, after children are closed, uses the uppercase image delete selector to free image data when the terminal can do so. + +## Commit and cancellation contract + +Before output commitment: + +- arguments/options/capability/endpoint/cancellation are validated; +- output-gate acquisition remains cancellable. + +After the first frame of a logical persistent transfer commits: + +- ordinary caller cancellation does not intentionally truncate the transaction; +- remaining frames and flush complete through the session serialization boundary; +- transport failure is surfaced; +- no automatic replay occurs; +- no Sixel fallback occurs; +- terminal-resident certainty is not fabricated after ambiguous partial failure. + +A failed or ambiguous resource creation never publishes a usable resource handle. + +## Parent/child disposal contract + +A resource owns zero or more placements. + +Placement disposal is locally idempotent. While current, it emits at most one targeted placement delete. It always relinquishes local ownership even if terminal cleanup fails, while surfacing the failure to the caller. + +Resource disposal: + +1. stops new child creation; +2. closes live child placements; +3. while current, requests terminal-side resource-data deletion; +4. releases local ownership; +5. aggregates cleanup failures. + +## Lifecycle contract + +Persistent terminal identities are generation-scoped. + +`InvalidateState()` and managed suspend/resume invalidate terminal-resident certainty. Existing resource/placement handles become stale. + +After invalidation: + +- no automatic replay/re-upload occurs; +- no hidden image copy is retained for restoration; +- create-placement/update return controlled `Unavailable` before terminal output; +- stale disposal performs local bookkeeping only and emits no stale numeric terminal identity. + +Session disposal remains final cleanup authority. If persistent identities are still current, placements are deleted before parent resources. Accepted committed output drains before final output-state restoration. + +## Security and architectural exclusions + +1. No caller-supplied raw Kitty identifiers. +2. No generic public Kitty/APC command builder. +3. No file/temp-file/shared-memory transmission. +4. No unbounded resource or placement registry. +5. Acknowledgements remain untrusted terminal-controlled input. +6. Correlation grants ownership, not trust. +7. No retained source-image cache for hidden replay. +8. No automatic retry/backend switch after partial committed output. +9. No Sixel persistent-resource emulation. +10. No cells/windows/layout/damage/scene ownership that belongs in `Icod.DCurses`. + +## Result + +C110 is accepted. The public/ownership design is sufficiently narrow to begin TDD implementation. C111 is the next active tranche and must first define byte-exact persistent Kitty protocol vectors without changing existing ephemeral raster output. \ No newline at end of file diff --git a/docs/C111-Persistent-Kitty-Protocol-Foundation.md b/docs/C111-Persistent-Kitty-Protocol-Foundation.md new file mode 100644 index 000000000..1dc8e64f9 --- /dev/null +++ b/docs/C111-Persistent-Kitty-Protocol-Foundation.md @@ -0,0 +1,83 @@ +# C111 — Persistent Kitty Protocol Foundation + +- **Release:** `Icod.Terminal 1.11.0` +- **Tranche:** C111 +- **Status:** accepted +- **Validated head:** `a37457fe603977d30a2b8727ecacd61376207857` +- **Acceptance workflow:** `#1542 / 34617396678` + +## Purpose + +C111 establishes the internal typed Kitty Graphics protocol substrate required for persistent raster resources and placements. It intentionally adds no public API. + +## Accepted protocol contract + +Persistent resource upload is a direct, transmit-only Kitty Graphics operation: + +```text +a=t +t=d +I= +``` + +Unlike the existing ephemeral display path, persistent upload requires a terminal acknowledgement and therefore omits quiet mode. The terminal remains free to return the assigned image id together with the private image number. + +The established one-pixel canonical upload payload is: + +```text +Ga=t,f=24,s=1,v=1,t=d,I=31,m=0;AAAA +``` + +Multi-frame persistent upload retains the existing 4096-byte encoded data ceiling but uses acknowledgement-preserving continuation payloads: + +```text +Gm=1; +Gm=0; +``` + +Existing ephemeral `DisplayRasterAsync(...)` bytes are unchanged and continue to use the established `a=T` / `q=2` direct-transfer behavior. + +## Placement and deletion contract + +Persistent placement uses the terminal-assigned image id and a private placement id: + +```text +Ga=p,i=,p=,C=1[,c=][,r=] +``` + +Re-emitting the same image-id/placement-id pair is the primitive used later by C116 for semantic placement update/replacement. + +Targeted placement disposal uses the lower-case image delete selector and retains image data: + +```text +Ga=d,d=i,i=,p= +``` + +Resource cleanup uses the uppercase image selector after child placements have been closed: + +```text +Ga=d,d=I,i= +``` + +## Correlation contract + +`KittyGraphicsPersistentResponseMatcher` correlates acknowledgement traffic by the private image number (`I`) rather than by a preselected terminal image id. + +A complete acknowledgement is parsed through the existing bounded `KittyGraphicsCodec.ParseResponse(...)` path. Duplicate keys, unsigned-integer overflow, malformed control data, and response-size limits therefore retain the existing validation behavior. + +Prefix correlation recognizes only a complete matching `I=` field in seven-bit or eight-bit APC framing. Incomplete or different image-number fields do not claim traffic. + +## TDD evidence + +The RED checkpoint intentionally failed the runtime build across all supported TFMs because `KittyGraphicsPersistentEncoder` did not yet exist. The observed failure was `CS0103` from the new C111 tests, with zero warnings. + +GREEN introduced: + +- `src/Graphics/KittyGraphicsPersistentEncoder.cs`; +- `src/Graphics/KittyGraphicsPersistentResponseMatcher.cs`. + +The final unchanged C111 head passed workflow `#1542 / 34617396678`, including Windows, Linux, macOS runtime validation, package candidate/public-API validation, package-contract shards, and the validated package artifact. + +## Result + +C111 is accepted. C112 may now add the public semantic capability `PersistentRasterGraphics = 9` and integrate it with the 1.10 capability-planning model without changing the now-qualified persistent Kitty wire substrate. diff --git a/docs/C112-Persistent-Raster-Capability-Integration.md b/docs/C112-Persistent-Raster-Capability-Integration.md new file mode 100644 index 000000000..a2bb477f2 --- /dev/null +++ b/docs/C112-Persistent-Raster-Capability-Integration.md @@ -0,0 +1,47 @@ +# C112 — Persistent Raster Capability Integration + +**Release:** `1.11.0-alpha.1` +**Tranche:** C112 +**Status:** accepted +**Accepted head:** `67923d7f3364bc693e52bdedd59e848d0db5672e` +**Qualification workflow:** `#1553 / 34623710345` + +## Accepted contract + +C112 adds the public semantic capability `TerminalCapability.PersistentRasterGraphics = 9` without changing the frozen numeric values `0..8`. + +Persistent raster capability is intentionally distinct from ordinary raster display capability: + +- `RasterGraphics` may be satisfied by verified Sixel or verified Kitty Graphics; +- `PersistentRasterGraphics` is satisfied only by the reviewed persistent-capable Kitty Graphics backend; +- verified Sixel alone never implies persistent-raster support; +- capability inspection remains side-effect free; +- explicit persistent-raster verification reuses the existing bounded Kitty Graphics support probe and does not probe Sixel. + +The N156 semantic backend registry contains a Kitty-only candidate for `PersistentRasterGraphics`, and N157 contains the corresponding explicit Kitty-only routing policy. Redirected/noninteractive output remains unavailable without probe traffic. + +## API baseline + +The provisional 1.11 public API baseline includes exactly the additive capability value introduced by C112. Existing `TerminalCapability` numeric assignments remain unchanged. + +The normalized 1.11 public API fingerprint at C112 is: + +```text +c037c3088a86c93da6f74b8e1deb3769ff9f43252d2fb871562b0519f333bd18 +``` + +## Qualification + +Exact head `67923d7f3364bc693e52bdedd59e848d0db5672e` passed pull-request workflow `#1553 / 34623710345` across: + +- Runtime Windows; +- Runtime Linux; +- Runtime macOS; +- Package candidate / public API freeze; +- Package Foundation; +- Package Presentation; +- Package Semantic and hardening; +- Package Stable 1.x release line; +- Validated package artifact. + +C112 is therefore closed. C113 may build the bounded session-owned persistent resource/placement registry on this qualified capability boundary. diff --git a/docs/C113-Persistent-Raster-Ownership-Registry.md b/docs/C113-Persistent-Raster-Ownership-Registry.md new file mode 100644 index 000000000..9ff94943b --- /dev/null +++ b/docs/C113-Persistent-Raster-Ownership-Registry.md @@ -0,0 +1,50 @@ +# C113 — Persistent Raster Ownership Registry + +**Release:** `1.11.0-alpha.1` +**Tranche:** C113 +**Status:** accepted +**Accepted head:** `ad48f0307f7e20714e3ad4533a98efd5ad9ec100` +**Qualification workflow:** `#1556 / 34625127402` + +## Accepted contract + +C113 introduces the internal, transport-free ownership substrate for persistent raster resources and placements. + +The registry is deliberately bounded and session-oriented: + +- at most 256 live resources; +- at most 4096 live placements across the session; +- resource image numbers and placement ids are private, nonzero 32-bit identities; +- allocation advances monotonically, wraps from `uint.MaxValue` to `1`, and never collides with a currently live identity; +- multiple placements may belong to one resource; +- resource release closes and unregisters all children; +- resource and placement local release is idempotent; +- generation is stamped into every reserved state object; +- one synchronization boundary protects allocation, counts, ownership, and release; +- registry/state objects retain no `TerminalRasterImage` or pixel byte payload. + +C113 performs no terminal I/O and exposes no new public API. Capacity exhaustion is a local reservation failure for C114+ to project to a controlled public result before protocol output begins. + +## TDD evidence + +The RED checkpoint was commit `64f9e5d71c50796efc3b889666c80677e1dbbf8e`. Runtime compilation failed across the target frameworks because `TerminalPersistentRasterRegistry`, `TerminalPersistentRasterResourceState`, and `TerminalPersistentRasterPlacementState` did not yet exist. The failure was therefore attributable to the missing C113 production substrate rather than test syntax or an unrelated regression. + +The GREEN implementation was committed atomically at `ad48f0307f7e20714e3ad4533a98efd5ad9ec100`. + +## Qualification + +Exact head `ad48f0307f7e20714e3ad4533a98efd5ad9ec100` passed pull-request workflow `#1556 / 34625127402` across: + +- Runtime Windows; +- Runtime Linux; +- Runtime macOS; +- Package candidate / public API freeze; +- Package Foundation; +- Package Presentation; +- Package Semantic and hardening; +- Package Stable 1.x release line; +- Validated package artifact. + +The provisional public API fingerprint remains unchanged from C112 because C113 is internal-only. + +C113 is therefore closed. C114 may build public resource creation and acknowledged upload on this qualified bounded ownership substrate. diff --git a/docs/C115-Persistent-Raster-Placement-Creation.md b/docs/C115-Persistent-Raster-Placement-Creation.md new file mode 100644 index 000000000..e92f4e9de --- /dev/null +++ b/docs/C115-Persistent-Raster-Placement-Creation.md @@ -0,0 +1,54 @@ +# C115 — Persistent Raster Placement Creation + +**Release:** `1.11.0-alpha.1` +**Tranche:** C115 +**Status:** accepted +**Accepted head:** `40fae141010bf6e27a7516776af03d886c2537fb` +**Qualification workflow:** `#1565 / 34665854426` + +## Accepted contract + +C115 adds opaque placement creation and multi-placement ownership above one acknowledged persistent raster resource. + +The accepted surface provides: + +- public `TerminalRasterPlacementOptions` with nullable `Columns` and `Rows`; +- public opaque `TerminalRasterPlacement : IAsyncDisposable`; +- `TerminalRasterResource.CreatePlacementAsync(...)` returning `TerminalControlResult`; +- current-cursor placement with Kitty `C=1`, so the graphics operation does not move the text cursor; +- independently optional `Columns` and `Rows`, each bounded to `1..16384`; +- multiple placements per resource with distinct private nonzero placement identities; +- pre-cancellation and option validation before placement output; +- disposed parent resources rejecting new placement creation with `ObjectDisposedException`; +- registry exhaustion or loss of local ownership projected as controlled `Unavailable` before a public placement is returned; +- no public image id, image number, placement id, backend selector, X/Y coordinates, or raw Kitty control builder. + +C115 deliberately leaves placement update/reposition and terminal-side placement/resource deletion to C116. Placement disposal remains local-only at this checkpoint. + +## TDD evidence + +The RED checkpoint was commit `f2ff800693b815f584eea951794135ee8150ad3e`. Runtime compilation failed across `net8.0`, `net9.0`, and `net10.0` because `TerminalRasterPlacementOptions`, `TerminalRasterPlacement`, and `TerminalRasterResource.CreatePlacementAsync(...)` did not yet exist. The RED build reported zero warnings. + +The GREEN implementation was committed at `471b8444ba1452c5d4f4340f72d77f7cee7262aa`. The approved additive public surface changed the deterministic 1.11 API fingerprint to: + +```text +e88c867e252c4acc24e3c718ddbc75ac52e167537c7f1495e8aec37649a372f3 +``` + +The fingerprint/documentation adjustment was committed at `40fae141010bf6e27a7516776af03d886c2537fb`. + +## Qualification + +Exact head `40fae141010bf6e27a7516776af03d886c2537fb` passed pull-request workflow `#1565 / 34665854426` across: + +- Runtime Windows; +- Runtime Linux; +- Runtime macOS; +- Package candidate / public API freeze; +- Package Foundation; +- Package Presentation; +- Package Semantic and hardening; +- Package Stable 1.x release line; +- Validated package artifact. + +C115 is therefore closed. C116 may add semantic placement replacement plus deterministic terminal-side placement/resource cleanup on this qualified ownership surface. diff --git a/docs/C116-Persistent-Raster-Update-And-Cleanup.md b/docs/C116-Persistent-Raster-Update-And-Cleanup.md new file mode 100644 index 000000000..47ac1526c --- /dev/null +++ b/docs/C116-Persistent-Raster-Update-And-Cleanup.md @@ -0,0 +1,74 @@ +# C116 — Persistent Raster Update and Deterministic Cleanup + +**Release:** `1.11.0-alpha.1` +**Tranche:** C116 +**Status:** accepted +**Accepted head:** `db5dab910f818bb1894c906c3915ad30dd608037` +**Qualification workflow:** `#1571 / 34667244384` + +## Accepted contract + +C116 completes the approved public persistent-raster ownership surface with semantic placement replacement and deterministic current-generation cleanup. + +The accepted behavior provides: + +- `TerminalRasterPlacement.UpdateAsync(...)` returning the existing `TerminalControlMutationResult` vocabulary; +- placement replacement at the terminal's current cursor position with Kitty `C=1`; +- replacement reusing the same private image-id / placement-id pair; +- independently optional `Columns` / `Rows`, each bounded to `1..16384`; +- self-disposed placement update throwing `ObjectDisposedException`; +- a placement whose parent/local ownership is no longer current returning controlled `Unavailable` before output; +- placement disposal claiming local ownership exactly once and then attempting one quiet targeted soft delete; +- resource disposal atomically closing child placement ownership, attempting child soft deletes first, then the hard image-data delete; +- quiet cleanup using Kitty `q=2`; +- local identity ownership remaining released even when terminal cleanup transport fails; +- no retry of protocol cleanup after a failed public placement/resource disposal attempt; +- resource cleanup continuing through later child/resource cleanup operations after an individual child-delete failure; +- cleanup failures surfaced rather than silently discarded. + +C116 does not yet make lifecycle invalidation or session-wide teardown authoritative for persistent raster state. Those behaviors remain C117. + +## Wire contract + +The reviewed cleanup vectors are: + +```text +placement delete Ga=d,d=i,i=,p=,q=2 +resource delete Ga=d,d=I,i=,q=2 +``` + +Placement update reuses the existing reviewed placement command: + +```text +Ga=p,i=,p=,C=1[,c=][,r=] +``` + +## TDD evidence + +The final valid RED checkpoint was commit `26c9de4f069d9e59b3835513c2df07fa8db969f7`. After correcting test-harness-only syntax and fixture imports, runtime compilation failed across `net8.0`, `net9.0`, and `net10.0` solely because `TerminalRasterPlacement.UpdateAsync(...)` did not yet exist. The RED build reported zero warnings. + +The GREEN implementation was committed atomically at `178e19b76ae177119f2da7fcb3c660ddee06b30e`. + +That head generated identical public API snapshots across all three target frameworks with fingerprint: + +```text +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 +``` + +The intentional C116 baseline freeze was committed at `db5dab910f818bb1894c906c3915ad30dd608037`. + +## Qualification + +Exact head `db5dab910f818bb1894c906c3915ad30dd608037` passed pull-request workflow `#1571 / 34667244384` across: + +- Runtime Windows; +- Runtime Linux; +- Runtime macOS; +- Package candidate / public API freeze; +- Package Foundation; +- Package Presentation; +- Package Semantic and hardening; +- Package Stable 1.x release line; +- Validated package artifact. + +C116 is therefore closed. C117 may integrate generation invalidation, managed suspend/resume uncertainty, stale-handle local-only disposal, and session-wide child-before-resource teardown without expanding the C116 public API surface. diff --git a/docs/C117-Persistent-Raster-Lifecycle-Acceptance.md b/docs/C117-Persistent-Raster-Lifecycle-Acceptance.md new file mode 100644 index 000000000..a1079c76b --- /dev/null +++ b/docs/C117-Persistent-Raster-Lifecycle-Acceptance.md @@ -0,0 +1,56 @@ +# C117 — Persistent Raster Lifecycle Acceptance + +**Project:** `Icod.Terminal` +**Release track:** `1.11.0` +**Accepted code head:** `aec40cf9d9c2cfcccd1ccea15c315a7558c45715` +**Qualification workflow:** `#1575 / 34692497521` +**Result:** accepted + +## Scope + +C117 integrates persistent raster ownership with session invalidation, managed suspend/resume, and session teardown without adding public API. + +Accepted behavior includes: + +- generation-scoped resource and placement identity; +- explicit `InvalidateState()` staling existing persistent handles; +- managed suspend/resume and external resume staling existing persistent handles without replay or re-upload; +- stale resource placement creation returning controlled `Unavailable` before output; +- stale placement update returning controlled `Unavailable` before output; +- stale placement/resource disposal performing local cleanup only, without stale protocol identifiers; +- resource and placement creation rechecking generation ownership before publishing a public handle; +- current-generation session teardown deleting every placement before any resource data; +- deterministic cleanup ordering; +- cleanup continuation after individual persistent-raster transport failures; +- aggregation of persistent-raster cleanup failures into the existing session/presentation disposal model; +- use of the internal control-output gate for cleanup after public session output has been closed. + +No source raster is retained and no automatic restoration/rebinding is introduced. + +## API compatibility + +C117 is behavioral/internal only. The 1.11 public API remains the C116 surface with deterministic fingerprint: + +```text +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 +``` + +Supported target frameworks remain `net8.0`, `net9.0`, and `net10.0`. + +## Verification + +Exact code head `aec40cf9d9c2cfcccd1ccea15c315a7558c45715` passed pull-request workflow `#1575 / 34692497521` unchanged. + +Successful jobs: + +- Runtime Windows +- Runtime Linux +- Runtime macOS +- Package candidate / public API freeze +- Package Foundation +- Package Presentation +- Package Semantic and hardening +- Package Stable 1.x release line +- Validated package artifact + +C117 is accepted. C118 owns adversarial hardening, terminal-side disappearance handling, repeated lifecycle/ownership stress, fresh-package consumer qualification, downstream `Icod.DCurses` acceptance, and the focused backend-neutral persistent-raster sample. diff --git a/docs/C117-Persistent-Raster-Lifecycle-And-Teardown.md b/docs/C117-Persistent-Raster-Lifecycle-And-Teardown.md new file mode 100644 index 000000000..8cd4b0f85 --- /dev/null +++ b/docs/C117-Persistent-Raster-Lifecycle-And-Teardown.md @@ -0,0 +1,72 @@ +# C117 — Persistent Raster Lifecycle and Teardown Acceptance + +**Project:** `Icod.Terminal` +**Release track:** `1.11.0` +**Tranche:** C117 — lifecycle invalidation, teardown, and failure semantics +**Accepted code head:** `aec40cf9d9c2cfcccd1ccea15c315a7558c45715` +**Qualification workflow:** `#1575 / 34692497521` +**Result:** accepted + +## Accepted semantics + +C117 integrates persistent raster ownership with the existing session lifecycle without adding any new public API. + +The accepted implementation establishes: + +- generation-scoped persistent resource and placement identity; +- `TerminalSession.InvalidateState()` invalidation of persistent raster certainty through presentation-state invalidation; +- stale resource placement creation returning controlled `Unavailable` before output; +- stale placement update returning controlled `Unavailable` before output; +- stale placement/resource disposal performing local cleanup only, without emitting stale Kitty numeric identities; +- managed suspend/resume and external resume invalidating existing persistent handles without raster replay or re-upload; +- no retained raster source cache for lifecycle restoration; +- post-acknowledgement resource creation rechecking generation ownership before publishing a public handle; +- placement creation rechecking generation ownership before publishing a public handle; +- session teardown atomically draining current persistent ownership; +- all current placements being deleted before any current resource data is deleted; +- deterministic ordering within the placement and resource cleanup groups; +- cleanup continuing after individual persistent-raster transport failures; +- persistent-raster cleanup failures participating in the existing presentation/session aggregate-disposal model; +- handle disposal using the internal control-output gate so teardown can stop new public output while still serializing cleanup safely. + +## No replay contract + +C117 does not replay, re-upload, or rebind persistent raster content after explicit invalidation, suspension, resume, or other generation changes. Existing handles become stale and remain stale. Applications must create new resources explicitly when they need new terminal-resident ownership. + +## Compatibility and API + +C117 is internal/behavioral only. It adds no public members and does not alter the C116 public API freeze. + +The current 1.11 deterministic public API fingerprint remains: + +```text +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 +``` + +Supported target frameworks remain: + +- `net8.0` +- `net9.0` +- `net10.0` + +## Verification evidence + +Exact head `aec40cf9d9c2cfcccd1ccea15c315a7558c45715` passed pull-request workflow `#1575 / 34692497521` unchanged. + +Successful jobs included: + +- Runtime Windows +- Runtime Linux +- Runtime macOS +- Package candidate / public API freeze +- Package Foundation +- Package Presentation +- Package Semantic and hardening +- Package Stable 1.x release line +- Validated package artifact + +The corrected C117 RED checkpoint first demonstrated the missing lifecycle behavior while the package/API gates stayed green, and the GREEN exact head subsequently passed the complete matrix. + +## Next gate + +C118 owns adversarial hardening, repeated-lifecycle and ownership stress, terminal-side disappearance handling, fresh-package consumer qualification, downstream `Icod.DCurses` acceptance, and the focused backend-neutral persistent-raster sample. diff --git a/docs/C118-1.11.0-Persistent-Raster-Adversarial-Downstream-and-Package-Qualification.md b/docs/C118-1.11.0-Persistent-Raster-Adversarial-Downstream-and-Package-Qualification.md new file mode 100644 index 000000000..6a6f6d61e --- /dev/null +++ b/docs/C118-1.11.0-Persistent-Raster-Adversarial-Downstream-and-Package-Qualification.md @@ -0,0 +1,137 @@ +# C118 — Persistent Raster Adversarial, Downstream, and Package Qualification + +- **Release:** `Icod.Terminal 1.11.0` +- **Tranche:** C118 +- **Status:** accepted +- **Validated feature head:** `bc838f7011a2a584f958a0478fd800bf508a4832` +- **Acceptance workflow:** `#1602 / 34695867877` + +## Purpose + +C118 hardens the complete persistent-raster ownership surface introduced by C111–C117 and proves that the public contract works from the shipped package rather than only from repository project references. + +The tranche does not widen the public API. It qualifies the already-reviewed 1.11 ownership model against malformed terminal responses, bounded registries, repeated lifetime churn, lifecycle uncertainty, package consumption, generated documentation, downstream integration, and cross-platform Staging validation. + +## Acknowledgement and correlation hardening + +Persistent resource upload continues to require the reviewed image-number acknowledgement contract. C118 extends the same authoritative query-ownership model to placement creation and update. + +Placement transactions now require a correlated Kitty Graphics response matching both private protocol identities: + +```text +i= +p= +``` + +The hardening tests prove that: + +- a placement operation does not complete on a response for the wrong image id or placement id; +- malformed correlated responses are rejected rather than treated as success; +- duplicate placement identity fields are rejected; +- terminal `ENOENT` invalidates the owning persistent resource and its placement certainty; +- later operations on an invalidated object return controlled `Unavailable` without emitting stale terminal identifiers; +- the existing one-reader/query-transaction architecture remains authoritative; no graphics-specific reader was added. + +This closes a real uncertainty gap from the earlier fire-and-forget placement implementation without exposing protocol identifiers publicly. + +## Bounded ownership and repeated-use stress + +`TerminalPersistentRasterHardeningTests` adds repeated-use coverage beyond the individual C113–C117 unit contracts. + +The Staging suite exercises: + +- 24 complete create → place → update → placement-dispose → resource-dispose cycles; +- local idempotence of repeated placement and resource disposal; +- 64 repeated session-generation invalidations with no replay and no stale output; +- 8,192 placement registry reserve/release churn operations; +- zero live-placement leakage after each churn iteration; +- zero live resource/placement ownership after final cleanup; +- monotonic private identity allocation throughout the stress window. + +Existing focused tests continue to cover the exact 256-resource and 4096-placement ceilings, identity wraparound/collision avoidance, cancellation before commitment, committed-output cancellation semantics, redirected/noninteractive output, upload transport failures, malformed/oversized response handling, and resource acknowledgement correlation. + +## Fresh NuGet-only consumer and generated documentation + +C118 adds a package-only persistent-raster smoke consumer under: + +```text +tools/package-persistent-raster-smoke/ +``` + +The consumer has one direct package dependency: + +```text +Icod.Terminal +``` + +It compiles and runs from the produced NuGet candidate on `net8.0`, `net9.0`, and `net10.0`. It binds the stable semantic entry points for: + +- `TerminalSession.CreateRasterResourceAsync(...)`; +- `TerminalRasterResource.CreatePlacementAsync(...)`; +- `TerminalRasterPlacement.UpdateAsync(...)`; +- `TerminalCapability.PersistentRasterGraphics`. + +It also verifies that the public resource and placement surfaces remain opaque and do not expose Kitty image ids, image numbers, placement ids, backend selectors, or Kitty-named members. + +`packaging/VerifyPersistentRasterPackage.ps1` additionally opens the built NuGet archive and verifies generated XML documentation for the new capability, options, resource, placement, create/update methods, disposal methods, and session resource-creation method in every supported TFM. + +The verifier is part of the semantic package-contract shard, so package qualification is exercised by the ordinary PR validation pipeline. + +## Backend-neutral sample + +C118 adds: + +```text +samples/Icod.Terminal.PersistentRaster.Sample/ +``` + +The sample demonstrates the intended application-level flow: + +1. explicitly verify `PersistentRasterGraphics` capability; +2. create a backend-neutral `TerminalRasterImage`; +3. create a persistent resource; +4. create a placement using cell extents; +5. update that placement; +6. dispose placement/resource ownership deterministically. + +`packaging/VerifyPersistentRasterSample.ps1` rejects protocol-specific teaching material in the sample. The source may not mention Kitty, Sixel, image ids, image numbers, or placement ids. It builds the sample for `net8.0`, `net9.0`, and `net10.0` as part of runtime validation. + +## Downstream acceptance + +The established `Icod.DCurses` acceptance suite remains part of every runtime validation. On the C118 feature head it passed across all three supported TFMs, including synchronized output, progress, pointer shape, semantic prompt, color observation, modern keyboard, and the repeated hardening soak. + +This preserves the architectural boundary: `Icod.Terminal` owns terminal protocol/resource lifetime, while `Icod.DCurses` remains responsible for higher-level cell/window/layout policy. + +## Exact-head acceptance evidence + +Feature head: + +```text +bc838f7011a2a584f958a0478fd800bf508a4832 +``` + +Pull-request workflow: + +```text +#1602 / 34695867877 +``` + +The unchanged feature head passed: + +- Runtime Windows; +- Runtime Linux; +- Runtime macOS; +- package candidate and public-API freeze; +- Package Foundation; +- Package Presentation; +- Package Semantic and hardening; +- Package Stable 1.x release line; +- validated package artifact. + +The Windows runtime log independently recorded `1905 / 1905` tests passing on each of `net8.0`, `net9.0`, and `net10.0`, followed by successful persistent-raster sample builds and all downstream DCurses acceptance checks. + +## C118 result + +C118 is accepted without adding another public API layer or relaxing any frozen ownership rule. The 1.11 persistent-raster surface is now qualified for adversarial responses, repeated ownership churn, lifecycle invalidation, package-only consumption, generated documentation, backend-neutral sample usage, downstream compatibility, and all supported target frameworks. + +C119 is the remaining release-closure tranche. It will freeze the final public API baseline and package identity, update permanent/release-facing documentation, remove prerelease metadata, and require one unchanged final PR head to pass the complete Staging matrix before maintainer-controlled merge/tag/release actions. diff --git a/docs/C119-1.11.0-Release-Closure.md b/docs/C119-1.11.0-Release-Closure.md new file mode 100644 index 000000000..663f8b0f3 --- /dev/null +++ b/docs/C119-1.11.0-Release-Closure.md @@ -0,0 +1,151 @@ +# C119 — Icod.Terminal 1.11.0 Release Closure + +- **Release:** `Icod.Terminal 1.11.0` +- **Tranche:** C119 +- **Status:** accepted; exact-head Staging qualification complete +- **Stable compatibility floor:** `1.0.0` +- **Final public API fingerprint:** `9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2` + +## Purpose + +C119 closes the 1.11 development line without adding further feature or public API work. Its job is to make the repository, package metadata, permanent authorities, release notes, sample catalog, public API baseline, and roadmaps describe one stable 1.11 contract, then qualify one unchanged final pull-request code head before maintainer merge/tag/publication. + +## Release-facing state + +The release candidate records: + +- repository/package version `1.11.0` with no prerelease suffix; +- stable package release notes in `Icod.Terminal.csproj`; +- stable README installation and persistent-raster guidance; +- final `CHANGELOG.md` 1.11 entry; +- curated `docs/releases/1.11.0.md` release notes; +- final public API baseline in `docs/Public-API-Baseline-1.11.md` and `.sha256`; +- permanent persistent-raster ownership authority in `docs/Persistent-Raster-Ownership.md`; +- Architecture authority updated for persistent terminal-resident graphics ownership; +- Security/Privacy authority updated for response trust, resource bounds, terminal eviction, direct-transfer/no-cache/no-replay rules; +- Compatibility/Versioning authority updated for the 1.11 additive API, enum numeric `9`, fingerprint, behavioral ownership semantics, and resource ceilings; +- sample catalog updated for `Icod.Terminal.PersistentRaster.Sample`; +- versioned 1.11 roadmap closed at C119; +- long-range roadmap handed to conditional 1.12 advanced raster placement/lifecycle work. + +## Feature acceptance carried into C119 + +C118 feature acceptance was established on: + +```text +bc838f7011a2a584f958a0478fd800bf508a4832 +``` + +by workflow: + +```text +#1602 / 34695867877 +``` + +That unchanged feature head passed: + +- Runtime Windows; +- Runtime Linux; +- Runtime macOS; +- package candidate/public API freeze; +- Package Foundation; +- Package Presentation; +- Package Semantic and hardening; +- Package Stable 1.x release line; +- validated package artifact. + +C118 acceptance covered: + +- correlated persistent upload and placement acknowledgement; +- wrong identity, duplicate-field, malformed, overflow, and bounded response handling; +- terminal `ENOENT` invalidation; +- 256-resource and 4096-placement capacity boundaries; +- private identity wraparound/collision handling; +- repeated create/place/update/delete ownership cycles; +- repeated generation invalidation and stale local-only cleanup; +- repeated registry churn; +- cancellation/transport/redirection commitment boundaries; +- fresh NuGet-only persistent-raster consumption on `net8.0`, `net9.0`, and `net10.0`; +- generated XML documentation from the package; +- the backend-neutral persistent-raster sample; +- current `Icod.DCurses` acceptance and hardening soak. + +The C118 documentation head subsequently passed the same complete Staging matrix before C119 release-facing changes began. + +## Final public contract + +The stable additive surface is frozen by `docs/Public-API-Baseline-1.11.md`: + +```text +TerminalCapability.PersistentRasterGraphics = 9 +TerminalRasterPlacementOptions +TerminalRasterResource +TerminalRasterPlacement +TerminalSession.CreateRasterResourceAsync(...) +TerminalRasterResource.CreatePlacementAsync(...) +TerminalRasterPlacement.UpdateAsync(...) +IAsyncDisposable cleanup on resource and placement +``` + +All existing public enum values and signatures remain unchanged. No post-C116 public API expansion was required by lifecycle integration, adversarial hardening, package qualification, or release closure. + +## Compatibility and architecture boundary + +Version 1.11 remains additive over the stable `1.0.0` compatibility floor. + +Persistent raster ownership remains deliberately narrower than a scene graph: + +- no public Kitty numeric identities; +- no caller-selected graphics backend; +- no Sixel persistence emulation; +- no source-image cache or hidden replay/re-upload; +- no source rectangles, z-order, Unicode placeholders, relative/pixel placement, or animation; +- no image decoder/transcoder; +- no Kitty file/temp-file/shared-memory transfer; +- no PTY/ConPTY process hosting; +- no cells/windows/layout/damage ownership moved from `Icod.DCurses`. + +## Final C119 acceptance + +The final code/release head accepted for C119 is: + +```text +66002280a3e5c800b9b8d230de483f63945d571d +``` + +The qualifying pull-request workflow is: + +```text +#1611 / 34698727655 +``` + +Workflow #1611 completed with conclusion `success` on that unchanged SHA. The accepted matrix includes: + +- Runtime Windows; +- Runtime Linux; +- Runtime macOS; +- package candidate and public API baseline freeze; +- Package Foundation; +- Package Presentation; +- Package Semantic and hardening; +- Package Stable 1.x release line; +- validated package artifact. + +The first macOS execution exposed an unrelated scheduler-sensitive manual-clock test in the query transaction suite. The macOS job was rerun on the **same SHA** and passed; no source change occurred between the failed execution and successful rerun. Windows and Linux passed the same exact code head, as did every package gate. + +During release qualification, scheduler-sensitive scripted-response tests were also made deterministic without weakening production timeout semantics. Production persistent-raster acknowledgement deadlines and query late-response ownership remain unchanged. + +C119 is therefore accepted. No further feature/API work belongs in the 1.11 branch. + +## Maintainer handoff + +With exact-head Staging qualification complete: + +1. PR #52 may be marked ready for maintainer review; +2. merge remains an explicit maintainer action; +3. validate the resulting `main` head through the Release workflow; +4. tag `v1.11.0` only after successful mainline validation; +5. use `docs/releases/1.11.0.md` as the curated GitHub Release/publication notes; +6. NuGet publication remains a maintainer/release-workflow action. + +The next roadmap line is `1.12.0`, reserved for conditional advanced raster placement/lifecycle work only where downstream requirements and measurement justify widening the 1.11 ownership foundation. diff --git a/docs/Compatibility-and-Versioning.md b/docs/Compatibility-and-Versioning.md index c7b6a4e30..23451fba1 100644 --- a/docs/Compatibility-and-Versioning.md +++ b/docs/Compatibility-and-Versioning.md @@ -31,7 +31,8 @@ Compatible minor-release additions receive separate reviewed baselines rather th - `1.4` — additive typed Kitty OSC 99 notification/query surface; - `1.7` — additive backend-neutral raster-display surface; - `1.9` — additive protocol-neutral semantic-event envelope and interactive Kitty notification options; -- `1.10` — additive protocol-neutral semantic capability inspection/planning surface. +- `1.10` — additive protocol-neutral semantic capability inspection/planning surface; +- `1.11` — additive persistent-raster capability and opaque resource/placement ownership surface. Versions `1.5.0` and `1.6.0` intentionally added no public API and retained the 1.4 fingerprint: @@ -53,16 +54,22 @@ Version `1.9.0` advanced the fingerprint to: e652e6fd65cd43422ca84b7c4c2a1815ee7ead9b2a64285e0e17cf39614b0315 ``` -Version `1.10.0` intentionally advances the authoritative current public API fingerprint to: +Version `1.10.0` advanced the fingerprint to: ```text ee705250d19d51df92645e5020f188646dd2dbf38483278e6e57ce6fbbc1e9fb ``` +Version `1.11.0` intentionally advances the authoritative current public API fingerprint to: + +```text +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 +``` + The authoritative current baseline is: -- `docs/Public-API-Baseline-1.10.md`; -- `docs/Public-API-Baseline-1.10.sha256`. +- `docs/Public-API-Baseline-1.11.md`; +- `docs/Public-API-Baseline-1.11.sha256`. Historical baselines remain checked in unchanged as compatibility evidence. @@ -108,15 +115,16 @@ TerminalRasterPixelFormat Indexed8 = 2 TerminalCapability - ClipboardRead = 0 - ClipboardWrite = 1 - CursorStyle = 2 - SynchronizedOutput = 3 - KeyboardReporting = 4 - MouseReporting = 5 - FocusReporting = 6 - BracketedPaste = 7 - RasterGraphics = 8 + ClipboardRead = 0 + ClipboardWrite = 1 + CursorStyle = 2 + SynchronizedOutput = 3 + KeyboardReporting = 4 + MouseReporting = 5 + FocusReporting = 6 + BracketedPaste = 7 + RasterGraphics = 8 + PersistentRasterGraphics = 9 TerminalCapabilitySupport Unknown = 0 @@ -134,7 +142,7 @@ TerminalCapabilityEvidenceKind LiveObservation = 2 ``` -These 1.10 capability-planning numerics are part of the stable 1.x compatibility contract from 1.10 onward. +The 1.10 capability-planning numerics remain stable, and 1.11 appends `PersistentRasterGraphics = 9` without renumbering values `0..8`. ## 5. Behavioral compatibility @@ -160,6 +168,10 @@ Stable guarantees include: - query timeout not automatically becoming unsupported truth; - committed graphics output not intentionally truncated by ordinary caller cancellation; - partial committed graphics failure surfaced without automatic replay/backend switching; +- persistent raster identities scoped to the lifecycle generation that established them; +- no automatic persistent-raster replay/re-upload after lifecycle uncertainty; +- child placement cleanup preceding resource-data cleanup while identities are current; +- stale persistent handles never emitting stale terminal identifiers during disposal; - teardown draining committed output before output-state restoration; - correlated terminal responses remaining untrusted and bounded after ownership is established. @@ -167,7 +179,7 @@ Minor/patch releases may strengthen correctness while preserving these guarantee ## 6. Raster compatibility contract -The public raster model is backend-neutral. It represents bounded raw image data plus semantic display intent, not “a Sixel image” or “a Kitty image.” +The public raster model is backend-neutral. It represents bounded raw image data plus semantic graphics intent, not “a Sixel image” or “a Kitty image.” Version 1.7 added: @@ -180,19 +192,37 @@ TerminalSession.DisplayRasterAsync(...) The raster object owns a snapshot of caller-provided pixel/palette storage. RGB24 is opaque; RGBA32 and indexed palette colors preserve straight alpha. -Version 1.8 added Kitty Graphics below the unchanged semantic raster API while retaining Sixel fallback. The public contract still does not expose a raw DCS/APC writer, backend selector, persistent image id, placement id, source rectangle, z-order model, deletion/animation API, or scene graph. +Version 1.8 added Kitty Graphics below the unchanged ephemeral raster API while retaining Sixel fallback. The public contract does not expose a raw DCS/APC writer, backend selector, Kitty numeric image id, Kitty image number, Kitty placement id, arbitrary control-data dictionary, or scene graph. + +Version 1.11 adds a separate persistent ownership domain: + +```text +TerminalCapability.PersistentRasterGraphics +TerminalRasterResource +TerminalRasterPlacement +TerminalRasterPlacementOptions +TerminalSession.CreateRasterResourceAsync(...) +TerminalRasterResource.CreatePlacementAsync(...) +TerminalRasterPlacement.UpdateAsync(...) +``` + +Persistent resources and placements are opaque session-owned handles. Their backend protocol identities remain private. Placement creation/update uses current-cursor positioning and optional `Columns`/`Rows`; callers continue to use ordinary terminal operations for cursor movement rather than receiving a scene-coordinate API. + +The existing ephemeral `DisplayRasterAsync(...)` contract remains compatible and may still resolve through verified Kitty Graphics or Sixel. Persistent ownership is a distinct capability and is not emulated through Sixel. Fractional alpha remains valid common raster data. Kitty RGBA32 preserves it; Sixel returns controlled unsupported when equivalent semantics cannot be represented truthfully rather than silently compositing. ## 7. Sixel and Kitty Graphics protocol compatibility -Sixel and Kitty Graphics remain internal backends beneath the public raster contract. +Sixel and Kitty Graphics remain internal backends beneath public semantic graphics contracts. Stable Sixel behavior includes canonical seven-bit DCS framing, deterministic bounded quantization, bounded lazy payload generation, caller cancellation before commitment but not intentional frame truncation after commitment, serialization through final ST/flush, and no automatic retry after partial transport failure. Stable Kitty Graphics behavior includes canonical seven-bit APC framing, direct transfer (`t=d`), RGB24/RGBA32 raw transmission, deterministic Indexed8 expansion, Base64 image data bounded to 4096 bytes per protocol chunk, one logical multi-frame serialized transaction, no ordinary post-commit cancellation truncation, no automatic replay/Sixel switch after partial committed failure, and bounded correlated support-query ownership. -File/temp-file/shared-memory Kitty transports and advanced placement/resource lifecycle are outside the 1.8–1.10 compatibility promise and require separate review before introduction. +Version 1.11 additionally reviews the narrow persistent Kitty subset required to upload acknowledged terminal-resident image data, create/update placements, and delete placements/resources. Those commands remain internal implementation detail behind opaque public ownership objects. + +File/temp-file/shared-memory Kitty transports, source rectangles, z-order, Unicode placeholders, relative placements, pixel-coordinate placement, animation, and scene-graph policy remain outside the 1.11 compatibility promise unless separately reviewed in a later release. ## 8. Capability evidence and uncertainty @@ -226,7 +256,7 @@ TerminalSession.VerifyCapabilityAsync(...) `InspectCapability(...)` is synchronous and side-effect free. It reads current in-memory semantic knowledge only and emits no terminal traffic. -`VerifyCapabilityAsync(...)` is explicit and bounded. It may strengthen knowledge only through existing reviewed probe paths. In 1.10 those live verification paths are `KeyboardReporting` and `RasterGraphics`; capabilities without a reviewed probe remain inspection-only rather than receiving invented traffic. +`VerifyCapabilityAsync(...)` is explicit and bounded. It may strengthen knowledge only through existing reviewed probe paths. Version 1.10 introduced live verification for `KeyboardReporting` and `RasterGraphics`; capabilities without a reviewed probe remain inspection-only rather than receiving invented traffic. Support knowledge and endpoint availability are separate compatibility dimensions. A statically advertised capability may remain `Advertised` while the required endpoint is `Unavailable`; this makes `IsUsable` false without rewriting truthful support knowledge to `Unsupported`. @@ -244,6 +274,14 @@ Generation-scoped live observations expire on lifecycle invalidation/resume whil The permanent contract authority is `docs/Capability-Inspection-and-Planning.md`. +### Version 1.11 persistent-raster capability + +Version 1.11 appends `PersistentRasterGraphics = 9` to that semantic capability vocabulary. + +`RasterGraphics` and `PersistentRasterGraphics` are intentionally distinct. Verified Sixel can satisfy ordinary raster display but does not imply terminal-resident persistent resource ownership. The persistent capability is verified only through the reviewed Kitty Graphics path and uses the same side-effect-free inspection / explicit bounded verification model introduced in 1.10. + +`CreateRasterResourceAsync(...)` does not hide a new background probe. It requires current verified persistent-raster capability and a usable endpoint before committing upload traffic. + ## 10. Correlation and response ownership The one-reader/query ownership model is a stable 1.x behavioral contract. @@ -254,6 +292,8 @@ Version 1.9 extends the same ownership principle to unsolicited semantic reports Version 1.10 verification reuses this established query/ownership machinery and does not introduce a second reader or generic raw probe API. +Version 1.11 resource creation and acknowledged placement mutation reuse the same authoritative query ownership. Correlation includes the private image number/image id and, where relevant, the private placement id. A well-formed terminal `ENOENT` for a resource/placement believed current invalidates that local terminal-resident certainty and is surfaced as controlled `Unavailable`; other well-formed negative replies remain controlled failures rather than trusted statements about unrelated state. + ## 11. Resource-bound compatibility Documented resource ceilings are part of the safety contract. Implementations may become more efficient, but minor/patch releases must not silently remove bounds and introduce unbounded work or retention. @@ -267,6 +307,16 @@ maximum owned pixel data 64 MiB maximum indexed palette 256 entries ``` +Persistent-raster ownership adds these session-local ceilings: + +```text +maximum live persistent resources 256 +maximum live persistent placements 4096 +placement Columns / Rows 1..16384 when supplied +``` + +The persistent registries are local bookkeeping limits, not claims about terminal storage quota. Exhaustion returns controlled `Unavailable` before protocol output rather than creating unbounded local state. + Other stable bounds include the 4096-byte normal response frame, 4096-byte Kitty Base64 image-data chunk, bounded control-family frames, fixed Sixel histogram, bounded resynchronization state, and bounded semantic-event/application-event buffering. Interactive Kitty notification buttons remain bounded to 16 labels, 512 UTF-8 bytes per label, and 2,048 UTF-8 bytes for the combined button payload including separators. @@ -294,11 +344,11 @@ Custom hosts remain possible through `ITerminalControlProvider`, `ITerminalInput Permanent layer boundaries remain part of the support model: - `Icod.TermInfo` owns immutable capability information; -- `Icod.Terminal` owns the live terminal conversation, query/evidence model, capability planning, unsolicited semantic-event routing, semantic output, raster routing, and reversible session mechanics; +- `Icod.Terminal` owns the live terminal conversation, query/evidence model, capability planning, unsolicited semantic-event routing, semantic output, ephemeral raster routing, persistent raster resource/placement ownership, and reversible session mechanics; - `Icod.DCurses` owns higher-level virtual-screen/curses presentation policy; - PTY/process hosting remains orthogonal. -Kitty Graphics does not move virtual-screen/scene ownership into `Icod.Terminal`. Unsolicited semantic events do not make it a generic vendor-event bus. Capability planning does not make the internal backend registry, evidence ledger, or `Icod.TermInfo` provenance part of the public contract. +Persistent Kitty Graphics does not move virtual-screen/scene ownership into `Icod.Terminal`. Opaque resources/placements are terminal-resident ownership handles, not cells, windows, layers, or a scene graph. Unsolicited semantic events do not make the library a generic vendor-event bus. Capability planning does not make the internal backend registry, evidence ledger, or `Icod.TermInfo` provenance part of the public contract. ## 14. Security compatibility @@ -310,6 +360,7 @@ Stable 1.x does not quietly introduce through a minor/patch release: - hazardous host-affecting OSC 9 commands; - generic raw OSC 633/777/1337/99 dispatch replacing reviewed semantic surfaces; - arbitrary public Sixel/Kitty writers merely because internal grammars exist; +- public Kitty numeric image ids/image numbers/placement ids as semantic graphics identity; - a generic raw unsolicited-event stream or arbitrary vendor-event dictionary; - terminal-brand-triggered activation presented as capability truth; - a competing protocol-specific input reader; @@ -318,6 +369,7 @@ Stable 1.x does not quietly introduce through a minor/patch release: - authentication claims for terminal-supplied notification interaction reports; - automatic image-file decoding or network/process side effects in raster display; - hidden file/temp-file/shared-memory graphics transport; +- hidden persistent-raster source-image caching or automatic replay after lifecycle uncertainty; - silent compositing of unsupported fractional-alpha raster data; - cancellation-driven truncation of already-committed graphics transfers; - automatic retry/backend switch after partial committed graphics output; @@ -342,6 +394,8 @@ Direct consumers should use `TerminalSession` when they need live terminal/sessi Applications needing windows/cells/diff/refresh should normally use `Icod.DCurses` and allow that layer to own the supplied session according to its integration contract. +Persistent raster resources/placements are appropriate building blocks for higher-level consumers, but layout, damage tracking, clipping policy, and virtual-screen/scene decisions remain higher-level responsibilities. + Do not create independent state-owning sessions over the same physical terminal merely to divide responsibilities. ## 17. Deprecation policy @@ -358,7 +412,7 @@ A release is not considered compatible merely because unit tests pass. The repository maintains layered evidence including: -- retained historical public API fingerprints plus the authoritative current 1.10 fingerprint; +- retained historical public API fingerprints plus the authoritative current 1.11 fingerprint; - Windows/Linux/macOS runtime/source validation; - exact multi-TFM API snapshot agreement; - fresh NuGet-only consumers for newly added or compatibility-critical semantic APIs; @@ -370,7 +424,7 @@ The repository maintains layered evidence including: - resource-bound tests; - release/distribution validation on configured architectures. -Version 1.9 qualified semantic-event ownership and interactive Kitty notification reporting. Version 1.10 qualifies side-effect-free semantic capability inspection, explicit bounded verification, lifecycle invalidation, concurrency/cancellation, package-only consumption, loose dependency coupling, and downstream compatibility. +Version 1.9 qualified semantic-event ownership and interactive Kitty notification reporting. Version 1.10 qualified side-effect-free semantic capability inspection, explicit bounded verification, lifecycle invalidation, concurrency/cancellation, package-only consumption, loose dependency coupling, and downstream compatibility. Version 1.11 qualifies persistent-raster acknowledgement/correlation, bounded resource/placement ownership, placement replacement, deterministic disposal, lifecycle invalidation/no-replay behavior, adversarial terminal replies, package-only consumption/XML documentation, a protocol-neutral sample, and current DCurses acceptance. Exact release qualification evidence belongs to the relevant pull-request workflow, merged `main` workflow, release notes, and GitHub Release rather than being hard-coded permanently into this policy document. diff --git a/docs/Persistent-Raster-Ownership.md b/docs/Persistent-Raster-Ownership.md new file mode 100644 index 000000000..c00bbe8e0 --- /dev/null +++ b/docs/Persistent-Raster-Ownership.md @@ -0,0 +1,278 @@ +# Persistent Raster Ownership + +This document is the permanent 1.x authority for `Icod.Terminal` persistent terminal-resident raster resources and placements introduced in version `1.11.0`. + +Historical C110–C119 tranche documents explain how the design was developed and qualified. This document defines the supported semantic contract consumers should rely on. + +## 1. Scope + +`Icod.Terminal` exposes two distinct raster intents: + +```text +DisplayRasterAsync(...) + ephemeral raster display + +CreateRasterResourceAsync(...) + persistent terminal-resident resource ownership + -> placement ownership + -> placement update + -> deterministic disposal +``` + +Persistent ownership is not a scene graph, virtual screen, window system, or image database. `Icod.DCurses` remains responsible for cells, windows, clipping/layout policy, damage, and refresh strategy. + +## 2. Semantic capability + +Persistent raster ownership is represented by: + +```text +TerminalCapability.PersistentRasterGraphics = 9 +``` + +It is intentionally distinct from ordinary `RasterGraphics`. + +```text +RasterGraphics + may be usable through verified Kitty Graphics or verified Sixel + +PersistentRasterGraphics + is usable only through the reviewed persistent-capable Kitty Graphics path +``` + +A terminal may therefore support ordinary raster output without supporting this ownership domain. + +`TerminalSession.InspectCapability(...)` remains side-effect free. `TerminalSession.VerifyCapabilityAsync(...)` is explicit and may issue only the reviewed bounded support probe for this semantic capability. + +## 3. Public ownership surface + +The public resource model is opaque: + +```csharp +TerminalControlResult result = + await session.CreateRasterResourceAsync( image ); + +await using TerminalRasterResource resource = result.GetRequiredValue(); +``` + +A resource can create one or more placements: + +```csharp +TerminalControlResult result = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = 24 + } + ); + +await using TerminalRasterPlacement placement = result.GetRequiredValue(); +``` + +A placement can be replaced at the current cursor location while preserving the same private placement identity: + +```csharp +TerminalControlMutationResult result = await placement.UpdateAsync( + new TerminalRasterPlacementOptions { + Columns = 16, + Rows = 8 + } +); +``` + +The public types do not expose Kitty image ids, image numbers, placement ids, raw APC command dictionaries, or a backend selector. + +## 4. Placement size and position + +`TerminalRasterPlacementOptions.Columns` and `.Rows` are nullable `int` values. + +- `null` means use protocol/default behavior for that dimension; +- each supplied value must be in `1..16384`; +- either dimension may be supplied independently; +- the backend derives an unspecified dimension while preserving image aspect ratio where the protocol supports that behavior. + +Placement position is the terminal's current cursor location. `Icod.Terminal` does not add public pixel-coordinate or absolute-cell-coordinate placement state in 1.11. + +The reviewed backend requests placement without moving the text cursor. + +To reposition an existing placement, move the terminal cursor through ordinary terminal semantics and call `UpdateAsync(...)`. + +## 5. Resource creation and acknowledgement + +Persistent resource creation is acknowledged before the library publishes a usable public handle. + +Internally, the session allocates a private nonzero image number for upload correlation. A successful acknowledgement must correlate that image number and return a nonzero terminal-assigned image id. Future placement and cleanup operations use that private terminal image identity. + +The public handle contains no protocol identity that callers can manufacture or reuse. + +If upload cannot establish a reliable acknowledgement, no public resource is returned. The library does not publish an object whose terminal-side existence is ambiguous. + +## 6. Placement acknowledgement and query ownership + +Placement creation and update reuse the existing authoritative query/input architecture. There is no graphics-specific input reader. + +A placement acknowledgement is owned by the active transaction only when the expected terminal image id and placement id correlate. Wrong identities do not satisfy another operation. + +Correlated terminal responses remain untrusted. The implementation validates framing, numeric overflow, duplicate fields, and bounded response sizes before accepting semantic success. + +Malformed correlated responses are failures rather than capability evidence. + +## 7. Cancellation and committed output + +Arguments, options, current ownership, endpoint/capability state, and caller cancellation are checked before commitment where possible. + +The output gate may be cancelled before the first frame commits. Once persistent graphics output commits, ordinary caller cancellation does not intentionally truncate the logical transaction. + +After partial committed output, the library does not blindly replay, switch to Sixel, or invent certainty about terminal state. + +A transport failure remains a transport failure and is surfaced to the caller. + +## 8. Bounded ownership + +Session bookkeeping is bounded independently of the terminal's own storage limits: + +```text +maximum live persistent resources 256 +maximum live persistent placements 4096 +``` + +A full local registry returns controlled `Unavailable` before protocol output. + +Private resource/image-number and placement identities are nonzero, avoid live collisions, allocate monotonically where possible, and have explicit wraparound handling. + +The registry stores ownership/lifecycle bookkeeping only. After successful resource creation, the library does not retain an arbitrary hidden copy of the source raster image. + +## 9. Generation-scoped certainty + +Persistent terminal identities are valid only for the session generation in which they were established. + +The following invalidate existing terminal-resident certainty: + +- explicit `TerminalSession.InvalidateState()`; +- managed suspend/resume generation changes; +- other session lifecycle transitions that invalidate terminal state knowledge. + +After invalidation: + +- existing resource and placement handles are stale; +- placement update returns controlled `Unavailable` before output; +- new placement creation from a stale resource returns controlled `Unavailable` before output; +- disposal releases local ownership but does not emit stale numeric identifiers; +- the library does not automatically re-upload or rebind the raster; +- no hidden source-image cache is consulted because 1.11 does not retain one for replay. + +A caller that needs persistent graphics again must establish current capability and create a new resource explicitly. + +## 10. Terminal eviction and `ENOENT` + +Terminal-resident image storage is external state. The terminal may evict resources according to its own quota or policy even while the local handle remains otherwise current. + +A well-formed correlated Kitty Graphics `ENOENT` response for a resource/placement the session believed current invalidates that ownership certainty. The operation returns controlled `Unavailable` semantics and later operations do not emit stale identifiers. + +Other well-formed negative terminal responses remain controlled `Failed` results with bounded diagnostic text where applicable. + +This behavior is not automatic replay. The caller remains responsible for deciding whether to create a new resource. + +## 11. Placement disposal + +Placement disposal is locally idempotent. + +For a current placement, the first disposal: + +1. prevents new mutation; +2. releases local placement ownership exactly once; +3. attempts one quiet targeted terminal placement delete; +4. surfaces transport failure to the direct caller; +5. never restores local ownership merely so a later disposal can retry uncertain cleanup. + +A stale placement performs local cleanup only. + +## 12. Resource disposal + +Resource disposal is locally idempotent and owns its children. + +For a current resource, disposal: + +1. prevents new child placements; +2. closes/releases child placement ownership; +3. attempts child placement deletion before resource-data deletion; +4. attempts the terminal resource-data delete; +5. releases local ownership even when cleanup transport fails; +6. aggregates multiple cleanup transport failures where necessary. + +Disposing an already stale resource emits no stale terminal identifiers. + +## 13. Session teardown + +Current-generation persistent graphics participate in ordinary session teardown. + +The session drains committed transactions, deletes current placements before current resource data, aggregates persistent cleanup failures with the existing cleanup/restoration model, and then continues final terminal restoration according to the broader session contract. + +If persistent state was already invalidated, teardown performs local ownership cleanup only. + +## 14. Security and privacy boundary + +Persistent raster traffic is external terminal I/O. + +Stable guarantees include: + +- no caller-supplied raw Kitty image/placement identifiers; +- no generic public persistent Kitty command builder; +- bounded image dimensions/storage inherited from `TerminalRasterImage`; +- bounded resource/placement registries; +- bounded correlated response parsing; +- direct Kitty transfer only; +- no file, temporary-file, or shared-memory transport chosen silently; +- no retained arbitrary source-image cache after creation; +- no hidden replay after lifecycle uncertainty or partial commitment; +- correlation establishes routing ownership, not terminal authenticity. + +A successful acknowledgement proves only that a well-formed correlated response was received under the protocol contract. It does not authenticate the emulator, multiplexer, remote endpoint, host, desktop session, or user. + +## 15. Backend neutrality + +The persistent public surface intentionally speaks in resource and placement semantics rather than Kitty protocol vocabulary. + +Version 1.11 does not emulate persistence through Sixel. Such emulation would require the library to retain and redraw image data and would change the ownership/lifecycle contract substantially. + +The implementation may use Kitty Graphics internally, but callers should plan against `PersistentRasterGraphics`, not terminal brand, `TERM`, APC framing, or numeric image identifiers. + +## 16. Explicit exclusions + +The 1.11 persistent ownership contract does not include: + +- public backend ids or Kitty numeric identities; +- caller-selected graphics backend; +- Sixel persistent-resource emulation; +- automatic replay/re-upload/rebind; +- retained source-image cache; +- source rectangles; +- z-order; +- Unicode placeholder placement; +- relative placement; +- pixel-coordinate placement; +- animation; +- scene-graph ownership; +- image-file decoding/transcoding; +- Kitty file/temp-file/shared-memory transfer; +- PTY/ConPTY hosting; +- cells/windows/damage/layout ownership. + +Later releases may add narrowly reviewed functionality without weakening this base ownership contract. + +## 17. Sample and related authorities + +A focused backend-neutral example is available at: + +```text +samples/Icod.Terminal.PersistentRaster.Sample/ +``` + +Related permanent authorities are: + +- `Architecture.md`; +- `Security-and-Privacy.md`; +- `Compatibility-and-Versioning.md`; +- `Capability-Inspection-and-Planning.md`; +- `Terminal-Session-and-Ownership.md`; +- `Lifecycle-and-Restoration.md`. + +The final 1.11 API surface is frozen by `Public-API-Baseline-1.11.md` and its SHA-256 fingerprint. diff --git a/docs/Public-API-Baseline-1.11.md b/docs/Public-API-Baseline-1.11.md new file mode 100644 index 000000000..ed832ca7d --- /dev/null +++ b/docs/Public-API-Baseline-1.11.md @@ -0,0 +1,120 @@ +# Icod.Terminal Public API Baseline — 1.11.0 + +**Release:** `1.11.0` +**Status:** final C119 public API freeze +**Target frameworks:** `net8.0`, `net9.0`, `net10.0` + +## Purpose + +This document records the final intentional additive public surface for `Icod.Terminal 1.11.0`. Historical baselines remain unchanged. + +Version 1.11 adds exactly one semantic capability value and one opaque persistent-raster ownership domain above the internal Kitty Graphics identifiers used by the implementation. + +## Final public additions + +The capability vocabulary gains: + +```text +TerminalCapability.PersistentRasterGraphics = 9 +``` + +Existing values `0..8` remain unchanged. + +The persistent resource surface is: + +```csharp +public sealed class TerminalRasterResource : IAsyncDisposable { + public ValueTask> CreatePlacementAsync( + TerminalRasterPlacementOptions? options = null, + CancellationToken cancellationToken = default + ); + + public ValueTask DisposeAsync(); +} + +public sealed partial class TerminalSession { + public ValueTask> CreateRasterResourceAsync( + TerminalRasterImage image, + CancellationToken cancellationToken = default + ); +} +``` + +The placement surface is: + +```csharp +public sealed class TerminalRasterPlacementOptions { + public int? Columns { get; set; } + public int? Rows { get; set; } +} + +public sealed class TerminalRasterPlacement : IAsyncDisposable { + public ValueTask UpdateAsync( + TerminalRasterPlacementOptions? options = null, + CancellationToken cancellationToken = default + ); + + public ValueTask DisposeAsync(); +} +``` + +Construction of resource and placement handles remains library-owned. No public image id, image number, placement id, backend selector, raw APC control dictionary, registry, pixel-coordinate placement, or mutable raster storage is introduced. + +`Columns` and `Rows` are independently optional and each supplied value is bounded to `1..16384`. Placement creation and update use the terminal's current cursor location while the reviewed backend uses no-cursor-movement semantics. + +## Final machine fingerprint + +The deterministic reflection snapshot is identical across `net8.0`, `net9.0`, and `net10.0`. + +After normalizing line endings to LF, the final 1.11 fingerprint is: + +```text +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 +``` + +The machine-readable fingerprint is stored in: + +`docs/Public-API-Baseline-1.11.sha256` + +`packaging/VerifyPublicApiBaseline.ps1` regenerates the public API snapshot independently for every supported target framework and verifies this exact fingerprint. + +## Semantic capability distinction + +`PersistentRasterGraphics` is intentionally separate from ordinary `RasterGraphics`. + +```text +RasterGraphics + may be satisfied by verified Sixel or verified Kitty Graphics + +PersistentRasterGraphics + is satisfied only by the reviewed persistent-capable Kitty Graphics path +``` + +Verified Sixel therefore does not imply persistent terminal-resident resource support. The public API does not expose which concrete backend satisfied a semantic capability. + +`TerminalSession.InspectCapability(...)` remains side-effect free. `TerminalSession.VerifyCapabilityAsync(...)` uses the reviewed bounded Kitty support path for `PersistentRasterGraphics`; it does not add broad terminal fingerprinting. + +## Ownership and lifecycle meaning + +`CreateRasterResourceAsync(...)` requires current verified persistent-raster capability and publishes a public resource only after a correlated terminal acknowledgement establishes terminal-side identity. + +A resource may own multiple placements. Placement creation and update retain opaque internal identity and use the existing query/input authority for correlated acknowledgement. A well-formed terminal `ENOENT` for an object believed current invalidates that terminal-resident certainty and produces controlled `Unavailable` semantics. + +Persistent identities are session-generation scoped. Explicit invalidation and lifecycle generation changes make existing handles stale. Version 1.11 does not retain arbitrary raster payloads for hidden re-upload and does not automatically replay terminal-resident resources after suspend/resume uncertainty. + +Disposal is locally idempotent. Current placements are cleaned before their resource data; stale handles perform local cleanup only and do not emit stale protocol identifiers. + +## Bounded compatibility contract + +The internal live ownership ceilings are: + +```text +256 persistent raster resources per session +4096 persistent raster placements per session +``` + +These are library bookkeeping bounds, not claims about terminal storage quotas. The terminal may independently evict stored image data. + +Version 1.11 remains additive over the stable `1.0.0` compatibility floor. It removes or renumbers no existing public member. C117–C118 hardening, package-only consumption, lifecycle tests, downstream acceptance, and final release closure required no additional public API beyond the C116 freeze recorded by this final baseline. + +The permanent ownership contract is `docs/Persistent-Raster-Ownership.md` and the broader compatibility authority remains `docs/Compatibility-and-Versioning.md`. diff --git a/docs/Public-API-Baseline-1.11.sha256 b/docs/Public-API-Baseline-1.11.sha256 new file mode 100644 index 000000000..1d595017b --- /dev/null +++ b/docs/Public-API-Baseline-1.11.sha256 @@ -0,0 +1 @@ +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 diff --git a/docs/Security-and-Privacy.md b/docs/Security-and-Privacy.md index 3e95fb7d3..9cf2d0bea 100644 --- a/docs/Security-and-Privacy.md +++ b/docs/Security-and-Privacy.md @@ -1,6 +1,6 @@ # Security and Privacy -`Icod.Terminal` mediates a bidirectional terminal conversation. Terminal control sequences are not merely visual formatting: some operations publish metadata, request external state, alter terminal-owned presentation state, influence desktop integration, display raster graphics, or report unsolicited terminal-side observations. +`Icod.Terminal` mediates a bidirectional terminal conversation. Terminal control sequences are not merely visual formatting: some operations publish metadata, request external state, alter terminal-owned presentation state, influence desktop integration, display raster graphics, create terminal-resident raster resources/placements, or report unsolicited terminal-side observations. This document defines the permanent 1.x security and privacy boundary. @@ -9,14 +9,15 @@ This document defines the permanent 1.x security and privacy boundary. `Icod.Terminal` assumes that: - application-supplied arguments may be untrusted; -- terminal input, unsolicited semantic events, capability responses, and query responses are external input and may be malformed or adversarial; +- terminal input, unsolicited semantic events, capability responses, graphics acknowledgements, and query responses are external input and may be malformed or adversarial; - the attached terminal, multiplexer, remote session, or transport may fabricate otherwise well-formed observations; - the terminal may not implement a protocol exactly as expected; - successful byte transmission does not prove terminal-side support or application; +- even an acknowledged terminal-resident graphics identity is terminal-controlled state rather than an authentication primitive; - terminal metadata may be logged, persisted, forwarded, surfaced to the desktop, or visible to other software depending on the environment; -- large raster inputs and large terminal replies may create accidental or adversarial resource pressure. +- large raster inputs, large terminal replies, and terminal-resident graphics bookkeeping may create accidental or adversarial resource pressure. -The library therefore favors typed semantic APIs, bounded parsing/encoding, pre-output validation, explicit capability evidence, and one authoritative input/query/event path over raw generic protocol construction. +The library therefore favors typed semantic APIs, bounded parsing/encoding, pre-output validation, explicit capability evidence, opaque ownership handles, and one authoritative input/query/event path over raw generic protocol construction. ## 2. Control-sequence injection boundary @@ -33,13 +34,14 @@ Different protocols use different safe encodings. Examples include: - OSC 777 rejects delimiters/control bytes for which the protocol defines no interoperable escape; - OSC 1337 user-variable values use strict UTF-8 plus Base64; - Kitty Graphics raw image bytes use protocol-defined Base64 inside a typed bounded APC dialect; -- closed color, pointer, keyboard, notification-event, capability-planning, and raster APIs avoid arbitrary caller-supplied protocol strings. +- closed color, pointer, keyboard, notification-event, capability-planning, and raster APIs avoid arbitrary caller-supplied protocol strings; +- persistent raster APIs expose opaque resource/placement objects rather than caller-supplied Kitty control dictionaries or numeric ids. Validation protects framing integrity. It does not make semantic content confidential, authentic, or trustworthy. ## 3. Bounded resources -Input decoding, paste handling, query transactions, unsolicited semantic reports, application-event buffering, request/response frames, late-response ownership, resynchronization, capability verification, and graphics processing are bounded. +Input decoding, paste handling, query transactions, unsolicited semantic reports, application-event buffering, request/response frames, late-response ownership, resynchronization, capability verification, graphics processing, and persistent-raster bookkeeping are bounded. The normalized control-language layer uses one bounded scanner for CSI, DCS, OSC, APC, PM, and SOS rather than separate unbounded per-dialect accumulators. @@ -52,6 +54,14 @@ maximum owned pixel bytes 64 MiB maximum indexed palette 256 entries ``` +Persistent-raster session bookkeeping is additionally bounded: + +```text +maximum live persistent resources 256 +maximum live persistent placements 4096 +placement Columns / Rows 1..16384 when supplied +``` + Relevant protocol/resource ceilings include: ```text @@ -67,13 +77,15 @@ Kitty combined button payload 2,048 UTF-8 bytes including separators Sixel output is generated as bounded lazy segments. Kitty Graphics direct output is generated as bounded lazy Base64/application chunks and one bounded APC frame at a time. Large graphics do not require one complete encoded transfer in memory. -Malformed or oversized owned query/semantic traffic is recovered through bounded drain/resynchronization rather than unbounded accumulation or leakage back into ordinary application text. +Persistent resource/placement registry limits are local safety bounds, not claims about terminal storage quota. Registry exhaustion produces controlled `Unavailable` before protocol output rather than unbounded local growth. + +Malformed or oversized owned query/semantic/graphics-acknowledgement traffic is recovered through bounded drain/resynchronization rather than unbounded accumulation or leakage back into ordinary application text. ## 4. One authoritative input reader A live `TerminalSession` owns the authoritative input decoder, query router, and unsolicited semantic-event classifier. -The stable 1.x surface does not expose a session raw-input property. A competing raw read could steal bytes from UTF-8 scalars, key sequences, paste frames, lifecycle traffic, unsolicited semantic reports, or active query responses. +The stable 1.x surface does not expose a session raw-input property. A competing raw read could steal bytes from UTF-8 scalars, key sequences, paste frames, lifecycle traffic, unsolicited semantic reports, active query responses, or persistent-raster acknowledgements. `ITerminalInput` remains public for custom transport injection, but a caller supplying the transport must not create a competing reader while the session owns it. @@ -87,7 +99,7 @@ active query response A query-owned response is never also published as a semantic event. A recognizable unsolicited report does not satisfy an unrelated query merely because both use a shared control family. -Sixel capability observation, Kitty Graphics support probing, and 1.10 capability verification all reuse the same authoritative input/query path. None introduces a protocol-specific reader, notification reader, capability reader, or callback stream. +Sixel capability observation, Kitty Graphics support probing, capability verification, and 1.11 persistent-raster acknowledgements all reuse the same authoritative input/query path. None introduces a protocol-specific reader, notification reader, capability reader, graphics reader, or callback stream. ## 5. Capability evidence is not terminal identity @@ -103,7 +115,7 @@ For Kitty Graphics, a correlated valid Kitty response verifies the backend; the The distinction prevents both false-positive brand guessing and false-negative interpretation of ordinary silence. -## 6. Public capability planning security boundary — 1.10 +## 6. Public capability planning security boundary — 1.10+ Version 1.10 exposes semantic capability planning through: @@ -115,7 +127,7 @@ TerminalCapabilityStatus `InspectCapability(...)` is side-effect free. It reads only the session's existing in-memory knowledge and emits no terminal bytes. Applications may therefore use inspection for ordinary planning without triggering hidden terminal fingerprinting. -`VerifyCapabilityAsync(...)` is deliberately explicit because verification may emit bounded terminal query/probe traffic. In 1.10 only reviewed existing probe paths are used, initially for `KeyboardReporting` and `RasterGraphics`. Capabilities without a reviewed live probe remain inspection-only. +`VerifyCapabilityAsync(...)` is deliberately explicit because verification may emit bounded terminal query/probe traffic. Version 1.10 introduced reviewed live probe paths for `KeyboardReporting` and `RasterGraphics`; version 1.11 adds `PersistentRasterGraphics` using only the reviewed Kitty Graphics probe path. Capabilities without a reviewed live probe remain inspection-only. A successful `Verified` result means that the reviewed terminal observation established current-generation support under the library contract. It is **not** authentication of the terminal, emulator, multiplexer, desktop session, host, or user. @@ -133,6 +145,8 @@ Support and endpoint availability remain separate. An unavailable endpoint does Pre-cancelled verification emits no probe traffic. Repeated verification of decisive current live evidence does not intentionally re-probe merely to return the same result. +Persistent resource creation does not perform an additional hidden support probe. It requires current verified persistent-raster capability before committing upload traffic. + ## 7. Correlation grants ownership, not trust A response that matches an active query identity is still attacker-controlled terminal input. @@ -143,6 +157,10 @@ For the Kitty Graphics support probe, once a complete matching image id is obser The same principle applies to unsolicited semantic notification reports. Recognition prevents hostile owned traffic from being reinterpreted as ordinary text, but does not authenticate identifiers, button numbers, close events, or event ordering. +Version 1.11 applies the same rule to persistent-raster acknowledgements. The private image number, terminal-assigned image id, and placement id are correlation fields, not security tokens. They are strictly parsed, overflow checked, duplicate-field checked, and matched to the active operation before state advances. + +A well-formed terminal `ENOENT` for a resource/placement believed current invalidates the library's certainty about that terminal-resident object. It does not authenticate why the object disappeared and is not generalized into conclusions about unrelated terminal state. + ## 8. Emission is not application For unacknowledged output protocols, successful completion normally means only that the requested bytes were successfully written. @@ -159,6 +177,8 @@ For explicit query APIs, successful completion means a correlated response was r Raster display is capability-gated before emission, but successful `DisplayRasterAsync(...)` output still does not claim visual verification after bytes are written. +For acknowledged persistent resource creation, successful completion means that a correlated valid terminal response accepted the reviewed upload transaction and supplied a usable terminal image identity under the current lifecycle generation. It still does not authenticate the terminal or prove how/where any future placement will be rendered. + ## 9. Raster input and alpha semantics `TerminalRasterImage` owns an immutable snapshot of caller-provided raw raster data. Copying input at construction prevents asynchronous display from observing later caller mutation of supplied buffers. @@ -177,21 +197,39 @@ Sixel quantization uses bounded deterministic work state and stable tie-breaking Kitty Graphics direct transfer (`t=d`) remains the reviewed transport. The library does not silently choose file, temporary-file, or shared-memory transfer media because those introduce path naming, lifetime, permissions, race, visibility, cleanup, and cross-process concerns absent from direct terminal traffic. +Version 1.11 persistent resource upload uses that same direct-transfer policy. No file/temp-file/shared-memory shortcut is introduced merely because a resource may outlive one display operation. + Base64 is framing-safe encoding, not encryption. ## 11. Committed graphics output -Both raster backends use committed output semantics through the session serialization boundary. +Raster and persistent-raster operations use committed output semantics through the session serialization boundary. Before commitment, validation and caller cancellation remain effective. After the first backend frame commits, ordinary caller cancellation is not allowed to intentionally truncate the logical graphics transaction. For Sixel, the committed unit is the complete DCS transaction through ST and flush. For Kitty Graphics, several complete APC frames may form one logical direct-transfer transaction and remain serialized through the final frame and flush. +Persistent resource upload likewise remains one logical acknowledged transaction. The library does not publish a public resource until the required correlated acknowledgement establishes terminal-side identity. Placement creation/update remains serialized with the same session output authority; cleanup commands are targeted to private identities owned by the session. + If the transport fails after commitment, the error is surfaced. The library does not automatically retry the image, replay uncertain output, switch raster backends, or speculate that additional recovery commands are safe. Session teardown drains committed output before output-state restoration continues. -## 12. No generic raw graphics or capability escape hatch +## 12. Persistent raster ownership — 1.11 + +Persistent raster ownership is deliberately narrower than a scene graph. + +The public surface exposes opaque `TerminalRasterResource` and `TerminalRasterPlacement` objects. Numeric Kitty image ids, image numbers, and placement ids remain private implementation state so callers cannot forge ownership or collide intentionally with other terminal clients through the semantic API. + +A successful resource upload does not cause the library to retain the caller's raster indefinitely. After creation succeeds, the session retains bounded ownership bookkeeping, not a hidden source-image cache for automatic replay. + +Persistent identities are lifecycle-generation scoped. `InvalidateState()` and managed lifecycle generation changes make existing resource/placement certainty stale. Stale handles do not trigger re-upload, backend switching, or stale-id cleanup traffic. Mutations return controlled `Unavailable`; disposal releases local ownership without emitting stale numeric identifiers. + +While identities are current, cleanup is child-first: placements are removed before resource data. Local ownership is released even if terminal cleanup transport fails, preventing retry loops from turning stale protocol identity into an unbounded or ambiguous ownership model. + +The terminal may independently evict stored graphics data under its own quota/policy. A correlated `ENOENT` is therefore treated as loss of that terminal-resident certainty, not as an impossible condition. + +## 13. No generic raw graphics or capability escape hatch The stable semantic surface intentionally does not expose: @@ -203,11 +241,13 @@ The stable semantic surface intentionally does not expose: - public backend-routing scores; - arbitrary internal evidence-ledger entries; - arbitrary terminfo capability-name probing; -- persistent Kitty image/placement ids through the common raster API. +- caller-selected Kitty image ids/image numbers/placement ids; +- a generic scene/layer/z-order/animation model; +- hidden persistent source-image caching or replay. `TerminalSession.Output` remains a public advanced borrowed transport and can be misused by a caller. Direct writes through it are outside session serialization and semantic validation; that escape hatch is not an endorsement of constructing arbitrary untrusted terminal traffic. -## 13. Image decoding is out of scope +## 14. Image decoding is out of scope `Icod.Terminal` consumes bounded raw pixel/index data. It does not decode PNG, JPEG, GIF, or other image files as part of raster display. @@ -215,7 +255,7 @@ This avoids importing file-parser attack surface, metadata handling, decompressi Applications may decode image formats with libraries appropriate to their own trust model, then provide bounded raw raster data to `Icod.Terminal`. -## 14. Clipboard privacy — OSC 52 +## 15. Clipboard privacy — OSC 52 Clipboard writes can place application data into terminal or desktop selection state. Clipboard reads request external selection data and are explicitly privacy-sensitive. @@ -223,19 +263,19 @@ Clipboard writes can place application data into terminal or desktop selection s Applications should treat returned clipboard bytes as untrusted external input and should not publish secrets to terminal clipboard state unintentionally. -## 15. Current-location and shell metadata disclosure +## 16. Current-location and shell metadata disclosure OSC 7, OSC 9;9, OSC 633 `Cwd`, OSC 1337 `CurrentDir`, and related semantic metadata can reveal user names, source-tree names, customer/project names, mount points, shares, and host identity. `Icod.Terminal` does not automatically discover and publish environment/current-directory/shell-history data. The caller decides whether disclosure is appropriate. -## 16. Hyperlink security — OSC 8 +## 17. Hyperlink security — OSC 8 The library validates hyperlink framing and URI syntax but does not decide whether a URI is safe for a particular application to expose to users. It does not fetch targets, resolve DNS, launch browsers/shells, or apply a universal URI-scheme trust policy. -## 17. Desktop notification privacy and trust +## 18. Desktop notification privacy and trust OSC 9, OSC 777, and OSC 99 notifications can leave the terminal window and appear in desktop notification surfaces, logs, recordings, screen sharing, or accessibility software. @@ -245,13 +285,13 @@ Version 1.9 notification activation/button/close reports are validated but unaut An identifier is correlation data, not a capability token, trusted desktop handle, cryptographic proof, or evidence that a trusted human performed an action. Applications must not use these events as an authorization boundary without their own independent security mechanism. -## 18. Modern keyboard, focus, mouse, and paste privacy +## 19. Modern keyboard, focus, mouse, and paste privacy Modern keyboard protocols can expose press/repeat/release phase, associated text, shifted/base-layout identities, and modifier state. Focus/mouse reports expose interaction context. Bracketed-paste data may contain arbitrary user text. Applications should collect, log, and transmit only what they need. Bracketed paste marks provenance and boundaries; it does not make pasted content safe to execute. -## 19. Terminal observations can fingerprint the environment +## 20. Terminal observations can fingerprint the environment Explicit queries and capability verification can reveal terminal/environment characteristics such as device attributes, supported protocol families, graphics support, cursor/color state, clipboard state, or notification support. @@ -259,9 +299,9 @@ Applications should issue only observations they need. `InspectCapability(...)` performs no terminal I/O. `VerifyCapabilityAsync(...)` is explicit so the application controls whether the benefit of stronger capability evidence justifies the terminal traffic and possible fingerprinting signal. -`DisplayRasterAsync(...)` may use the reviewed raster capability/probe machinery when necessary; it does not conduct broad emulator-brand fingerprinting. +`DisplayRasterAsync(...)` may use the reviewed raster capability/probe machinery when necessary; it does not conduct broad emulator-brand fingerprinting. `CreateRasterResourceAsync(...)` requires already-verified persistent capability and does not perform a new hidden fingerprinting query as a side effect of creation. -## 20. Redirected endpoints +## 21. Redirected endpoints Semantic operations that require a live terminal reject known redirected/non-terminal output rather than blindly writing control bytes into a file or pipe. @@ -269,7 +309,9 @@ Active queries additionally require compatible interactive input/output endpoint Capability support knowledge is not rewritten merely because the current endpoint is unavailable. Endpoint availability is represented separately from support truth. -## 21. Restoration, lifecycle, and evidence invalidation +Persistent resource creation and mutation follow the same endpoint discipline; registry allocation does not justify emitting control traffic to an unsuitable endpoint. + +## 22. Restoration, lifecycle, and evidence invalidation When `Icod.Terminal` claims exact restoration, it establishes a truthful baseline first. Unknown state is not replaced by a guessed default while being described as restoration. @@ -277,9 +319,11 @@ Suspend/resume and explicit invalidation are trust boundaries for live observati Already-returned `TerminalCapabilityStatus` values are immutable snapshots. They do not update themselves across lifecycle changes; callers inspect again when current knowledge matters. -Raster images and notification observations are ephemeral output/observations. They are not automatically replayed after resume and are not represented as exactly restorable terminal state. +Ephemeral raster display and notification observations are not automatically replayed after resume and are not represented as exactly restorable terminal state. + +Persistent raster handles are also not replayable restoration state. Their terminal-side identities are valid only for the lifecycle generation that established them. Invalidation/resume makes existing handles stale; the library does not retain hidden raster copies, silently re-upload them, or emit stale ids during later disposal. -## 22. Dependencies and native boundaries +## 23. Dependencies and native boundaries Native platform APIs are used only for terminal-control/lifecycle operations that require them. The package does not hide PTY process hosting, shell execution, browser/network access, OS clipboard integration, image decoding, or native desktop notification APIs behind terminal semantic methods. @@ -287,26 +331,26 @@ Native platform APIs are used only for terminal-control/lifecycle operations tha Sixel, Kitty Graphics, notification protocols, and capability probes are terminal traffic only. -## 23. Reporting security issues +## 24. Reporting security issues Security defects should be reported through the repository owner's supported private security-reporting channel when available rather than publishing exploitable details before a fix can be prepared. Compatibility or missing-feature requests should remain distinct from security reports. -## 24. Permanent security principles +## 25. Permanent security principles For stable 1.x, new features should preserve these principles: 1. expose semantic intent rather than generic dangerous protocol dispatch; 2. validate and bound untrusted payloads before commitment where possible; -3. keep parsing, conversion, event buffering, capability verification, and resynchronization bounded; +3. keep parsing, conversion, event buffering, capability verification, persistent ownership registries, and resynchronization bounded; 4. preserve one authoritative input/query/event reader; 5. do not infer support solely from brand/environment identity; 6. separate capability support, endpoint availability, and evidence lifetime; 7. keep ordinary inspection side-effect free and make terminal probing explicit; 8. treat correlation or semantic recognition as bounded ownership rather than trust; -9. distinguish emission from terminal application or acknowledgement; -10. treat unsolicited semantic events and query responses as unauthenticated external input; +9. distinguish emission from terminal application or acknowledgement, and acknowledgement from authentication; +10. treat unsolicited semantic events, query responses, and graphics acknowledgements as unauthenticated external input; 11. make metadata disclosure explicit; 12. do not claim exact restoration without a truthful baseline; 13. surface uncertainty and compound failures rather than hiding them; @@ -314,4 +358,7 @@ For stable 1.x, new features should preserve these principles: 15. once a terminal graphics transaction is committed, preserve logical-transfer integrity rather than using ordinary caller cancellation to truncate it; 16. never automatically replay or switch backends after partial committed graphics failure; 17. do not turn typed semantic event or capability-planning support into a generic raw vendor/protocol bus; -18. do not expose dependency/backend provenance as authentication or terminal identity. +18. do not expose dependency/backend provenance as authentication or terminal identity; +19. keep persistent protocol identities private behind opaque session-owned handles; +20. treat lifecycle invalidation and terminal `ENOENT` as loss of persistent-state certainty rather than silently replaying or inventing state; +21. do not retain arbitrary raster source data solely to provide hidden persistent-state replay. diff --git a/docs/releases/1.11.0.md b/docs/releases/1.11.0.md new file mode 100644 index 000000000..f82edfd19 --- /dev/null +++ b/docs/releases/1.11.0.md @@ -0,0 +1,168 @@ +# Icod.Terminal 1.11.0 + +`Icod.Terminal 1.11.0` adds a backend-neutral persistent raster ownership domain above the existing raw raster and capability-planning foundations. + +The package continues to target: + +```text +net8.0 +net9.0 +net10.0 +``` + +The stable compatibility floor remains `1.0.0`. + +## Persistent raster resources and placements + +Version 1.11 introduces opaque terminal-resident raster resource and placement ownership: + +```csharp +TerminalCapabilityStatus capability = await session.VerifyCapabilityAsync( + TerminalCapability.PersistentRasterGraphics +); + +TerminalControlResult resourceResult = + await session.CreateRasterResourceAsync( image ); +await using TerminalRasterResource resource = resourceResult.GetRequiredValue(); + +TerminalControlResult placementResult = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = 24 + } + ); +await using TerminalRasterPlacement placement = placementResult.GetRequiredValue(); + +TerminalControlMutationResult update = await placement.UpdateAsync( + new TerminalRasterPlacementOptions { + Columns = 16 + } +); +``` + +The public surface adds: + +- `TerminalCapability.PersistentRasterGraphics = 9`; +- `TerminalRasterResource`; +- `TerminalRasterPlacement`; +- `TerminalRasterPlacementOptions`; +- `TerminalSession.CreateRasterResourceAsync(...)`; +- resource placement creation and placement update/disposal. + +The common API does not expose Kitty image ids, image numbers, placement ids, raw APC command dictionaries, or a caller-selected graphics backend. + +## Capability and backend semantics + +Ordinary `RasterGraphics` remains independent from persistent ownership: + +```text +RasterGraphics + verified Kitty Graphics or verified Sixel + +PersistentRasterGraphics + reviewed persistent-capable Kitty Graphics path only +``` + +A terminal can therefore support ordinary raster display through Sixel while persistent raster resources remain unknown or unsupported. + +`InspectCapability(...)` stays side-effect free. `VerifyCapabilityAsync(PersistentRasterGraphics)` uses the reviewed bounded Kitty support path and the existing one-reader/query architecture. + +## Resource creation and acknowledgement + +Persistent upload uses direct terminal transfer and requires a correlated acknowledgement before a public resource is returned. + +Internally, the session allocates a private nonzero image number for upload correlation; the terminal returns its assigned nonzero image id. Those protocol identities never become part of the public object model. + +The upload path preserves the established committed-output rule: cancellation is effective before commitment, but the library does not intentionally truncate an already committed logical transfer, blindly replay uncertain output, or switch to Sixel after partial failure. + +## Placement behavior + +A resource can own multiple placements. Each placement is created or updated at the terminal's current cursor location. + +`TerminalRasterPlacementOptions.Columns` and `.Rows` are independently optional. Each supplied value must be between `1` and `16384`. The reviewed backend requests no cursor movement while placing graphics. + +Placement creation and update use correlated acknowledgement ownership. A wrong image/placement identity does not satisfy another placement transaction; malformed correlated responses are rejected. + +## Lifetime and invalidation + +Persistent terminal identities are session-generation scoped. + +Explicit `InvalidateState()` and managed lifecycle generation changes invalidate the certainty represented by existing resource/placement handles. Version 1.11 deliberately does not retain arbitrary source raster images for hidden replay or automatically re-upload resources after suspend/resume. + +Stale update/create operations return controlled `Unavailable` before output. Stale disposal performs local cleanup only and does not emit stale terminal identifiers. + +Current resource cleanup closes placements before resource data. Local disposal is idempotent even when cleanup transport fails, and cleanup failures are surfaced rather than silently retried with uncertain identity. + +## Terminal eviction and `ENOENT` + +Terminal-resident graphics are external state. A terminal may evict stored image data under its own quota policy. + +When a well-formed correlated persistent-raster response reports `ENOENT` for an object the session believed current, the owning resource/placement certainty is invalidated and the operation returns controlled `Unavailable` semantics. Other well-formed negative responses remain controlled failures. + +## Resource bounds + +Persistent bookkeeping is bounded independently of terminal storage: + +```text +maximum live persistent resources 256 per session +maximum live persistent placements 4096 per session +``` + +Private protocol identities are nonzero, avoid live collisions, and have explicit wraparound handling. + +No successful persistent creation retains a hidden copy of the source `TerminalRasterImage`. + +## Security and privacy + +Persistent raster responses are untrusted terminal input even after identity correlation. Matching ids establish routing ownership, not terminal authenticity. Numeric parsing, duplicate fields, malformed framing, and response sizes remain bounded and validated. + +Version 1.11 continues to use Kitty direct transfer only. It does not silently introduce file, temporary-file, or shared-memory graphics transports and their filesystem/IPC lifetime and visibility concerns. + +No generic raw public Kitty writer or caller-selected raw image/placement identifiers are added. + +See [`Persistent-Raster-Ownership.md`](../Persistent-Raster-Ownership.md) and [`Security-and-Privacy.md`](../Security-and-Privacy.md). + +## Qualification + +The C118 feature head was qualified on Windows, Linux, and macOS with: + +- all `net8.0`, `net9.0`, and `net10.0` runtime tests; +- repeated create/place/update/delete ownership stress; +- repeated generation invalidation and registry churn; +- adversarial/malformed acknowledgement handling; +- fresh NuGet-only persistent-raster consumption; +- generated XML documentation checks on all package TFMs; +- the backend-neutral persistent-raster sample; +- current `Icod.DCurses` downstream acceptance and hardening soak; +- package candidate/API freeze, all package shards, and validated package artifact. + +The final 1.11 public API fingerprint is: + +```text +9336a1f6def1c4b02e86db813bae27f45b95af33f47a2cf10dccd4d1d44324f2 +``` + +## Deliberate exclusions + +Version 1.11 does not add: + +- public Kitty image/placement ids; +- caller-selected raster backends; +- Sixel emulation of persistent resources; +- automatic raster replay/re-upload after invalidation; +- hidden source-image caches; +- source rectangles or pixel-coordinate placement; +- z-order or Unicode placeholders; +- relative placement or animation; +- scene-graph, cells, windows, damage, or layout ownership; +- image-file decoding/transcoding; +- Kitty file/temp-file/shared-memory transfer; +- PTY/ConPTY process hosting. + +Higher-level screen/layout policy remains the responsibility of `Icod.DCurses`. Advanced placement/lifecycle features remain candidates for later releases only where a concrete downstream requirement justifies them. + +## Compatibility + +Version 1.11 is an additive minor release over the stable `1.0.0` compatibility floor. Existing public signatures and enum numerics are preserved; `PersistentRasterGraphics` is appended at numeric value `9`. + +See [`Compatibility-and-Versioning.md`](../Compatibility-and-Versioning.md) for the permanent compatibility policy and [`Icod.Terminal-1.11.0-Development-Roadmap.md`](../../Icod.Terminal-1.11.0-Development-Roadmap.md) for the complete development/qualification record. diff --git a/docs/superpowers/plans/2026-09-11-1.11.0-persistent-raster.md b/docs/superpowers/plans/2026-09-11-1.11.0-persistent-raster.md new file mode 100644 index 000000000..75baf6b4b --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-1.11.0-persistent-raster.md @@ -0,0 +1,200 @@ +# Icod.Terminal 1.11.0 Persistent Raster Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add bounded, session-owned persistent raster resources and placements above the existing Kitty Graphics backend while preserving the 1.7/1.8 ephemeral raster contract, the 1.10 capability-planning model, the one-reader query architecture, and the stable 1.x compatibility floor. + +**Architecture:** Keep public identities opaque. A `TerminalSession` owns a bounded persistent-raster registry. Resource creation uploads raw raster data through direct Kitty transfer, correlates the acknowledgement through the authoritative input/query path, and returns a `TerminalRasterResource` only after terminal-side identity is known. Placements are child handles with private placement IDs; update replaces the same placement at the current cursor position. Lifecycle generation changes make all persistent identities stale rather than replaying hidden source data. + +**Tech Stack:** C# 13, .NET `net8.0`/`net9.0`/`net10.0`, xUnit, existing APC/Kitty Graphics encoder/parser, `TerminalInputCoordinator`, `TerminalQueryTransactionManager`, semantic capability/evidence routing, PowerShell packaging verification, GitHub Actions Staging matrix. + +**Spec:** `docs/superpowers/specs/2026-09-11-1.11.0-persistent-raster-design.md` and `Icod.Terminal-1.11.0-Development-Roadmap.md`. + +## Global Constraints + +- Stable compatibility floor remains `1.0.0`. +- Preserve all existing public members and enum numerics; append `TerminalCapability.PersistentRasterGraphics = 9` only. +- `RasterGraphics` remains independently usable through verified Sixel or Kitty Graphics. +- `PersistentRasterGraphics` is satisfied only by the reviewed persistent-capable Kitty path. +- No public Kitty image id, image number, placement id, backend selector, APC writer, or registry enumeration. +- One live `TerminalSession` remains the authoritative input/query reader. +- Persistent acknowledgements reuse existing bounded response routing; no graphics-specific second reader. +- Direct Kitty transfer only; no file, temporary-file, or shared-memory media. +- Acknowledged persistent upload **must not use quiet mode**. Omit `q` (protocol default `q=0`) so success/failure responses remain observable. Existing ephemeral `DisplayRasterAsync(...)` retains its established `q=2` bytes unchanged. +- Caller cancellation is effective before commitment; committed multi-frame transfers are not intentionally truncated. +- No automatic replay, backend switching, or hidden raster retention after ambiguous failure or lifecycle invalidation. +- Maximum live persistent resources per session: `256`. +- Maximum live placements per session: `4096`. +- `TerminalRasterPlacementOptions.Columns` and `.Rows` are nullable and, when present, must be within `1..16384`. +- Public persistent operations use the normal session-output gate. Internal disposal cleanup may use the control-output gate after public output has been closed. +- Current `Icod.DCurses` acceptance and all three package TFMs remain release gates. + +--- + +### Task 1: Activate the 1.11 development line and freeze C110 + +**Files:** `Directory.Build.props`, `Icod.Terminal-1.11.0-Development-Roadmap.md`, new `docs/C110-Persistent-Raster-Architecture-and-API-Regret-Gate.md`. + +- [ ] Set `VersionPrefix=1.11.0` and `VersionSuffix=alpha.1`; do not alter package dependency versions. +- [ ] Mark C110 complete / C111 active in the 1.11 roadmap and record the approved public API, result semantics, registry ceilings, lifecycle rules, and exclusions. +- [ ] Record C110 as design-only acceptance; no production runtime delta belongs in this tranche. +- [ ] Verify the PR baseline remains green after version/doc-only changes. +- [ ] Commit: `docs: accept C110 persistent raster contract`. + +### Task 2: C111 RED — persistent Kitty protocol vectors + +**Files:** new `tests/Icod.Terminal.Tests/src/Graphics/KittyGraphicsPersistentEncoderTests.cs`, new `tests/Icod.Terminal.Tests/src/Graphics/KittyGraphicsPersistentResponseMatcherTests.cs`, and `KittyGraphicsCodecTests.cs` only where shared response behavior belongs there. + +- [ ] RED: one-pixel transmit-only acknowledged upload has canonical first payload: + +```text +Ga=t,f=24,s=1,v=1,t=d,I=31,m=0;AAAA +``` + + It deliberately omits `q`. The terminal must be free to return the image-number acknowledgement. +- [ ] RED: multi-frame upload continuation payloads are `Gm=1;...` / `Gm=0;...`; every Base64 data chunk stays within 4096 encoded bytes. Do not copy the ephemeral encoder's `q=2` into this acknowledged path. +- [ ] RED: placement/update payload uses `a=p`, terminal `i=`, private `p=`, `C=1`, and optional `c`/`r`. +- [ ] RED: updating reuses the same image-id/placement-id pair. +- [ ] RED: targeted placement deletion is `a=d,d=i,i=,p=` (soft delete: keep image data); resource cleanup after child placements is `a=d,d=I,i=` (uppercase delete selector: free image data when unreferenced). +- [ ] RED: zero image/image-number/placement identities throw before encoding. +- [ ] RED matcher: an APC response matches only when parsed `ImageNumber == expected` and a nonzero terminal-assigned `ImageId` is present; unrelated APC remains unclaimed. +- [ ] RED: malformed/duplicate/overflow response fields fail boundedly. +- [ ] Run focused net10 test command and confirm the correct RED failure because persistent encoder/matcher types are absent. +- [ ] Commit RED tests before production code: `test: define C111 persistent Kitty protocol contract`. + +### Task 3: C111 GREEN — typed persistent Kitty protocol foundation + +**Files:** new `src/Graphics/KittyGraphicsPersistentEncoder.cs`, new `src/Graphics/KittyGraphicsPersistentResponseMatcher.cs`, minimal `KittyGraphicsCodec.cs` changes only if a bounded shared helper is required. + +- [ ] Implement `EncodeUploadPayloads(KittyRasterData raster, uint imageNumber)` separately; do not alter `KittyGraphicsDirectEncoder.EncodeDisplayPayloads(...)` or existing ephemeral bytes. +- [ ] First upload frame uses `a=t`, raw format/dimensions, `t=d`, `I=`, and `m`; continuation frames contain only `m` plus payload framing. Omit `q` throughout acknowledged upload. +- [ ] Implement canonical placement/update payload encoding with current-cursor placement and `C=1`. +- [ ] Implement typed soft placement delete and hard resource-data delete payloads. +- [ ] Implement `KittyGraphicsPersistentResponseMatcher : ITerminalResponseMatcher, ICorrelatedTerminalResponseMatcher` keyed by image number; complete-frame matching parses through `KittyGraphicsCodec.ParseResponse(...)`. +- [ ] Preserve existing parser/control-data/message ceilings and correlated malformed-response ownership. +- [ ] Run C111 tests plus existing direct encoder/codec/output-transaction tests on net8/net9/net10. +- [ ] Commit: `feat: add C111 persistent Kitty protocol foundation`. + +### Task 4: C112 RED/GREEN — persistent capability planning + +**Files:** `src/Session/TerminalCapabilityStatus.cs`, `src/Routing/TerminalControlLanguageVocabulary.cs`, `src/Routing/TerminalSemanticBackendRegistry.cs`, `src/Session/TerminalSession.CapabilityInspection.cs`, capability inspection/verification/lifecycle tests. + +- [ ] RED: assert exact public numeric map `ClipboardRead=0 ... RasterGraphics=8, PersistentRasterGraphics=9`. +- [ ] RED: verified Sixel alone makes `RasterGraphics` verified but does not verify `PersistentRasterGraphics`. +- [ ] RED: verified Kitty evidence verifies both semantic operations without inspection I/O. +- [ ] RED: redirected output reports persistent endpoint unavailable without probing. +- [ ] RED: verification of persistent raster reuses the bounded Kitty support path and never probes Sixel. +- [ ] GREEN: append public/internal persistent semantic operations and map persistent only to `ApcKittyGraphics`. +- [ ] GREEN: add public capability mapping and `VerifyCapabilityAsync` case calling the existing bounded Kitty support probe. +- [ ] Run capability-planning and semantic-routing tests. +- [ ] Create provisional `Public-API-Baseline-1.11` files and point `VerifyPublicApiBaseline.ps1` to them when the intentional API delta first lands; any later public drift outside the approved C110 surface is a review failure. +- [ ] Commit: `feat: add C112 persistent raster capability planning`. + +### Task 5: C113 RED/GREEN — bounded persistent ownership registry + +**Files:** new `TerminalPersistentRasterRegistry.cs`, `TerminalPersistentRasterResourceState.cs`, `TerminalPersistentRasterPlacementState.cs`, registry tests; minimal session ownership wiring if needed. + +- [ ] RED: allocate nonzero image numbers with no live collisions and explicit wraparound. +- [ ] RED: enforce 256 live resources; allocation #257 fails controlledly before protocol output. +- [ ] RED: allocate nonzero placement IDs; enforce 4096 total live placements. +- [ ] RED: multiple placements per resource; parent close blocks child creation. +- [ ] RED: local disposal/unregistration is idempotent and does not reuse a still-owned identity. +- [ ] GREEN: implement one focused synchronization boundary and separate persistent-object generation counter. Never store `TerminalRasterImage` or pixel data in the registry. +- [ ] Run registry tests on all TFMs without terminal transport fixtures. +- [ ] Commit: `feat: add C113 persistent raster ownership registry`. + +### Task 6: C114 RED — public resource contract and acknowledged upload + +**Files:** new `src/Graphics/TerminalRasterResource.cs`, `src/Session/TerminalSession.PersistentRasterGraphics.cs`, `src/Graphics/KittyGraphicsPersistentUploadTransaction.cs`, public contract and session upload tests. + +- [ ] RED reflection tests for exactly the C110-approved public resource/session signatures; prove no public backend/numeric identity members leak. +- [ ] RED: null image, pre-cancel, unavailable endpoint, and Sixel-only persistent-unavailable cases produce the frozen argument/result behavior without inappropriate output. +- [ ] RED: successful Kitty upload sends transmit-only acknowledged chunks and accepts matching `I=,i=;OK` before returning `Available(resource)`. +- [ ] RED: wrong image number remains unrelated; malformed correlated acknowledgement throws `FormatException`; timeout throws `TimeoutException`; transport failures propagate. +- [ ] RED: cancellation after first committed upload frame does not truncate remaining chunks. +- [ ] RED: failed/ambiguous creation never publishes a usable resource handle. +- [ ] Commit RED tests separately. + +### Task 7: C114 GREEN — upload/query coordination and resource creation + +- [ ] Implement sealed internal-construction-only `TerminalRasterResource` and `TerminalSession.CreateRasterResourceAsync(...)` returning `TerminalControlResult`. +- [ ] Require/verify `PersistentRasterGraphics`, reserve registry capacity before output, and roll reservation back on failure. +- [ ] Reuse authoritative query routing. If the existing single-request query primitive cannot express committed multi-frame upload, add one **internal** query-manager primitive that registers/arms a normal response expectation, holds the existing ambiguity gate, invokes a supplied committed emitter under existing output serialization, and then reuses existing timeout/late-response ownership logic. Do not add a second reader/router. +- [ ] After first upload frame commits, remaining frames and flush use non-cancellable transaction completion; caller wait behavior still follows query ownership rules. +- [ ] Require matching image number and assigned nonzero image id before binding registry state and returning the public resource. +- [ ] Well-formed negative response -> controlled `Failed`; correlated `ENOENT` during creation -> controlled `Unavailable`; release reservation in both cases. +- [ ] Run focused upload/resource tests plus existing query and Kitty output suites. +- [ ] Commit: `feat: implement C114 persistent raster resource upload`. + +### Task 8: C115/C116 RED/GREEN — placement options, creation, update, disposal + +**Files:** new `TerminalRasterPlacementOptions.cs`, `TerminalRasterPlacement.cs`, placement mutation transaction and tests; extend resource/session persistent files. + +- [ ] RED: nullable Columns/Rows accepted; 0, negative, and >16384 rejected before output. +- [ ] RED: `CreatePlacementAsync` emits `a=p,C=1,i=,p=` plus optional `c`/`r`; multiple placements use distinct IDs. +- [ ] RED: `UpdateAsync` reuses the same placement ID at the current cursor; no public coordinate API appears. +- [ ] RED: disposed handle throws `ObjectDisposedException`; stale resource/placement returns controlled `Unavailable` without output. +- [ ] RED: placement disposal emits at most one targeted soft delete; repeated disposal emits nothing. +- [ ] RED: resource disposal closes children before hard resource-data delete, releases local ownership even if terminal cleanup fails, and aggregates multiple cleanup failures. +- [ ] GREEN: implement options/public placement objects and typed mutation transactions. Public mutations use `AcquireSessionOutputAsync`; internal teardown cleanup may use `AcquireControlOutputAsync(CancellationToken.None)` after admission closes. +- [ ] Map Kitty `ENOENT` to object invalidation + controlled `Unavailable`; other well-formed negatives to `Failed`. +- [ ] Run placement, output serialization, and graphics suites. +- [ ] Preserve RED-before-GREEN history; C115 and C116 may be separate commits where practical. + +### Task 9: C117 RED/GREEN — lifecycle invalidation and teardown + +**Files:** `TerminalSession.cs`, `TerminalSession.Lifecycle.cs`, registry/state files, new lifecycle tests. + +- [ ] RED: `InvalidateState()` makes live persistent handles stale without delete/replay output. +- [ ] RED: suspend invalidates persistent identities before host state yield; resume does not replay image data. +- [ ] RED: stale create-placement/update return `Unavailable` without output; stale disposal is local-only. +- [ ] RED: current-generation session disposal closes admission, drains committed work, deletes child placements before resources, then continues existing restoration. +- [ ] RED: persistent cleanup exceptions aggregate with existing disposal/restoration failures. +- [ ] GREEN: use a separate persistent-object generation counter; do not double-advance the existing semantic evidence generation already advanced by input-protocol invalidation. +- [ ] GREEN: invoke persistent close after `StopAcceptingSessionOutput()` but before final host restoration; teardown uses internal control-output acquisition. +- [ ] Run lifecycle/session disposal and existing committed Kitty transaction tests. +- [ ] Commit: `feat: integrate C117 persistent raster lifecycle`. + +### Task 10: C118 adversarial, sample, package, downstream qualification + +**Files:** new persistent-raster sample + verifier, new NuGet-only smoke + package verifier, solution/sample README/runtime/package shard updates, hardening tests. + +- [ ] Sample: inspect/verify `PersistentRasterGraphics`, generate raw raster data in memory, create resource/placement, reposition through ordinary cursor operations, update, dispose; never branch on Kitty/Sixel/brand/TERM or expose numeric IDs. +- [ ] Build sample on net8/net9/net10 via `VerifyPersistentRasterGraphicsSample.ps1` wired into `VerifyRuntime.ps1`. +- [ ] Fresh package smoke has exactly one direct package reference: `Icod.Terminal`. +- [ ] Package XML verifier requires all new public types/methods and proves no public Kitty numeric-id API. +- [ ] Add semantic package-shard coverage. +- [ ] Harden malformed/oversized/wrong correlation, 256/4096 exhaustion, wraparound, repeated invalidation/disposal, races, and transport failures at commitment boundaries. +- [ ] Keep current DCurses acceptance/soak unchanged unless a real incompatibility is exposed. +- [ ] Run Staging runtime and semantic package shard on exact head. +- [ ] Commit qualification and any narrowly exposed fixes separately. + +### Task 11: C119 public API, permanent docs, stable closure + +**Files:** root README/changelog/project/version/roadmaps, Architecture/Security/Compatibility docs, new permanent persistent-raster doc, final 1.11 baseline, release notes, C119 closure record, baseline verifier. + +- [ ] Freeze one identical net8/net9/net10 API snapshot; delta must equal the C110-approved capability/types/methods only. +- [ ] Set stable `1.11.0` package identity and remove prerelease suffix. +- [ ] Update release notes/tags/docs without branch-specific alpha links. +- [ ] Document permanent ownership, lifecycle, security, bounds, and exclusions. +- [ ] Close 1.11 roadmap and hand long-range roadmap to conditional 1.12 advanced placement. +- [ ] Run complete unchanged-head PR Staging matrix: Windows, Linux, macOS, package candidate, all package shards, validated artifact. +- [ ] Record exact head/workflow only after all jobs are green. +- [ ] Leave merge, main Release validation, `v1.11.0` tag, GitHub Release, and NuGet publication to explicit maintainer actions. +- [ ] Commit: `docs: close Icod.Terminal 1.11.0`. + +## Execution Order + +```text +C110 contract + -> C111 protocol substrate + -> C112 capability + provisional API baseline + -> C113 bounded local ownership + -> C114 acknowledged upload + -> C115/C116 placement ownership + -> C117 lifecycle/teardown + -> C118 adversarial/package/downstream + -> C119 final API/docs/release closure +``` + +At every tranche boundary, preserve RED-before-GREEN evidence where practical and do not broaden scope merely because Kitty offers additional protocol features. Source rectangles, z-order, Unicode placeholders, relative placement, pixel offsets, animation, file/temp/shared-memory transport, automatic replay, and scene-graph policy remain outside 1.11. \ No newline at end of file diff --git a/docs/superpowers/specs/2026-09-11-1.11.0-persistent-raster-design.md b/docs/superpowers/specs/2026-09-11-1.11.0-persistent-raster-design.md new file mode 100644 index 000000000..fa598145d --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-1.11.0-persistent-raster-design.md @@ -0,0 +1,408 @@ +# Icod.Terminal 1.11.0 — Persistent Raster Resources and Placements + +**Date:** 2026-09-11 +**Release:** `1.11.0` +**Status:** approved design; C110 public contract frozen for implementation planning +**Base:** merged `1.10.0` mainline + +## Purpose + +Version 1.11 introduces session-owned persistent raster resources and placements without turning `Icod.Terminal` into a scene graph, exposing Kitty Graphics numeric identifiers, or weakening the established backend-neutral raster contract. + +Versions 1.7 and 1.8 deliberately limited the public raster API to ephemeral `DisplayRasterAsync(...)` output. Version 1.10 exposed protocol-neutral capability inspection and explicit bounded verification. Version 1.11 builds a distinct terminal-resident ownership domain on those foundations. + +The release goal is intentionally narrow: + +```text +create/upload one terminal-resident raster resource +create one or more placements for that resource +reposition/resize an existing placement +delete/dispose placements and resources deterministically +invalidate terminal-resident certainty across lifecycle-generation changes +``` + +## Design principles + +1. Public resource and placement identity is opaque and semantic. +2. Kitty image ids, image numbers, and placement ids remain internal protocol details. +3. Persistent raster capability is distinct from ordinary raster-display capability. +4. Sixel remains a valid backend for ordinary `RasterGraphics`; it does not pretend to provide persistent-resource semantics. +5. The session owns persistent graphics bookkeeping and terminal cleanup while handles are alive. +6. No hidden source-image retention or automatic replay occurs across suspend/resume or explicit invalidation. +7. Lifecycle-generation changes invalidate terminal-resident certainty rather than inventing restoration. +8. After partial committed transmission, the library does not blindly replay, retry through another backend, or claim certainty about terminal state. +9. Registries and protocol data remain bounded. +10. 1.11 does not absorb DCurses cells/windows/layout/damage/scene ownership. + +## Public capability model + +Version 1.11 adds exactly one semantic capability value: + +```text +PersistentRasterGraphics = 9 +``` + +The frozen 1.10 values `0..8` remain unchanged. + +`RasterGraphics` continues to mean that the session can display a backend-neutral raster using a reviewed raster backend. `PersistentRasterGraphics` means that the session can create and manage terminal-resident raster resources and placements using the 1.11 ownership contract. + +A session may therefore truthfully report: + +```text +RasterGraphics Verified / usable +PersistentRasterGraphics Unknown or Unsupported +``` + +when Sixel is available but the persistent Kitty subset is not verified. + +`InspectCapability(...)` remains side-effect free. `VerifyCapabilityAsync(...)` may strengthen `PersistentRasterGraphics` only through the existing reviewed bounded Kitty Graphics support path. It does not introduce a broader fingerprinting query. + +## Frozen public API shape + +The C110 public surface is: + +```csharp +public sealed class TerminalRasterPlacementOptions { + public int? Columns { get; set; } + public int? Rows { get; set; } +} + +public sealed class TerminalRasterResource : IAsyncDisposable { + public ValueTask> CreatePlacementAsync( + TerminalRasterPlacementOptions? options = null, + CancellationToken cancellationToken = default + ); + + public ValueTask DisposeAsync(); +} + +public sealed class TerminalRasterPlacement : IAsyncDisposable { + public ValueTask UpdateAsync( + TerminalRasterPlacementOptions? options = null, + CancellationToken cancellationToken = default + ); + + public ValueTask DisposeAsync(); +} + +public sealed partial class TerminalSession { + public ValueTask> CreateRasterResourceAsync( + TerminalRasterImage image, + CancellationToken cancellationToken = default + ); +} +``` + +No public backend identity, resource id, image id, image number, placement id, mutable raster bytes, registry enumeration, or protocol command object is added. + +### Controlled-result contract + +The existing `TerminalControlResult` / `TerminalControlMutationResult` pattern is reused rather than creating a new graphics-specific result hierarchy. + +For creation/update operations: + +- `Available` means the requested object/mutation was established under the current generation; +- `Unavailable` means the endpoint/generation/terminal-resident object cannot currently satisfy the operation; +- `Unsupported` means the persistent semantic operation is not supported by the verified backend state; +- `Failed` means a reviewed terminal negative response or controlled operation failure occurred. + +Programming and transport/query failures keep the library's existing exception semantics: + +- invalid arguments/options -> `ArgumentNullException` / `ArgumentOutOfRangeException`; +- use of a disposed handle -> `ObjectDisposedException`; +- caller cancellation before commitment -> `OperationCanceledException`; +- bounded correlated timeout -> `TimeoutException`; +- correlated malformed terminal response -> `FormatException`; +- transport failure -> propagated transport exception. + +A Kitty `ENOENT` received while operating on a resource/placement that the session believed current invalidates that terminal-resident object and returns controlled `Unavailable`. Other well-formed Kitty negative responses are controlled `Failed` results with bounded diagnostic text. + +## TerminalRasterPlacementOptions + +The first release keeps placement policy narrow. + +`Columns` and `Rows` are nullable positive cell counts: + +```text +null protocol/default behavior +1..16384 accepted +0 or >16384 rejected +negative rejected +``` + +Either property may be specified independently. Kitty defines the unspecified dimension from the source aspect ratio when only one of `c`/`r` is supplied. + +Version 1.11 deliberately excludes absolute screen coordinates, pixel offsets, source rectangles, z-order, Unicode placeholders, relative placement, animation, and scene-graph concepts. + +## Placement positioning model + +A placement is created at the terminal's current cursor position. The protocol command always uses `C=1`, so creating or updating a placement does not itself move the text cursor. + +Repositioning is explicit: + +```text +move the terminal cursor through ordinary terminal semantics +update the existing placement +``` + +The same private `(image id, placement id)` pair is reused for update. Kitty specifies that a second placement using the same pair replaces the first, allowing move/resize without creating a new public placement object. + +This keeps coordinate/layout policy outside `Icod.Terminal` and avoids duplicating DCurses responsibilities. + +## Internal Kitty identity strategy + +Initial resource upload uses a private nonzero Kitty image number (`I`) allocated by the session. Image numbers are appropriate when the terminal screen may be shared because they do not require guessing which global image ids are free. The acknowledgement returns the terminal-assigned image id (`i`), which is used for all later resource and placement operations. + +The internal mapping is: + +```text +public TerminalRasterResource + -> private resource token + -> private Kitty image number + -> acknowledged Kitty image id +``` + +Each placement receives a private nonzero placement id. + +Identity allocation: + +- uses `uint` protocol identities; +- skips zero; +- avoids collision with live registry entries; +- advances monotonically within a session where possible; +- scans for a free live identity after wraparound; +- returns controlled `Unavailable` if the bounded registry is full rather than creating unbounded bookkeeping. + +The public API never promises numeric identity stability. + +## Protocol operations + +The 1.11 internal Kitty subset is frozen to: + +```text +resource upload a=t, t=d, I=, q=0 +placement create a=p, i=, p=, C=1 [,c=] [,r=] +placement update same a=p / i / p identity, C=1 [,c=] [,r=] +placement delete a=d, d=i, i=, p=, q=2 +resource delete a=d, d=I, i=, q=2 +``` + +The uppercase resource-delete selector is used so terminal-side image data is freed when its placements are gone. Placement deletion uses the lowercase selector so deleting one placement does not free the shared resource data. + +Persistent resource upload uses the existing direct RGB24/RGBA32 adaptation and bounded Base64 chunking. Only the first chunk carries full transmission metadata; continuation chunks carry the existing bounded continuation grammar. File, temporary-file, and shared-memory transfer remain excluded. + +There is no public raw Kitty command dictionary or APC writer. + +## Acknowledgement and correlation + +Resource creation requires a correlated acknowledgement before a public resource is returned. The acknowledgement must contain: + +```text +I= +i= +message = OK +``` + +The existing authoritative input/query architecture remains the only reader. Persistent graphics adds no graphics-specific input loop. + +A correlated response remains untrusted terminal input. Correlation grants routing ownership, not trust. Existing parser ceilings, numeric overflow checks, duplicate-key rejection, malformed termination handling, and bounded resynchronization remain applicable. + +Placement creation/update uses the resource's acknowledged image id and private placement id. Its acknowledgement, when requested by the implementation for correlation-sensitive operations, must match both identities before it can affect local state. + +## Commit and cancellation semantics + +Before commitment: + +- arguments/options are validated; +- resource/placement local state is validated; +- capability and endpoint requirements are checked; +- caller cancellation is honored; +- session output-gate acquisition remains cancellable. + +After the first persistent graphics frame commits: + +- ordinary caller cancellation does not intentionally truncate the logical transaction; +- the output gate remains held through the transaction boundary; +- partial transport failure is surfaced; +- the operation does not automatically replay; +- the operation does not switch to Sixel; +- terminal-resident certainty is not fabricated after ambiguous partial failure. + +If resource creation fails after partial committed output but before reliable acknowledgement, no public resource is returned and the allocated local identity is retired from that operation before reuse. + +## Bounded registries + +`TerminalSession` owns bounded registries with these 1.11 ceilings: + +```text +maximum live persistent raster resources per session 256 +maximum live persistent raster placements per session 4096 +``` + +The limits apply to live local ownership, not terminal implementation quotas. Reaching a limit returns controlled `Unavailable` before protocol output. + +The registries provide: + +- opaque internal identity; +- generation association; +- parent/child ownership; +- idempotent local disposal state; +- serialization of conflicting mutations; +- bounded teardown enumeration; +- prevention of identity reuse while ownership is live. + +The registry is bookkeeping, not a retained scene representation. It does not retain source raster images after creation completes. + +## Parent/child lifetime rules + +A resource may own zero or more placements. + +Placement disposal: + +1. atomically prevents new mutations; +2. if generation certainty is current and session output still accepts cleanup, emits one targeted quiet placement delete; +3. removes local registry ownership even if the terminal write fails; +4. surfaces a transport failure to the direct disposing caller; +5. subsequent disposal performs no second protocol delete. + +Resource disposal: + +1. prevents new placement creation; +2. closes/disposes all still-live child placements; +3. if generation certainty is current, emits one quiet resource delete that frees terminal image data; +4. removes local registry ownership; +5. aggregates child/resource cleanup transport failures; +6. subsequent disposal performs no second protocol delete. + +## Lifecycle-generation semantics + +Persistent terminal graphics are generation-scoped. + +`InvalidateState()` and managed suspend/resume invalidate the generation used for persistent-resource certainty. + +After invalidation: + +- existing resource and placement handles become stale; +- no automatic image replay occurs; +- no hidden source-image copy is retained; +- update/create-placement return controlled `Unavailable` before output; +- disposal performs local bookkeeping without emitting stale numeric ids that may now refer to unrelated terminal state. + +Static capability evidence may remain valid according to the 1.10 model; terminal-resident object identity does not. + +1.11 adds no re-upload/rebind API. + +## Session teardown + +`TerminalSession.DisposeAsync()` remains final cleanup authority. + +While the persistent-graphics generation is current, teardown closes persistent graphics before final output-state restoration completes. Child placements are deleted before parent resources. + +Cleanup failures are accumulated with the session's existing disposal/restoration failure model. + +If the generation is already invalid, teardown closes local registry state without sending stale terminal identities. + +## Concurrency + +Resource and placement state transitions are serialized within the ownership registry/objects. + +Required properties: + +- two callers cannot emit duplicate protocol disposal for one object; +- create-placement cannot race resource disposal into an orphaned public handle; +- update cannot race placement disposal into reuse of a freed placement identity; +- session disposal prevents acceptance of new persistent mutations; +- accepted committed transactions drain before final restoration; +- independent local state reads never require terminal I/O. + +The implementation reuses existing session output/query synchronization rather than creating a competing global terminal lock or second input reader. + +## Security and privacy + +Persistent raster data has the same confidentiality properties as ordinary raster output: bytes are disclosed to the attached terminal and may be observed or retained by that environment. + +1.11 security rules are: + +- never accept caller-supplied raw Kitty image/placement ids; +- never expose a generic persistent Kitty command builder; +- bound resource/placement registries; +- validate sizes/counts before commitment; +- treat acknowledgements as untrusted terminal input; +- use direct transfer only; +- do not retain raster source data after creation completes; +- do not replay after lifecycle uncertainty or partial committed failure. + +Terminal storage quotas are external state. A terminal may evict stored image data under quota pressure; a later well-formed `ENOENT` therefore invalidates the affected resource rather than being treated as impossible internal corruption. + +## Compatibility + +Version 1.11 is an additive minor release and preserves: + +- the stable `1.0.0` compatibility floor; +- every existing public signature and enum numeric value; +- `TerminalCapability` values `0..8` exactly; +- ordinary `DisplayRasterAsync(...)` semantics; +- Sixel and Kitty ephemeral raster routing; +- the one-reader input/query/event model; +- capability-planning truthfulness and side-effect-free inspection; +- committed graphics output semantics; +- `net8.0`, `net9.0`, and `net10.0` package targets. + +A new 1.11 public API baseline/fingerprint is required before release. + +## Deliberate exclusions + +Version 1.11 does not add: + +- public Kitty image ids, image numbers, or placement ids; +- public backend selection; +- Sixel emulation of persistent resources; +- automatic replay/re-upload after resume; +- retained source-image caches for hidden restoration; +- source rectangles; +- z-order; +- Unicode placeholders; +- relative placements; +- pixel-coordinate placement; +- animation; +- image-file decoding; +- file/temp/shared-memory Kitty transports; +- cells, windows, layout, damage tracking, or a scene graph. + +These exclusions are intentional compatibility boundaries, not unfinished 1.11 work. + +## Testing strategy + +### Protocol unit tests + +Cover exact upload/place/update/delete payloads, chunking, numeric bounds, zero rejection, image-number acknowledgement, wrong/duplicate identities, malformed messages, and bounded response handling. + +### Ownership tests + +Cover the 256-resource and 4096-placement ceilings, wraparound/collision behavior, multiple placements, parent/child disposal, idempotent disposal, race boundaries, and stale-generation behavior. + +### Session tests + +Cover capability inspection/verification, unavailable endpoints, cancellation before commitment, transport failure after commitment, session disposal, lifecycle invalidation, terminal `ENOENT`, and absence of replay. + +### Compatibility/package tests + +Cover exact public API baselines on all TFMs, fresh NuGet-only consumption, generated XML documentation, absence of public backend numeric ids, and downstream `Icod.DCurses` acceptance. + +### Cross-platform validation + +Windows, Linux, and macOS Staging validation remains mandatory even though Kitty Graphics is terminal protocol traffic rather than an OS-native graphics API. + +## Release acceptance + +1.11 is complete only when: + +- C110–C119 are accepted; +- the final public API baseline is frozen; +- Architecture, Security/Privacy, Compatibility/Versioning, README, samples, and release notes agree; +- all supported TFMs expose one identical public surface; +- fresh NuGet-only package consumption succeeds; +- downstream acceptance remains green; +- one unchanged final PR head passes the complete Staging matrix. + +Merge, `main` Release validation, tagging, GitHub Release creation, and NuGet publication remain explicit maintainer actions. diff --git a/packaging/VerifyPackageContractShard.ps1 b/packaging/VerifyPackageContractShard.ps1 index 6d1a0ba33..e1dbf57dc 100644 --- a/packaging/VerifyPackageContractShard.ps1 +++ b/packaging/VerifyPackageContractShard.ps1 @@ -59,6 +59,7 @@ $scripts = switch ($Shard) { 'VerifyOsc1337Package.ps1', 'VerifyModernKeyboardPackage.ps1', 'VerifyRasterGraphicsPackage.ps1', + 'VerifyPersistentRasterPackage.ps1', 'VerifyCapabilityPlanningPackage.ps1', 'VerifyHardeningPackage.ps1' ) diff --git a/packaging/VerifyPersistentRasterPackage.ps1 b/packaging/VerifyPersistentRasterPackage.ps1 new file mode 100644 index 000000000..c96b51d71 --- /dev/null +++ b/packaging/VerifyPersistentRasterPackage.ps1 @@ -0,0 +1,133 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDirectory, + + [ValidateSet('Debug', 'Staging', 'Release')] + [string]$Configuration = 'Release', + + [string]$ExpectedVersion = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force + +if (-not [System.IO.Path]::IsPathRooted($ArtifactDirectory)) { + $ArtifactDirectory = Join-Path $repositoryRoot $ArtifactDirectory +} +$ArtifactDirectory = [System.IO.Path]::GetFullPath($ArtifactDirectory) +if (-not (Test-Path -LiteralPath $ArtifactDirectory -PathType Container)) { + throw "Artifact directory '$ArtifactDirectory' does not exist." +} + +if ([string]::IsNullOrWhiteSpace($ExpectedVersion)) { + $projectPath = Join-Path $repositoryRoot 'Icod.Terminal.csproj' + $ExpectedVersion = Get-MSBuildProperty -ProjectPath $projectPath -Name 'PackageVersion' -Configuration $Configuration +} +if ([string]::IsNullOrWhiteSpace($ExpectedVersion)) { + throw 'Unable to determine the expected Icod.Terminal package version.' +} + +$packagePath = Join-Path $ArtifactDirectory "Icod.Terminal.$ExpectedVersion.nupkg" +if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { + throw "Expected package '$packagePath' was not produced." +} + +$requiredMembers = @( + 'F:Icod.Terminal.TerminalCapability.PersistentRasterGraphics', + 'T:Icod.Terminal.TerminalRasterPlacementOptions', + 'P:Icod.Terminal.TerminalRasterPlacementOptions.Columns', + 'P:Icod.Terminal.TerminalRasterPlacementOptions.Rows', + 'T:Icod.Terminal.TerminalRasterResource', + 'M:Icod.Terminal.TerminalRasterResource.CreatePlacementAsync(Icod.Terminal.TerminalRasterPlacementOptions,System.Threading.CancellationToken)', + 'M:Icod.Terminal.TerminalRasterResource.DisposeAsync', + 'T:Icod.Terminal.TerminalRasterPlacement', + 'M:Icod.Terminal.TerminalRasterPlacement.UpdateAsync(Icod.Terminal.TerminalRasterPlacementOptions,System.Threading.CancellationToken)', + 'M:Icod.Terminal.TerminalRasterPlacement.DisposeAsync', + 'M:Icod.Terminal.TerminalSession.CreateRasterResourceAsync(Icod.Terminal.TerminalRasterImage,System.Threading.CancellationToken)' +) + +$archive = [System.IO.Compression.ZipFile]::OpenRead($packagePath) +try { + foreach ($framework in @('net8.0', 'net9.0', 'net10.0')) { + $entryPath = "lib/$framework/Icod.Terminal.xml" + $entry = $archive.GetEntry($entryPath) + if ($null -eq $entry) { + throw "Package is missing generated documentation '$entryPath'." + } + + $stream = $entry.Open() + try { + $documentation = [System.Xml.XmlDocument]::new() + $documentation.Load($stream) + } finally { + $stream.Dispose() + } + + $documentedMembers = @( + $documentation.SelectNodes('/doc/members/member') | + ForEach-Object { $_.GetAttribute('name') } + ) + foreach ($requiredMember in $requiredMembers) { + if ($requiredMember -notin $documentedMembers) { + throw "$entryPath is missing required persistent-raster documentation '$requiredMember'." + } + } + } +} finally { + $archive.Dispose() +} + +$smokeRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("Icod.Terminal-persistent-raster-package-smoke-{0}" -f [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $smokeRoot -Force | Out-Null +try { + Copy-Item -LiteralPath (Join-Path $repositoryRoot 'tools/package-persistent-raster-smoke/Icod.Terminal.PackagePersistentRasterSmoke.csproj') -Destination (Join-Path $smokeRoot 'Icod.Terminal.PackagePersistentRasterSmoke.csproj') + Copy-Item -LiteralPath (Join-Path $repositoryRoot 'tools/package-persistent-raster-smoke/Program.cs') -Destination (Join-Path $smokeRoot 'Program.cs') + + $nugetConfig = Join-Path $smokeRoot 'NuGet.Config' + $artifactUri = [System.Security.SecurityElement]::Escape($ArtifactDirectory) + $nugetConfigText = @" + + + + + + + + +"@ + [System.IO.File]::WriteAllText($nugetConfig, $nugetConfigText, [System.Text.UTF8Encoding]::new($false)) + + $project = Join-Path $smokeRoot 'Icod.Terminal.PackagePersistentRasterSmoke.csproj' + $oldNuGetPackages = $env:NUGET_PACKAGES + $env:NUGET_PACKAGES = Join-Path $smokeRoot 'packages' + try { + Invoke-DotNet -Arguments @( + 'restore', $project, + '--no-cache', + '--configfile', $nugetConfig, + "-p:IcodTerminalPackageVersion=$ExpectedVersion" + ) + + foreach ($framework in @('net8.0', 'net9.0', 'net10.0')) { + Invoke-DotNet -Arguments @( + 'run', + '--project', $project, + '-c', $Configuration, + '-f', $framework, + '--no-restore', + "-p:IcodTerminalPackageVersion=$ExpectedVersion" + ) + } + } finally { + $env:NUGET_PACKAGES = $oldNuGetPackages + } +} finally { + if (Test-Path -LiteralPath $smokeRoot) { + Remove-Item -LiteralPath $smokeRoot -Recurse -Force + } +} + +Write-Host "1.11 persistent-raster package verification completed successfully for Icod.Terminal $ExpectedVersion ($Configuration)." diff --git a/packaging/VerifyPersistentRasterSample.ps1 b/packaging/VerifyPersistentRasterSample.ps1 new file mode 100644 index 000000000..7d6ae521f --- /dev/null +++ b/packaging/VerifyPersistentRasterSample.ps1 @@ -0,0 +1,46 @@ +param( + [ValidateSet('Debug', 'Staging', 'Release')] + [string]$Configuration = 'Staging' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force + +$project = Join-Path $repositoryRoot 'samples/Icod.Terminal.PersistentRaster.Sample/Icod.Terminal.PersistentRaster.Sample.csproj' +if (-not (Test-Path -LiteralPath $project -PathType Leaf)) { + throw "Persistent raster sample project '$project' does not exist." +} + +$programPath = Join-Path $repositoryRoot 'samples/Icod.Terminal.PersistentRaster.Sample/Program.cs' +$programText = [System.IO.File]::ReadAllText($programPath) +foreach ($forbidden in @('Kitty', 'Sixel', 'ImageId', 'ImageNumber', 'PlacementId')) { + if ($programText.Contains($forbidden, [System.StringComparison]::Ordinal)) { + throw "Persistent raster sample must remain backend-neutral; found forbidden text '$forbidden'." + } +} + +Write-Host '' +Write-Host '=== Restore persistent raster sample ===' +Invoke-DotNet -Arguments @( + 'restore', + $project +) + +foreach ($framework in @('net8.0', 'net9.0', 'net10.0')) { + Write-Host '' + Write-Host "=== Persistent raster sample build: $framework ===" + Invoke-DotNet -Arguments @( + 'build', + $project, + '-c', $Configuration, + '-f', $framework, + '--no-restore', + '-p:ContinuousIntegrationBuild=true' + ) +} + +Write-Host '' +Write-Host "Persistent raster sample verification completed successfully ($Configuration)." diff --git a/packaging/VerifyPublicApiBaseline.ps1 b/packaging/VerifyPublicApiBaseline.ps1 index 70593ab95..1dbb88d16 100644 --- a/packaging/VerifyPublicApiBaseline.ps1 +++ b/packaging/VerifyPublicApiBaseline.ps1 @@ -1,7 +1,7 @@ param( [string]$Configuration = 'Staging', [string]$OutputDirectory = 'artifacts/public-api', - [string]$BaselinePath = 'docs/Public-API-Baseline-1.10.sha256' + [string]$BaselinePath = 'docs/Public-API-Baseline-1.11.sha256' ) Set-StrictMode -Version Latest diff --git a/packaging/VerifyRuntime.ps1 b/packaging/VerifyRuntime.ps1 index 0792a6683..739e572f6 100644 --- a/packaging/VerifyRuntime.ps1 +++ b/packaging/VerifyRuntime.ps1 @@ -40,6 +40,7 @@ try { 'VerifyITerm2ShellIntegrationSample.ps1', 'VerifyVsCodeShellIntegrationSample.ps1', 'VerifyRasterGraphicsSample.ps1', + 'VerifyPersistentRasterSample.ps1', 'VerifyCapabilityPlanningSample.ps1', 'VerifyDCursesSynchronizedOutput.ps1', 'VerifyDCursesProgress.ps1', diff --git a/samples/Icod.Terminal.PersistentRaster.Sample/Icod.Terminal.PersistentRaster.Sample.csproj b/samples/Icod.Terminal.PersistentRaster.Sample/Icod.Terminal.PersistentRaster.Sample.csproj new file mode 100644 index 000000000..27885f22a --- /dev/null +++ b/samples/Icod.Terminal.PersistentRaster.Sample/Icod.Terminal.PersistentRaster.Sample.csproj @@ -0,0 +1,43 @@ + + + + + + + Exe + net8.0;net9.0;net10.0 + 13.0 + enable + enable + false + Icod.Terminal.PersistentRaster.Sample + Icod.Terminal.PersistentRaster.Sample + false + Debug;Staging;Release + + + + + + + + + + diff --git a/samples/Icod.Terminal.PersistentRaster.Sample/Program.cs b/samples/Icod.Terminal.PersistentRaster.Sample/Program.cs new file mode 100644 index 000000000..b734d0d53 --- /dev/null +++ b/samples/Icod.Terminal.PersistentRaster.Sample/Program.cs @@ -0,0 +1,157 @@ +/* + Icod.Terminal.PersistentRaster.Sample + Sample application demonstrating Icod.Terminal PersistentRaster features. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +using Icod.Terminal; + +const int width = 48; +const int height = 24; + +byte[] pixels = new byte[ width * height * 3 ]; +for ( int row = 0; row < height; ++row ) { + for ( int column = 0; column < width; ++column ) { + int offset = ( ( row * width ) + column ) * 3; + pixels[ offset ] = ScaleChannel( column, width - 1 ); + pixels[ offset + 1 ] = ScaleChannel( row, height - 1 ); + pixels[ offset + 2 ] = ScaleChannel( + column + row, + width + height - 2 + ); + } +} + +TerminalRasterImage image = TerminalRasterImage.CreateRgb24( + width, + height, + pixels +); + +await using TerminalSession session = await TerminalSession.OpenAsync( + new TerminalSessionOptions { + InputMode = TerminalInputMode.CBreak, + EchoInput = false + } +); + +await session.WriteTextAsync( + "Icod.Terminal persistent backend-neutral raster sample.\r\n" +); +await session.WriteTextAsync( + "The sample verifies the semantic capability without selecting a terminal brand or graphics backend.\r\n" +); + +TerminalCapabilityStatus capability = await session.VerifyCapabilityAsync( + TerminalCapability.PersistentRasterGraphics +); +if ( !capability.IsUsable ) { + await session.WriteTextAsync( + string.Concat( + "Persistent raster graphics are not currently usable: ", + capability.Support.ToString(), + ".\r\n" + ) + ); + return 1; +} + +TerminalControlResult resourceResult = + await session.CreateRasterResourceAsync( image ); +if ( TerminalControlStatus.Available != resourceResult.Status + || resourceResult.Value is null ) { + await session.WriteTextAsync( + FormatFailure( + "Persistent raster resource creation", + resourceResult.Status, + resourceResult.Message + ) + ); + return 1; +} +await using TerminalRasterResource resource = resourceResult.Value; + +TerminalControlResult placementResult = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = 24 + } + ); +if ( TerminalControlStatus.Available != placementResult.Status + || placementResult.Value is null ) { + await session.WriteTextAsync( + FormatFailure( + "Persistent raster placement creation", + placementResult.Status, + placementResult.Message + ) + ); + return 1; +} +await using TerminalRasterPlacement placement = placementResult.Value; + +await session.WriteTextAsync( + "\r\nThe terminal-resident resource remains owned while its placement is resized below.\r\n" +); +TerminalControlMutationResult update = await placement.UpdateAsync( + new TerminalRasterPlacementOptions { + Columns = 16 + } +); +if ( !update.Succeeded ) { + await session.WriteTextAsync( + FormatFailure( + "Persistent raster placement update", + update.Status, + update.Message + ) + ); + return 1; +} + +await session.WriteTextAsync( + "\r\nPersistent placement update completed; disposal will release placement and resource ownership.\r\n" +); +return 0; + +static string FormatFailure( + string operation, + TerminalControlStatus status, + string? message +) { + ArgumentException.ThrowIfNullOrWhiteSpace( operation ); + return string.Concat( + operation, + " was not completed: ", + status.ToString(), + string.IsNullOrEmpty( message ) + ? string.Empty + : string.Concat( " — ", message ), + "\r\n" + ); +} + +static byte ScaleChannel( + int value, + int maximum +) { + if ( 0 >= maximum ) { + return 0; + } + + return (byte)( ( value * byte.MaxValue ) / maximum ); +} diff --git a/samples/README.md b/samples/README.md index 01dd3c160..fc0652803 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,6 +1,6 @@ # Icod.Terminal Samples -The sample projects are small, focused repository consumers built through project references. They demonstrate the supported 1.x usage model; package-only compatibility is validated separately by the consumers under `tools/`, which restore only the freshly packed NuGet artifact. +The sample projects are small, focused repository consumers built through project references. They demonstrate the supported 1.x usage model. Fresh package-only compatibility is validated separately by consumers under `tools/`. All samples target `net8.0`, `net9.0`, and `net10.0`. @@ -8,16 +8,17 @@ All samples target `net8.0`, `net9.0`, and `net10.0`. | Goal | Sample | | --- | --- | -| Open a session, inspect identity/endpoints, and read an event | `Icod.Terminal.Sample` | -| Inspect rich input plus lifecycle and unsolicited semantic events | `Icod.Terminal.RichInput.Sample` | -| Run bounded terminal queries and verify coexistence with the unified event stream | `Icod.Terminal.Query.Sample` | -| Plan behavior from semantic capability knowledge without protocol branching | `Icod.Terminal.CapabilityPlanning.Sample` | +| Open a session and read an event | `Icod.Terminal.Sample` | +| Inspect rich input, lifecycle, and semantic events | `Icod.Terminal.RichInput.Sample` | +| Run bounded terminal queries | `Icod.Terminal.Query.Sample` | +| Plan from semantic capability knowledge | `Icod.Terminal.CapabilityPlanning.Sample` | | Observe or temporarily own terminal colors | `Icod.Terminal.Color.Sample` | -| Display a backend-neutral raster | `Icod.Terminal.RasterGraphics.Sample` | -| Own cursor style, synchronized output, progress, or pointer shape | focused state samples below | -| Publish title/location/prompt/shell metadata | focused metadata samples below | +| Display a backend-neutral ephemeral raster | `Icod.Terminal.RasterGraphics.Sample` | +| Create/update/dispose terminal-resident raster ownership | `Icod.Terminal.PersistentRaster.Sample` | +| Own cursor style, synchronized output, progress, or pointer shape | focused state samples | +| Publish title/location/prompt/shell metadata | focused metadata samples | | Emit notifications and observe interactive semantic events | `Icod.Terminal.Notification.Sample` | -| Emit hyperlinks or clipboard operations | focused output samples below | +| Emit hyperlinks or clipboard operations | focused output samples | ## Sample rules @@ -28,14 +29,16 @@ The examples follow the permanent 1.x contracts: - `TerminalSession.Output` is an advanced borrowed transport, not the normal application-output path; - scoped terminal state uses `await using` / `DisposeAsync()` for deterministic cleanup; - exact restoration is claimed only when the library first observed or captured a truthful baseline; -- metadata publication is explicit because paths, user/host identities, shell metadata, clipboard contents, notifications, command lines, and hyperlinks may disclose information outside the application; -- event-loop samples handle the event kinds they understand explicitly and remain nonfatal when a newer compatible minor release introduces an unfamiliar outer `TerminalEventKind` value. +- persistent raster identities are opaque and generation-scoped rather than exactly restorable state; +- persistent samples do not branch on Kitty/Sixel/backend ids and do not teach hidden replay; +- metadata publication is explicit because paths, user/host identities, shell metadata, clipboard contents, notifications, command lines, hyperlinks, and raster content may disclose information outside the application; +- event-loop samples remain nonfatal when a later compatible 1.x release introduces an unfamiliar outer event kind. ## Start here ### `Icod.Terminal.Sample` -Minimal session example covering identity selection, endpoint observations, application text, a timed event read, and disposal-driven restoration of session-owned terminal state. +Minimal session construction, endpoint/identity observation, application text, one timed event read, and disposal-driven restoration. ```text dotnet run --project samples/Icod.Terminal.Sample/Icod.Terminal.Sample.csproj -f net10.0 @@ -43,7 +46,7 @@ dotnet run --project samples/Icod.Terminal.Sample/Icod.Terminal.Sample.csproj -f ### `Icod.Terminal.RichInput.Sample` -Interactive inspector for text, keys, bracketed paste, focus, mouse, lifecycle, unsolicited semantic events, and negotiated modern keyboard reporting. Traditional keyboard decoding remains the compatibility fallback. The outer event switch also demonstrates a forward-compatible fallback for event kinds introduced by later compatible 1.x releases. +Interactive inspector for text, keys, bracketed paste, focus, mouse, lifecycle, unsolicited semantic events, and negotiated modern keyboard reporting. ```text dotnet run --project samples/Icod.Terminal.RichInput.Sample/Icod.Terminal.RichInput.Sample.csproj -f net10.0 @@ -51,7 +54,7 @@ dotnet run --project samples/Icod.Terminal.RichInput.Sample/Icod.Terminal.RichIn ### `Icod.Terminal.Query.Sample` -Demonstrates explicit bounded Primary/Secondary DA, DSR, CPR, DECRQSS, and XTGETTCAP queries through the session's single response-correlation path, then reads one event from the same authoritative session stream to demonstrate coexistence with ordinary input, lifecycle, and semantic events. +Demonstrates explicit bounded terminal queries through the same authoritative session stream used for application input and semantic/lifecycle events. ```text dotnet run --project samples/Icod.Terminal.Query.Sample/Icod.Terminal.Query.Sample.csproj -f net10.0 @@ -61,39 +64,54 @@ Timeout is not treated as proof that a terminal lacks support. ### `Icod.Terminal.CapabilityPlanning.Sample` -Demonstrates the 1.10 semantic capability-planning surface without terminal-brand, protocol-family, backend, or dependency-specific branching. - -By default the sample performs only side-effect-free inspection: +Demonstrates protocol-neutral capability inspection and optional explicit verification. ```text dotnet run --project samples/Icod.Terminal.CapabilityPlanning.Sample/Icod.Terminal.CapabilityPlanning.Sample.csproj -f net10.0 -``` - -Pass `--verify` to explicitly request bounded verification for the semantic capabilities which currently have reviewed probes: -```text dotnet run --project samples/Icod.Terminal.CapabilityPlanning.Sample/Icod.Terminal.CapabilityPlanning.Sample.csproj -f net10.0 -- --verify ``` -The sample reports support, endpoint availability, evidence kind, and current usability, then chooses a raster/non-raster presentation plan using only `TerminalCapabilityStatus.IsUsable`. It does not ask whether Kitty or Sixel won, inspect `TERM`, branch on emulator identity, or reference `Icod.TermInfo` directly. - -`packaging/VerifyCapabilityPlanningSample.ps1` builds this sample on every supported TFM during repository validation. +The sample does not inspect terminal brand, `TERM`, protocol family, backend identity, or `Icod.TermInfo` provenance. ## Raster graphics ### `Icod.Terminal.RasterGraphics.Sample` -Generates a small RGB24 gradient in memory and passes it to the public backend-neutral `DisplayRasterAsync(...)` operation. +Generates a small RGB24 gradient in memory and sends it through the backend-neutral ephemeral `DisplayRasterAsync(...)` API. ```text dotnet run --project samples/Icod.Terminal.RasterGraphics.Sample/Icod.Terminal.RasterGraphics.Sample.csproj -f net10.0 ``` -The sample does not select Sixel or Kitty Graphics, emit raw DCS/APC traffic, inspect terminal branding, load image files, or add an image-decoder dependency. The normal evidence-driven router may use verified Kitty Graphics or verified Sixel internally. +The normal evidence-driven router may use verified Kitty Graphics or verified Sixel internally. The sample performs no backend selection and emits no raw DCS/APC traffic. + +`packaging/VerifyRasterGraphicsSample.ps1` builds the sample on every supported TFM. + +### `Icod.Terminal.PersistentRaster.Sample` + +Demonstrates the 1.11 persistent-raster ownership model using semantic APIs only. + +```text +dotnet run --project samples/Icod.Terminal.PersistentRaster.Sample/Icod.Terminal.PersistentRaster.Sample.csproj -f net10.0 +``` + +The sample: -`packaging/VerifyRasterGraphicsSample.ps1` builds this sample on every supported TFM during repository validation. +1. explicitly verifies `TerminalCapability.PersistentRasterGraphics`; +2. creates a `TerminalRasterImage` in memory; +3. creates an opaque `TerminalRasterResource`; +4. creates a placement with a cell-column extent; +5. updates the same placement at the current cursor; +6. uses `await using` so placement/resource cleanup is deterministic. -## Queries and reversible state +It does not mention Kitty, Sixel, image ids, image numbers, placement ids, or terminal brand. It also does not imply that resources are replayed after lifecycle invalidation. + +`packaging/VerifyPersistentRasterSample.ps1` enforces those backend-neutral source rules and builds the sample on every supported TFM. + +See `docs/Persistent-Raster-Ownership.md` for the permanent ownership contract. + +## Reversible state and color ### `Icod.Terminal.Color.Sample` @@ -107,7 +125,7 @@ dotnet run --project samples/Icod.Terminal.Color.Sample/Icod.Terminal.Color.Samp ### `Icod.Terminal.CursorStyle.Sample` -Typed DECSCUSR cursor-style observation, mutation, and scoped restoration. +Typed cursor-style observation, mutation, and scoped restoration. ```text dotnet run --project samples/Icod.Terminal.CursorStyle.Sample/Icod.Terminal.CursorStyle.Sample.csproj -f net10.0 -- SteadyUnderline @@ -131,7 +149,7 @@ dotnet run --project samples/Icod.Terminal.Progress.Sample/Icod.Terminal.Progres ### `Icod.Terminal.PointerShape.Sample` -OSC 22 pointer-shape mutation, scoped ownership, nested fallback, terminal-policy reset, and bounded pointer queries. +Pointer-shape mutation, scoped ownership, reset, and bounded pointer queries. ```text dotnet run --project samples/Icod.Terminal.PointerShape.Sample/Icod.Terminal.PointerShape.Sample.csproj -f net10.0 @@ -141,7 +159,7 @@ dotnet run --project samples/Icod.Terminal.PointerShape.Sample/Icod.Terminal.Poi ### `Icod.Terminal.Title.Sample` -Semantic OSC 0/1/2 icon/window title operations. +Semantic icon/window title operations. ```text dotnet run --project samples/Icod.Terminal.Title.Sample/Icod.Terminal.Title.Sample.csproj -f net10.0 @@ -149,96 +167,58 @@ dotnet run --project samples/Icod.Terminal.Title.Sample/Icod.Terminal.Title.Samp ### `Icod.Terminal.Location.Sample` -Portable OSC 7 current-location publication plus the explicit Windows Terminal/ConEmu OSC 9;9 compatibility form. - -```text -dotnet run --project samples/Icod.Terminal.Location.Sample/Icod.Terminal.Location.Sample.csproj -f net10.0 -- posix /usr/local/src - -dotnet run --project samples/Icod.Terminal.Location.Sample/Icod.Terminal.Location.Sample.csproj -f net10.0 -- windows-osc9 C:\work\repo -``` - -OSC 7 remains the preferred portable location API. The sample does not inspect the process current directory or automatically emit multiple vendor protocols. +Portable current-location publication plus the explicit Windows compatibility form. ### `Icod.Terminal.SemanticPrompt.Sample` -Portable typed OSC 133 prompt/command-region metadata. - -```text -dotnet run --project samples/Icod.Terminal.SemanticPrompt.Sample/Icod.Terminal.SemanticPrompt.Sample.csproj -f net10.0 -``` +Portable typed prompt/command-region metadata. ### `Icod.Terminal.VsCodeShellIntegration.Sample` -Typed VS Code OSC 633 rich-command metadata, current directory, prompt/input/output boundaries, explicit command-line publication, and command completion. - -```text -dotnet run --project samples/Icod.Terminal.VsCodeShellIntegration.Sample/Icod.Terminal.VsCodeShellIntegration.Sample.csproj -f net10.0 -- /srv/repo "dotnet test" optional-nonce -``` - -All potentially sensitive metadata is supplied explicitly. The sample does not inspect process arguments, environment variables, shell history, or the process current directory. OSC 133 and OSC 7 remain the preferred portable semantic APIs where applicable. - -`packaging/VerifyVsCodeShellIntegrationSample.ps1` builds this sample on every supported TFM during repository validation. +Typed VS Code shell-integration metadata. Potentially sensitive metadata is supplied explicitly. ### `Icod.Terminal.ITerm2ShellIntegration.Sample` -Typed iTerm2 OSC 1337 shell-integration and semantic-history metadata. - -```text -dotnet run --project samples/Icod.Terminal.ITerm2ShellIntegration.Sample/Icod.Terminal.ITerm2ShellIntegration.Sample.csproj -f net10.0 -- /srv/repo alice host.example.test bash 20 branch main -``` - -Add `--clear-captured-output` only when intentionally demonstrating the destructive clear operation. User-variable Base64 is protocol framing, not confidentiality. +Typed iTerm2 shell-integration and semantic-history metadata. User-variable Base64 is protocol framing, not confidentiality. ## Notifications and interactive output ### `Icod.Terminal.Notification.Sample` -Demonstrates explicit OSC 9, OSC 777, and Kitty OSC 99 desktop-notification surfaces without terminal-brand routing. The interactive mode also demonstrates opt-in activation/button and close reporting through the same public `TerminalSession.ReadEventAsync(...)` event stream used for ordinary terminal input. +Demonstrates desktop-notification output plus explicit opt-in interaction reporting through the unified event stream. ```text dotnet run --project samples/Icod.Terminal.Notification.Sample/Icod.Terminal.Notification.Sample.csproj -f net10.0 -- "Build complete" - -dotnet run --project samples/Icod.Terminal.Notification.Sample/Icod.Terminal.Notification.Sample.csproj -f net10.0 -- --titled "Build" "Compilation complete" - -dotnet run --project samples/Icod.Terminal.Notification.Sample/Icod.Terminal.Notification.Sample.csproj -f net10.0 -- --kitty "Build" "Compilation complete" - -dotnet run --project samples/Icod.Terminal.Notification.Sample/Icod.Terminal.Notification.Sample.csproj -f net10.0 -- --kitty-interactive build-42 "Build" "Compilation complete" ``` -Interactive mode uses the caller-supplied identifier, requests activation/button and close reports, adds fixed `Acknowledge` and `Dismiss` sample buttons, and waits up to 30 seconds for a matching typed notification event. The event identifier and button number are validated but unauthenticated terminal-controlled input. A timeout does not prove that the terminal lacks support. - -Successful emission does not prove that the desktop displayed a notification. Notification text may be retained by the terminal or operating environment. +Notification identifiers and button reports are validated but unauthenticated terminal-controlled input. ### `Icod.Terminal.Hyperlink.Sample` -Bounded OSC 8 hyperlink output and scoped hyperlink ownership. - -```text -dotnet run --project samples/Icod.Terminal.Hyperlink.Sample/Icod.Terminal.Hyperlink.Sample.csproj -f net10.0 -- https://example.com/ "example link" example-1 -``` +Bounded hyperlink output and scoped hyperlink ownership. ### `Icod.Terminal.Clipboard.Sample` -Explicit OSC 52 clipboard/selection writes and privacy-sensitive reads. - -```text -dotnet run --project samples/Icod.Terminal.Clipboard.Sample/Icod.Terminal.Clipboard.Sample.csproj -f net10.0 -- "copied text" -``` - -Clipboard reads are never automatic. Terminal-side policy may ignore or deny them, and a timeout is not permanent unsupported evidence. +Explicit clipboard/selection writes and privacy-sensitive reads. Clipboard reads are never automatic. ## Choosing a sample -For a general terminal-aware application, a useful progression is: +For a general terminal-aware application: ```text Icod.Terminal.Sample -> Icod.Terminal.RichInput.Sample -> Icod.Terminal.CapabilityPlanning.Sample -> Icod.Terminal.Query.Sample - -> one focused state/output sample relevant to the application + -> one focused feature sample ``` -Applications interested in raster output can go directly from the basic session sample to `Icod.Terminal.CapabilityPlanning.Sample` and then `Icod.Terminal.RasterGraphics.Sample`. Shell integrations should prefer portable semantic APIs first, then use vendor-specific samples only when intentionally targeting those protocols. +For graphics: + +```text +Icod.Terminal.CapabilityPlanning.Sample + -> Icod.Terminal.RasterGraphics.Sample (ephemeral display) + -> Icod.Terminal.PersistentRaster.Sample (terminal-resident ownership) +``` -Higher-level full-screen applications normally consume these contracts through `Icod.DCurses` rather than reimplementing cells, windows, or refresh policy directly. +Higher-level full-screen applications normally consume these contracts through `Icod.DCurses` rather than reimplementing cells, windows, layout, or refresh policy directly. diff --git a/src/Graphics/KittyGraphicsPersistentCreationResponse.cs b/src/Graphics/KittyGraphicsPersistentCreationResponse.cs new file mode 100644 index 000000000..56346e0da --- /dev/null +++ b/src/Graphics/KittyGraphicsPersistentCreationResponse.cs @@ -0,0 +1,292 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +using System.Text; + +/// +/// Parses one bounded persistent-resource creation acknowledgement correlated by image number. +/// +internal sealed class KittyGraphicsPersistentCreationResponse { + private KittyGraphicsPersistentCreationResponse( + uint imageNumber, + uint? imageId, + bool isSuccess, + string message + ) { + this.ImageNumber = imageNumber; + this.ImageId = imageId; + this.IsSuccess = isSuccess; + this.Message = message; + } + + internal uint ImageNumber { + get; + } + + internal uint? ImageId { + get; + } + + internal bool IsSuccess { + get; + } + + internal bool IsUnavailable { + get { + return !this.IsSuccess + && this.Message.StartsWith( + "ENOENT", + StringComparison.Ordinal + ); + } + } + + internal string Message { + get; + } + + internal static KittyGraphicsPersistentCreationResponse Parse( + TerminalResponseFrame frame, + uint expectedImageNumber + ) { + ArgumentNullException.ThrowIfNull( frame ); + if ( 0u == expectedImageNumber ) { + throw new ArgumentOutOfRangeException( nameof( expectedImageNumber ) ); + } + if ( TerminalResponseFrameKind.Apc != frame.Kind ) { + throw new FormatException( + "The persistent Kitty Graphics response is not an APC frame." + ); + } + + TerminalControlFrameStructure structure = TerminalControlFrameStructure.Parse( frame ); + if ( TerminalControlFamily.Apc != structure.Family ) { + throw new FormatException( + "The persistent Kitty Graphics response is not an APC control string." + ); + } + + ReadOnlySpan payload = structure.PayloadBytes.Span; + if ( 0 == payload.Length || (byte)'G' != payload[ 0 ] ) { + throw new FormatException( + "The APC response is not a Kitty Graphics response." + ); + } + + int separator = payload.IndexOf( (byte)';' ); + if ( 1 >= separator ) { + throw new FormatException( + "The persistent Kitty Graphics response is missing control data or its response separator." + ); + } + + ReadOnlySpan controlData = payload[1..separator]; + ReadOnlySpan messageBytes = payload[( separator + 1 )..]; + if ( KittyGraphicsCodec.MaximumControlDataBytes < controlData.Length ) { + throw new FormatException( + $"The Kitty Graphics response control data exceeds {KittyGraphicsCodec.MaximumControlDataBytes} bytes." + ); + } + if ( 0 == messageBytes.Length ) { + throw new FormatException( + "The Kitty Graphics response message cannot be empty." + ); + } + if ( KittyGraphicsCodec.MaximumResponseMessageBytes < messageBytes.Length ) { + throw new FormatException( + $"The Kitty Graphics response message exceeds {KittyGraphicsCodec.MaximumResponseMessageBytes} bytes." + ); + } + + ParseControlData( + controlData, + out uint? imageId, + out uint? imageNumber + ); + if ( !imageNumber.HasValue ) { + throw new FormatException( + "A persistent Kitty Graphics creation response must contain the correlated image number." + ); + } + if ( expectedImageNumber != imageNumber.Value ) { + throw new FormatException( + "The persistent Kitty Graphics creation response contains the wrong image number." + ); + } + + string message = ParseMessage( messageBytes ); + bool success = string.Equals( + "OK", + message, + StringComparison.Ordinal + ); + if ( success && !imageId.HasValue ) { + throw new FormatException( + "A successful persistent Kitty Graphics creation response must contain a terminal-assigned image id." + ); + } + + return new KittyGraphicsPersistentCreationResponse( + imageNumber.Value, + imageId, + success, + message + ); + } + + private static void ParseControlData( + ReadOnlySpan controlData, + out uint? imageId, + out uint? imageNumber + ) { + imageId = null; + imageNumber = null; + Span seenKeys = stackalloc bool[ 128 ]; + + int offset = 0; + while ( offset < controlData.Length ) { + ReadOnlySpan remaining = controlData[offset..]; + int comma = remaining.IndexOf( (byte)',' ); + ReadOnlySpan field = 0 > comma + ? remaining + : remaining[..comma] + ; + if ( 3 > field.Length + || !IsAsciiLetter( field[ 0 ] ) + || (byte)'=' != field[ 1 ] ) { + throw new FormatException( + "The persistent Kitty Graphics response control data contains a malformed key/value field." + ); + } + + byte key = field[ 0 ]; + if ( seenKeys[ key ] ) { + throw new FormatException( + "The persistent Kitty Graphics response control data contains a duplicate key." + ); + } + seenKeys[ key ] = true; + + ReadOnlySpan value = field[2..]; + ValidateControlValue( value ); + switch ( key ) { + case (byte)'i': + uint parsedImageId = ParseUInt32( + value, + "i" + ); + if ( 0u == parsedImageId ) { + throw new FormatException( + "A terminal-assigned persistent Kitty Graphics image id must be non-zero." + ); + } + imageId = parsedImageId; + break; + + case (byte)'I': + uint parsedImageNumber = ParseUInt32( + value, + "I" + ); + if ( 0u == parsedImageNumber ) { + throw new FormatException( + "A persistent Kitty Graphics image number must be non-zero." + ); + } + imageNumber = parsedImageNumber; + break; + } + + if ( 0 > comma ) { + break; + } + offset = checked( offset + comma + 1 ); + if ( controlData.Length == offset ) { + throw new FormatException( + "The persistent Kitty Graphics response control data contains an empty field." + ); + } + } + } + + private static void ValidateControlValue( + ReadOnlySpan value + ) { + if ( value.IsEmpty ) { + throw new FormatException( + "A persistent Kitty Graphics response control value cannot be empty." + ); + } + foreach ( byte item in value ) { + if ( item is < 0x20 or > 0x7E + || (byte)',' == item ) { + throw new FormatException( + "A persistent Kitty Graphics response control value must contain printable ASCII without field separators." + ); + } + } + } + + private static uint ParseUInt32( + ReadOnlySpan value, + string key + ) { + ArgumentException.ThrowIfNullOrEmpty( key ); + + uint parsed = 0; + foreach ( byte item in value ) { + if ( item is < (byte)'0' or > (byte)'9' ) { + throw new FormatException( + $"The persistent Kitty Graphics response key '{key}' must contain an unsigned decimal integer." + ); + } + uint digit = (uint)( item - (byte)'0' ); + if ( ( uint.MaxValue - digit ) / 10 < parsed ) { + throw new FormatException( + $"The persistent Kitty Graphics response key '{key}' exceeds UInt32.MaxValue." + ); + } + parsed = ( parsed * 10 ) + digit; + } + return parsed; + } + + private static string ParseMessage( + ReadOnlySpan messageBytes + ) { + foreach ( byte item in messageBytes ) { + if ( item is < 0x20 or > 0x7E ) { + throw new FormatException( + "The persistent Kitty Graphics response message must contain printable ASCII." + ); + } + } + return Encoding.ASCII.GetString( messageBytes ); + } + + private static bool IsAsciiLetter( + byte value + ) { + return value is >= (byte)'A' and <= (byte)'Z' + or >= (byte)'a' and <= (byte)'z'; + } +} diff --git a/src/Graphics/KittyGraphicsPersistentEncoder.cs b/src/Graphics/KittyGraphicsPersistentEncoder.cs new file mode 100644 index 000000000..77159273b --- /dev/null +++ b/src/Graphics/KittyGraphicsPersistentEncoder.cs @@ -0,0 +1,258 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +using System.Buffers; +using System.Buffers.Text; +using System.Globalization; +using System.Text; + +/// +/// Encodes the bounded Kitty Graphics control payloads used by persistent +/// raster resource and placement ownership. +/// +internal static class KittyGraphicsPersistentEncoder { + internal static IEnumerable> EncodeUploadPayloads( + KittyRasterData raster, + uint imageNumber + ) { + ArgumentNullException.ThrowIfNull( raster ); + ValidateNonZeroIdentity( + imageNumber, + nameof( imageNumber ), + "A persistent Kitty Graphics image number must be non-zero." + ); + return EncodeUploadPayloadsCore( + raster, + imageNumber + ); + } + + internal static ReadOnlyMemory EncodePlacementPayload( + uint imageId, + uint placementId, + int? columns, + int? rows + ) { + ValidateNonZeroIdentity( + imageId, + nameof( imageId ), + "A persistent Kitty Graphics image id must be non-zero." + ); + ValidateNonZeroIdentity( + placementId, + nameof( placementId ), + "A persistent Kitty Graphics placement id must be non-zero." + ); + ValidatePlacementExtent( + columns, + nameof( columns ) + ); + ValidatePlacementExtent( + rows, + nameof( rows ) + ); + + StringBuilder value = new(); + _ = value.Append( "Ga=p,i=" ); + _ = value.Append( imageId.ToString( CultureInfo.InvariantCulture ) ); + _ = value.Append( ",p=" ); + _ = value.Append( placementId.ToString( CultureInfo.InvariantCulture ) ); + _ = value.Append( ",C=1" ); + if ( columns.HasValue ) { + _ = value.Append( ",c=" ); + _ = value.Append( columns.Value.ToString( CultureInfo.InvariantCulture ) ); + } + if ( rows.HasValue ) { + _ = value.Append( ",r=" ); + _ = value.Append( rows.Value.ToString( CultureInfo.InvariantCulture ) ); + } + return Encoding.ASCII.GetBytes( value.ToString() ); + } + + internal static ReadOnlyMemory EncodeDeletePlacementPayload( + uint imageId, + uint placementId + ) { + ValidateNonZeroIdentity( + imageId, + nameof( imageId ), + "A persistent Kitty Graphics image id must be non-zero." + ); + ValidateNonZeroIdentity( + placementId, + nameof( placementId ), + "A persistent Kitty Graphics placement id must be non-zero." + ); + + return Encoding.ASCII.GetBytes( + "Ga=d,d=i,i=" + + imageId.ToString( CultureInfo.InvariantCulture ) + + ",p=" + + placementId.ToString( CultureInfo.InvariantCulture ) + + ",q=2" + ); + } + + internal static ReadOnlyMemory EncodeDeleteResourcePayload( + uint imageId + ) { + ValidateNonZeroIdentity( + imageId, + nameof( imageId ), + "A persistent Kitty Graphics image id must be non-zero." + ); + + return Encoding.ASCII.GetBytes( + "Ga=d,d=I,i=" + + imageId.ToString( CultureInfo.InvariantCulture ) + + ",q=2" + ); + } + + private static IEnumerable> EncodeUploadPayloadsCore( + KittyRasterData raster, + uint imageNumber + ) { + ReadOnlyMemory bytes = raster.Bytes; + int offset = 0; + bool first = true; + while ( offset < bytes.Length ) { + int remaining = bytes.Length - offset; + int rawCount = Math.Min( + KittyGraphicsDirectEncoder.MaximumRawChunkBytes, + remaining + ); + bool isFinal = rawCount == remaining; + byte[] controlData = Encoding.ASCII.GetBytes( + first + ? CreateFirstUploadControlData( + raster, + imageNumber, + isFinal + ) + : CreateContinuationControlData( isFinal ) + ); + int encodedCount = checked( ( ( rawCount + 2 ) / 3 ) * 4 ); + if ( KittyGraphicsDirectEncoder.MaximumEncodedPayloadBytes < encodedCount ) { + throw new InvalidOperationException( + "The persistent Kitty Graphics Base64 chunk exceeded the reviewed payload ceiling." + ); + } + + byte[] payload = new byte[ + checked( controlData.Length + encodedCount ) + ]; + controlData.CopyTo( + payload, + 0 + ); + OperationStatus status = Base64.EncodeToUtf8( + bytes.Span.Slice( + offset, + rawCount + ), + payload.AsSpan( controlData.Length ), + out int consumed, + out int written, + isFinalBlock: true + ); + if ( OperationStatus.Done != status + || rawCount != consumed + || encodedCount != written ) { + throw new InvalidOperationException( + "The persistent Kitty Graphics Base64 encoder did not consume and encode the complete bounded chunk." + ); + } + + yield return payload; + offset = checked( offset + rawCount ); + first = false; + } + } + + private static string CreateFirstUploadControlData( + KittyRasterData raster, + uint imageNumber, + bool isFinal + ) { + ArgumentNullException.ThrowIfNull( raster ); + ValidateNonZeroIdentity( + imageNumber, + nameof( imageNumber ), + "A persistent Kitty Graphics image number must be non-zero." + ); + + return "Ga=t,f=" + + ( (int)raster.PixelFormat ).ToString( CultureInfo.InvariantCulture ) + + ",s=" + + raster.Width.ToString( CultureInfo.InvariantCulture ) + + ",v=" + + raster.Height.ToString( CultureInfo.InvariantCulture ) + + ",t=d,I=" + + imageNumber.ToString( CultureInfo.InvariantCulture ) + + ",m=" + + ( isFinal ? "0" : "1" ) + + ";"; + } + + private static string CreateContinuationControlData( + bool isFinal + ) { + return isFinal + ? "Gm=0;" + : "Gm=1;" + ; + } + + private static void ValidatePlacementExtent( + int? value, + string parameterName + ) { + ArgumentException.ThrowIfNullOrEmpty( parameterName ); + if ( !value.HasValue ) { + return; + } + if ( value.Value is < 1 or > TerminalRasterImage.MaximumDimension ) { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"A persistent raster placement extent must be between 1 and {TerminalRasterImage.MaximumDimension}." + ); + } + } + + private static void ValidateNonZeroIdentity( + uint value, + string parameterName, + string message + ) { + ArgumentException.ThrowIfNullOrEmpty( parameterName ); + ArgumentException.ThrowIfNullOrEmpty( message ); + if ( 0 == value ) { + throw new ArgumentOutOfRangeException( + parameterName, + value, + message + ); + } + } +} diff --git a/src/Graphics/KittyGraphicsPersistentPlacementResponse.cs b/src/Graphics/KittyGraphicsPersistentPlacementResponse.cs new file mode 100644 index 000000000..abdd20f8a --- /dev/null +++ b/src/Graphics/KittyGraphicsPersistentPlacementResponse.cs @@ -0,0 +1,103 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Parses one bounded persistent Kitty Graphics placement acknowledgement correlated by +/// terminal image and placement identity. +/// +internal sealed class KittyGraphicsPersistentPlacementResponse { + private KittyGraphicsPersistentPlacementResponse( + uint imageId, + uint placementId, + bool isSuccess, + string message + ) { + this.ImageId = imageId; + this.PlacementId = placementId; + this.IsSuccess = isSuccess; + this.Message = message; + } + + internal uint ImageId { + get; + } + + internal uint PlacementId { + get; + } + + internal bool IsSuccess { + get; + } + + internal bool IsUnavailable { + get { + return !this.IsSuccess + && this.Message.StartsWith( + "ENOENT", + StringComparison.Ordinal + ); + } + } + + internal string Message { + get; + } + + internal static KittyGraphicsPersistentPlacementResponse Parse( + TerminalResponseFrame frame, + uint expectedImageId, + uint expectedPlacementId + ) { + ArgumentNullException.ThrowIfNull( frame ); + if ( 0u == expectedImageId ) { + throw new ArgumentOutOfRangeException( nameof( expectedImageId ) ); + } + if ( 0u == expectedPlacementId ) { + throw new ArgumentOutOfRangeException( nameof( expectedPlacementId ) ); + } + + KittyGraphicsResponse response = KittyGraphicsCodec.ParseResponse( frame ); + if ( expectedImageId != response.ImageId ) { + throw new FormatException( + "The persistent Kitty Graphics placement response contains the wrong image id." + ); + } + if ( !response.PlacementId.HasValue ) { + throw new FormatException( + "A persistent Kitty Graphics placement response must contain the correlated placement id." + ); + } + if ( expectedPlacementId != response.PlacementId.Value ) { + throw new FormatException( + "The persistent Kitty Graphics placement response contains the wrong placement id." + ); + } + + return new KittyGraphicsPersistentPlacementResponse( + response.ImageId, + response.PlacementId.Value, + response.IsSuccess, + response.Message + ); + } +} diff --git a/src/Graphics/KittyGraphicsPersistentPlacementResponseMatcher.cs b/src/Graphics/KittyGraphicsPersistentPlacementResponseMatcher.cs new file mode 100644 index 000000000..1b9455581 --- /dev/null +++ b/src/Graphics/KittyGraphicsPersistentPlacementResponseMatcher.cs @@ -0,0 +1,238 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Matches one persistent Kitty Graphics placement acknowledgement by the private +/// terminal image and placement identities owned by the session. +/// +internal sealed class KittyGraphicsPersistentPlacementResponseMatcher : + ITerminalResponseMatcher, + ICorrelatedTerminalResponseMatcher { + internal KittyGraphicsPersistentPlacementResponseMatcher( + uint imageId, + uint placementId + ) { + if ( 0u == imageId ) { + throw new ArgumentOutOfRangeException( nameof( imageId ) ); + } + if ( 0u == placementId ) { + throw new ArgumentOutOfRangeException( nameof( placementId ) ); + } + + this.ImageId = imageId; + this.PlacementId = placementId; + } + + internal uint ImageId { + get; + } + + internal uint PlacementId { + get; + } + + public TerminalResponseFrameKind FrameKind { + get { + return TerminalResponseFrameKind.Apc; + } + } + + public bool IsMatch( + TerminalResponseFrame frame + ) { + ArgumentNullException.ThrowIfNull( frame ); + if ( TerminalResponseFrameKind.Apc != frame.Kind ) { + return false; + } + + TerminalControlFrameStructure structure; + try { + structure = TerminalControlFrameStructure.Parse( frame ); + } catch ( FormatException ) { + return false; + } + ReadOnlySpan payload = structure.PayloadBytes.Span; + if ( TerminalControlFamily.Apc != structure.Family + || payload.IsEmpty + || (byte)'G' != payload[ 0 ] ) { + return false; + } + + ReadOnlySpan controlAndMessage = payload[1..]; + return ContainsExpectedField( + controlAndMessage, + (byte)'i', + this.ImageId + ) && ContainsExpectedField( + controlAndMessage, + (byte)'p', + this.PlacementId + ); + } + + public bool IsCorrelatedPrefix( + IReadOnlyList bytes + ) { + ArgumentNullException.ThrowIfNull( bytes ); + if ( 0 == bytes.Count ) { + return false; + } + + int payloadStart; + if ( 0x9F == bytes[ 0 ] ) { + payloadStart = 1; + } else if ( 2 <= bytes.Count + && 0x1B == bytes[ 0 ] + && (byte)'_' == bytes[ 1 ] ) { + payloadStart = 2; + } else { + return false; + } + if ( bytes.Count <= payloadStart + || (byte)'G' != bytes[ payloadStart ] ) { + return false; + } + + bool imageMatched = false; + bool placementMatched = false; + int fieldStart = payloadStart + 1; + for ( int index = fieldStart; index < bytes.Count; index++ ) { + byte value = bytes[ index ]; + if ( value is not (byte)',' and not (byte)';' ) { + continue; + } + + int fieldLength = index - fieldStart; + imageMatched |= IsExpectedField( + bytes, + fieldStart, + fieldLength, + (byte)'i', + this.ImageId + ); + placementMatched |= IsExpectedField( + bytes, + fieldStart, + fieldLength, + (byte)'p', + this.PlacementId + ); + if ( imageMatched && placementMatched ) { + return true; + } + if ( (byte)';' == value ) { + return false; + } + fieldStart = index + 1; + } + return false; + } + + private static bool ContainsExpectedField( + ReadOnlySpan controlAndMessage, + byte key, + uint expected + ) { + int fieldStart = 0; + for ( int index = 0; index < controlAndMessage.Length; index++ ) { + byte value = controlAndMessage[ index ]; + if ( value is not (byte)',' and not (byte)';' ) { + continue; + } + + if ( IsExpectedField( + controlAndMessage.Slice( + fieldStart, + index - fieldStart + ), + key, + expected + ) ) { + return true; + } + if ( (byte)';' == value ) { + return false; + } + fieldStart = index + 1; + } + return false; + } + + private static bool IsExpectedField( + ReadOnlySpan field, + byte key, + uint expected + ) { + if ( 3 > field.Length + || key != field[ 0 ] + || (byte)'=' != field[ 1 ] ) { + return false; + } + + uint parsed = 0; + for ( int index = 2; index < field.Length; index++ ) { + byte item = field[ index ]; + if ( item is < (byte)'0' or > (byte)'9' ) { + return false; + } + + uint digit = (uint)( item - (byte)'0' ); + if ( ( uint.MaxValue - digit ) / 10 < parsed ) { + return false; + } + parsed = ( parsed * 10 ) + digit; + } + return expected == parsed; + } + + private static bool IsExpectedField( + IReadOnlyList bytes, + int start, + int length, + byte key, + uint expected + ) { + ArgumentNullException.ThrowIfNull( bytes ); + if ( 3 > length + || 0 > start + || bytes.Count < start + length + || key != bytes[ start ] + || (byte)'=' != bytes[ start + 1 ] ) { + return false; + } + + uint parsed = 0; + for ( int index = start + 2; index < start + length; index++ ) { + byte item = bytes[ index ]; + if ( item is < (byte)'0' or > (byte)'9' ) { + return false; + } + + uint digit = (uint)( item - (byte)'0' ); + if ( ( uint.MaxValue - digit ) / 10 < parsed ) { + return false; + } + parsed = ( parsed * 10 ) + digit; + } + return expected == parsed; + } +} diff --git a/src/Graphics/KittyGraphicsPersistentPlacementTransaction.cs b/src/Graphics/KittyGraphicsPersistentPlacementTransaction.cs new file mode 100644 index 000000000..9e66859a1 --- /dev/null +++ b/src/Graphics/KittyGraphicsPersistentPlacementTransaction.cs @@ -0,0 +1,76 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Emits one persistent Kitty Graphics placement as a committed single-frame transaction. +/// +internal static class KittyGraphicsPersistentPlacementTransaction { + internal static async ValueTask WriteAsync( + TerminalSession session, + uint imageId, + uint placementId, + TerminalRasterPlacementOptions? options, + CancellationToken cancellationToken + ) { + ArgumentNullException.ThrowIfNull( session ); + options?.Validate(); + cancellationToken.ThrowIfCancellationRequested(); + + using IDisposable outputLease = await session.AcquireSessionOutputAsync( + cancellationToken + ).ConfigureAwait( false ); + cancellationToken.ThrowIfCancellationRequested(); + + await WriteCoreAsync( + session, + imageId, + placementId, + options + ).ConfigureAwait( false ); + } + + internal static async ValueTask WriteCoreAsync( + TerminalSession session, + uint imageId, + uint placementId, + TerminalRasterPlacementOptions? options + ) { + ArgumentNullException.ThrowIfNull( session ); + options?.Validate(); + + ReadOnlyMemory payload = KittyGraphicsPersistentEncoder.EncodePlacementPayload( + imageId, + placementId, + options?.Columns, + options?.Rows + ); + byte[] frame = ApcWriter.EncodeFrame( payload.Span ); + + await session.Output.WriteAsync( + frame, + CancellationToken.None + ).ConfigureAwait( false ); + await session.Output.FlushAsync( + CancellationToken.None + ).ConfigureAwait( false ); + } +} diff --git a/src/Graphics/KittyGraphicsPersistentResponseMatcher.cs b/src/Graphics/KittyGraphicsPersistentResponseMatcher.cs new file mode 100644 index 000000000..e1b6b5b3b --- /dev/null +++ b/src/Graphics/KittyGraphicsPersistentResponseMatcher.cs @@ -0,0 +1,232 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Matches one persistent Kitty Graphics acknowledgement by the private image +/// number supplied during resource creation. +/// +internal sealed class KittyGraphicsPersistentResponseMatcher : + ITerminalResponseMatcher, + ICorrelatedTerminalResponseMatcher { + private readonly bool validateMatchedResponse; + + internal KittyGraphicsPersistentResponseMatcher( + uint imageNumber + ) : this( + imageNumber, + validateMatchedResponse: true + ) { + } + + internal KittyGraphicsPersistentResponseMatcher( + uint imageNumber, + bool validateMatchedResponse + ) { + if ( 0 == imageNumber ) { + throw new ArgumentOutOfRangeException( + nameof( imageNumber ), + imageNumber, + "A persistent Kitty Graphics image number must be non-zero." + ); + } + this.ImageNumber = imageNumber; + this.validateMatchedResponse = validateMatchedResponse; + } + + internal uint ImageNumber { + get; + } + + public TerminalResponseFrameKind FrameKind { + get { + return TerminalResponseFrameKind.Apc; + } + } + + public bool IsMatch( + TerminalResponseFrame frame + ) { + ArgumentNullException.ThrowIfNull( frame ); + if ( TerminalResponseFrameKind.Apc != frame.Kind ) { + return false; + } + + TerminalControlFrameStructure structure; + try { + structure = TerminalControlFrameStructure.Parse( frame ); + } catch ( FormatException ) { + return false; + } + ReadOnlySpan payload = structure.PayloadBytes.Span; + if ( TerminalControlFamily.Apc != structure.Family + || payload.IsEmpty + || (byte)'G' != payload[ 0 ] ) { + return false; + } + if ( !ContainsExpectedImageNumberField( + payload[1..], + this.ImageNumber + ) ) { + return false; + } + + if ( this.validateMatchedResponse ) { + _ = KittyGraphicsPersistentCreationResponse.Parse( + frame, + this.ImageNumber + ); + } + return true; + } + + public bool IsCorrelatedPrefix( + IReadOnlyList bytes + ) { + ArgumentNullException.ThrowIfNull( bytes ); + if ( 0 == bytes.Count ) { + return false; + } + + int payloadStart; + if ( 0x9F == bytes[ 0 ] ) { + payloadStart = 1; + } else if ( 2 <= bytes.Count + && 0x1B == bytes[ 0 ] + && (byte)'_' == bytes[ 1 ] ) { + payloadStart = 2; + } else { + return false; + } + if ( bytes.Count <= payloadStart + || (byte)'G' != bytes[ payloadStart ] ) { + return false; + } + + int fieldStart = payloadStart + 1; + for ( int index = fieldStart; index < bytes.Count; index++ ) { + byte value = bytes[ index ]; + if ( value is not (byte)',' and not (byte)';' ) { + continue; + } + + if ( IsExpectedImageNumberField( + bytes, + fieldStart, + index - fieldStart, + this.ImageNumber + ) ) { + return true; + } + if ( (byte)';' == value ) { + return false; + } + fieldStart = index + 1; + } + return false; + } + + private static bool ContainsExpectedImageNumberField( + ReadOnlySpan controlAndMessage, + uint imageNumber + ) { + int fieldStart = 0; + for ( int index = 0; index < controlAndMessage.Length; index++ ) { + byte value = controlAndMessage[ index ]; + if ( value is not (byte)',' and not (byte)';' ) { + continue; + } + + ReadOnlySpan field = controlAndMessage.Slice( + fieldStart, + index - fieldStart + ); + if ( IsExpectedImageNumberField( + field, + imageNumber + ) ) { + return true; + } + if ( (byte)';' == value ) { + return false; + } + fieldStart = index + 1; + } + return false; + } + + private static bool IsExpectedImageNumberField( + ReadOnlySpan field, + uint imageNumber + ) { + if ( 3 > field.Length + || (byte)'I' != field[ 0 ] + || (byte)'=' != field[ 1 ] ) { + return false; + } + + uint parsed = 0; + for ( int index = 2; index < field.Length; index++ ) { + byte item = field[ index ]; + if ( item is < (byte)'0' or > (byte)'9' ) { + return false; + } + + uint digit = (uint)( item - (byte)'0' ); + if ( ( uint.MaxValue - digit ) / 10 < parsed ) { + return false; + } + parsed = ( parsed * 10 ) + digit; + } + return imageNumber == parsed; + } + + private static bool IsExpectedImageNumberField( + IReadOnlyList bytes, + int start, + int length, + uint imageNumber + ) { + ArgumentNullException.ThrowIfNull( bytes ); + if ( 3 > length + || 0 > start + || bytes.Count < start + length + || (byte)'I' != bytes[ start ] + || (byte)'=' != bytes[ start + 1 ] ) { + return false; + } + + uint parsed = 0; + for ( int index = start + 2; index < start + length; index++ ) { + byte item = bytes[ index ]; + if ( item is < (byte)'0' or > (byte)'9' ) { + return false; + } + + uint digit = (uint)( item - (byte)'0' ); + if ( ( uint.MaxValue - digit ) / 10 < parsed ) { + return false; + } + parsed = ( parsed * 10 ) + digit; + } + return imageNumber == parsed; + } +} diff --git a/src/Graphics/KittyGraphicsPersistentUploadTransaction.cs b/src/Graphics/KittyGraphicsPersistentUploadTransaction.cs new file mode 100644 index 000000000..445c190b4 --- /dev/null +++ b/src/Graphics/KittyGraphicsPersistentUploadTransaction.cs @@ -0,0 +1,70 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Emits one already-committed persistent Kitty Graphics upload while the query manager +/// owns the session control-output gate and correlated response expectation. +/// +internal static class KittyGraphicsPersistentUploadTransaction { + internal static async ValueTask WriteAsync( + TerminalSession session, + KittyRasterData raster, + uint imageNumber + ) { + ArgumentNullException.ThrowIfNull( session ); + ArgumentNullException.ThrowIfNull( raster ); + if ( 0u == imageNumber ) { + throw new ArgumentOutOfRangeException( nameof( imageNumber ) ); + } + + IEnumerable> payloads = + KittyGraphicsPersistentEncoder.EncodeUploadPayloads( + raster, + imageNumber + ); + using IEnumerator> enumerator = payloads.GetEnumerator(); + if ( !enumerator.MoveNext() ) { + throw new InvalidOperationException( + "The persistent Kitty Graphics encoder produced no payload for a non-empty raster." + ); + } + + do { + ReadOnlyMemory payload = enumerator.Current; + if ( payload.IsEmpty ) { + throw new InvalidOperationException( + "The persistent Kitty Graphics encoder produced an empty payload after output commitment." + ); + } + + byte[] frame = ApcWriter.EncodeFrame( payload.Span ); + await session.Output.WriteAsync( + frame, + CancellationToken.None + ).ConfigureAwait( false ); + } while ( enumerator.MoveNext() ); + + await session.Output.FlushAsync( + CancellationToken.None + ).ConfigureAwait( false ); + } +} diff --git a/src/Graphics/TerminalPersistentRasterPlacementState.cs b/src/Graphics/TerminalPersistentRasterPlacementState.cs new file mode 100644 index 000000000..ebcd752c5 --- /dev/null +++ b/src/Graphics/TerminalPersistentRasterPlacementState.cs @@ -0,0 +1,71 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Tracks local ownership state for one persistent raster placement. +/// +internal sealed class TerminalPersistentRasterPlacementState { + private int closed; + + internal TerminalPersistentRasterPlacementState( + TerminalPersistentRasterResourceState resource, + uint placementId, + long generation + ) { + ArgumentNullException.ThrowIfNull( resource ); + if ( 0u == placementId ) { + throw new ArgumentOutOfRangeException( nameof( placementId ) ); + } + if ( generation < 0 ) { + throw new ArgumentOutOfRangeException( nameof( generation ) ); + } + + this.Resource = resource; + this.PlacementId = placementId; + this.Generation = generation; + } + + internal TerminalPersistentRasterResourceState Resource { + get; + } + + internal uint PlacementId { + get; + } + + internal long Generation { + get; + } + + internal bool IsClosed { + get { + return 0 != Volatile.Read( ref this.closed ); + } + } + + internal void Close() { + Interlocked.Exchange( + ref this.closed, + 1 + ); + } +} diff --git a/src/Graphics/TerminalPersistentRasterRegistry.cs b/src/Graphics/TerminalPersistentRasterRegistry.cs new file mode 100644 index 000000000..cf4288c66 --- /dev/null +++ b/src/Graphics/TerminalPersistentRasterRegistry.cs @@ -0,0 +1,366 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Owns bounded, generation-scoped bookkeeping for persistent raster resources and placements. +/// +internal sealed class TerminalPersistentRasterRegistry { + internal const int MaximumResources = 256; + internal const int MaximumPlacements = 4096; + + private readonly object synchronization = new(); + private readonly Dictionary< + TerminalPersistentRasterResourceState, + HashSet + > resources = []; + private readonly HashSet placements = []; + private readonly HashSet imageNumbers = []; + private readonly HashSet placementIds = []; + private uint nextImageNumber; + private uint nextPlacementId; + private long generation; + + internal TerminalPersistentRasterRegistry( + uint initialImageNumber = 1, + uint initialPlacementId = 1, + long initialGeneration = 1 + ) { + if ( initialGeneration < 0 ) { + throw new ArgumentOutOfRangeException( nameof( initialGeneration ) ); + } + + this.nextImageNumber = NormalizeIdentity( initialImageNumber ); + this.nextPlacementId = NormalizeIdentity( initialPlacementId ); + this.generation = initialGeneration; + } + + internal long Generation { + get { + lock ( this.synchronization ) { + return this.generation; + } + } + } + + internal int LiveResourceCount { + get { + lock ( this.synchronization ) { + return this.resources.Count; + } + } + } + + internal int LivePlacementCount { + get { + lock ( this.synchronization ) { + return this.placements.Count; + } + } + } + + internal bool TryReserveResource( + out TerminalPersistentRasterResourceState? resource + ) { + lock ( this.synchronization ) { + if ( MaximumResources <= this.resources.Count ) { + resource = null; + return false; + } + + uint imageNumber = AllocateIdentity( + this.imageNumbers, + ref this.nextImageNumber + ); + resource = new TerminalPersistentRasterResourceState( + imageNumber, + this.generation + ); + this.imageNumbers.Add( imageNumber ); + this.resources.Add( + resource, + [] + ); + return true; + } + } + + internal bool TryReservePlacement( + TerminalPersistentRasterResourceState resource, + out TerminalPersistentRasterPlacementState? placement + ) { + ArgumentNullException.ThrowIfNull( resource ); + + lock ( this.synchronization ) { + if ( resource.IsClosed + || resource.Generation != this.generation + || !this.resources.TryGetValue( + resource, + out HashSet? children + ) + || MaximumPlacements <= this.placements.Count ) { + placement = null; + return false; + } + + uint placementId = AllocateIdentity( + this.placementIds, + ref this.nextPlacementId + ); + placement = new TerminalPersistentRasterPlacementState( + resource, + placementId, + this.generation + ); + this.placementIds.Add( placementId ); + this.placements.Add( placement ); + children.Add( placement ); + return true; + } + } + + internal bool IsResourceCurrent( + TerminalPersistentRasterResourceState resource + ) { + ArgumentNullException.ThrowIfNull( resource ); + + lock ( this.synchronization ) { + return !resource.IsClosed + && resource.Generation == this.generation + && this.resources.ContainsKey( resource ); + } + } + + internal bool IsPlacementCurrent( + TerminalPersistentRasterPlacementState placement + ) { + ArgumentNullException.ThrowIfNull( placement ); + + lock ( this.synchronization ) { + if ( placement.IsClosed + || placement.Resource.IsClosed + || placement.Generation != this.generation + || placement.Resource.Generation != this.generation + || !this.placements.Contains( placement ) + || !this.resources.TryGetValue( + placement.Resource, + out HashSet? children + ) ) { + return false; + } + + return children.Contains( placement ); + } + } + + internal void Invalidate() { + lock ( this.synchronization ) { + this.generation = AdvanceGeneration( this.generation ); + this.resources.Clear(); + this.placements.Clear(); + this.imageNumbers.Clear(); + this.placementIds.Clear(); + } + } + + internal bool InvalidateResource( + TerminalPersistentRasterResourceState resource + ) { + ArgumentNullException.ThrowIfNull( resource ); + + lock ( this.synchronization ) { + if ( !this.resources.TryGetValue( + resource, + out HashSet? children + ) ) { + return false; + } + + foreach ( TerminalPersistentRasterPlacementState placement in children ) { + this.placements.Remove( placement ); + this.placementIds.Remove( placement.PlacementId ); + } + children.Clear(); + this.resources.Remove( resource ); + this.imageNumbers.Remove( resource.ImageNumber ); + return true; + } + } + + internal void DrainCurrent( + out TerminalPersistentRasterPlacementState[] releasedPlacements, + out TerminalPersistentRasterResourceState[] releasedResources + ) { + lock ( this.synchronization ) { + releasedPlacements = this.placements.ToArray(); + Array.Sort( + releasedPlacements, + static ( left, right ) => { + int resourceOrder = left.Resource.ImageNumber.CompareTo( + right.Resource.ImageNumber + ); + return 0 != resourceOrder + ? resourceOrder + : left.PlacementId.CompareTo( right.PlacementId ) + ; + } + ); + + releasedResources = this.resources.Keys.ToArray(); + Array.Sort( + releasedResources, + static ( left, right ) => left.ImageNumber.CompareTo( right.ImageNumber ) + ); + + foreach ( TerminalPersistentRasterPlacementState placement in releasedPlacements ) { + placement.Close(); + } + foreach ( TerminalPersistentRasterResourceState resource in releasedResources ) { + resource.Close(); + } + + this.resources.Clear(); + this.placements.Clear(); + this.imageNumbers.Clear(); + this.placementIds.Clear(); + } + } + + internal bool TryReleaseResource( + TerminalPersistentRasterResourceState resource + ) { + return this.TryReleaseResource( + resource, + out _ + ); + } + + internal bool TryReleaseResource( + TerminalPersistentRasterResourceState resource, + out TerminalPersistentRasterPlacementState[] releasedPlacements + ) { + ArgumentNullException.ThrowIfNull( resource ); + + lock ( this.synchronization ) { + if ( !this.resources.TryGetValue( + resource, + out HashSet? children + ) ) { + releasedPlacements = []; + return false; + } + + releasedPlacements = new TerminalPersistentRasterPlacementState[ + children.Count + ]; + children.CopyTo( releasedPlacements ); + Array.Sort( + releasedPlacements, + static ( left, right ) => left.PlacementId.CompareTo( right.PlacementId ) + ); + + foreach ( TerminalPersistentRasterPlacementState placement in releasedPlacements ) { + this.placements.Remove( placement ); + this.placementIds.Remove( placement.PlacementId ); + placement.Close(); + } + children.Clear(); + + this.resources.Remove( resource ); + this.imageNumbers.Remove( resource.ImageNumber ); + resource.Close(); + return true; + } + } + + internal bool TryReleasePlacement( + TerminalPersistentRasterPlacementState placement + ) { + ArgumentNullException.ThrowIfNull( placement ); + + lock ( this.synchronization ) { + if ( !this.placements.Remove( placement ) ) { + return false; + } + + if ( this.resources.TryGetValue( + placement.Resource, + out HashSet? children + ) ) { + children.Remove( placement ); + } + this.placementIds.Remove( placement.PlacementId ); + placement.Close(); + return true; + } + } + + private static uint AllocateIdentity( + HashSet liveIdentities, + ref uint nextIdentity + ) { + ArgumentNullException.ThrowIfNull( liveIdentities ); + + uint candidate = NormalizeIdentity( nextIdentity ); + for ( int attempt = 0; attempt <= liveIdentities.Count; ++attempt ) { + if ( !liveIdentities.Contains( candidate ) ) { + nextIdentity = AdvanceIdentity( candidate ); + return candidate; + } + candidate = AdvanceIdentity( candidate ); + } + + throw new InvalidOperationException( + "A free persistent raster protocol identity could not be allocated." + ); + } + + private static uint NormalizeIdentity( + uint identity + ) { + return ( 0u == identity ) + ? 1u + : identity + ; + } + + private static uint AdvanceIdentity( + uint identity + ) { + return ( uint.MaxValue == identity ) + ? 1u + : identity + 1u + ; + } + + private static long AdvanceGeneration( + long value + ) { + if ( 0 > value ) { + throw new ArgumentOutOfRangeException( nameof( value ) ); + } + + return long.MaxValue == value + ? 0L + : value + 1L + ; + } +} diff --git a/src/Graphics/TerminalPersistentRasterResourceState.cs b/src/Graphics/TerminalPersistentRasterResourceState.cs new file mode 100644 index 000000000..087ff6db4 --- /dev/null +++ b/src/Graphics/TerminalPersistentRasterResourceState.cs @@ -0,0 +1,91 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Tracks local ownership state for one persistent raster resource. +/// +internal sealed class TerminalPersistentRasterResourceState { + private int imageIdBits; + private int closed; + + internal TerminalPersistentRasterResourceState( + uint imageNumber, + long generation + ) { + if ( 0u == imageNumber ) { + throw new ArgumentOutOfRangeException( nameof( imageNumber ) ); + } + if ( generation < 0 ) { + throw new ArgumentOutOfRangeException( nameof( generation ) ); + } + + this.ImageNumber = imageNumber; + this.Generation = generation; + } + + internal uint ImageNumber { + get; + } + + internal uint ImageId { + get { + return unchecked( (uint)Volatile.Read( ref this.imageIdBits ) ); + } + } + + internal long Generation { + get; + } + + internal bool IsClosed { + get { + return 0 != Volatile.Read( ref this.closed ); + } + } + + internal void BindImageId( + uint imageId + ) { + if ( 0u == imageId ) { + throw new ArgumentOutOfRangeException( nameof( imageId ) ); + } + + int bits = unchecked( (int)imageId ); + int prior = Interlocked.CompareExchange( + ref this.imageIdBits, + bits, + 0 + ); + if ( 0 != prior && imageId != unchecked( (uint)prior ) ) { + throw new InvalidOperationException( + "The persistent raster resource already has a different terminal-assigned image id." + ); + } + } + + internal void Close() { + Interlocked.Exchange( + ref this.closed, + 1 + ); + } +} diff --git a/src/Graphics/TerminalRasterPlacement.cs b/src/Graphics/TerminalRasterPlacement.cs new file mode 100644 index 000000000..6d22f793b --- /dev/null +++ b/src/Graphics/TerminalRasterPlacement.cs @@ -0,0 +1,87 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Represents one opaque placement of a terminal-resident raster resource. +/// +public sealed class TerminalRasterPlacement : IAsyncDisposable { + private TerminalSession? session; + + internal TerminalRasterPlacement( + TerminalSession session, + TerminalPersistentRasterPlacementState state + ) { + ArgumentNullException.ThrowIfNull( session ); + ArgumentNullException.ThrowIfNull( state ); + + this.session = session; + this.State = state; + } + + internal TerminalPersistentRasterPlacementState State { + get; + } + + /// + /// Replaces this placement at the terminal's current cursor position while retaining its + /// private resource and placement identities. + /// + /// Optional terminal-cell placement extents. + /// Cancellation observed before replacement output commits. + /// The controlled mutation result. + public ValueTask UpdateAsync( + TerminalRasterPlacementOptions? options = null, + CancellationToken cancellationToken = default + ) { + options?.Validate(); + cancellationToken.ThrowIfCancellationRequested(); + + TerminalSession? owner = Volatile.Read( ref this.session ); + if ( owner is null ) { + throw new ObjectDisposedException( + nameof( TerminalRasterPlacement ), + "The persistent raster placement has already been disposed." + ); + } + + return owner.UpdatePersistentRasterPlacementAsync( + this.State, + options, + cancellationToken + ); + } + + /// + /// Releases this placement's local ownership and, while its terminal identity remains current, + /// attempts one targeted terminal-side placement deletion. + /// + public ValueTask DisposeAsync() { + TerminalSession? owner = Interlocked.Exchange( + ref this.session, + null + ); + return owner is null + ? ValueTask.CompletedTask + : owner.ReleasePersistentRasterPlacementAsync( this.State ) + ; + } +} diff --git a/src/Graphics/TerminalRasterPlacementOptions.cs b/src/Graphics/TerminalRasterPlacementOptions.cs new file mode 100644 index 000000000..cbf93a5b8 --- /dev/null +++ b/src/Graphics/TerminalRasterPlacementOptions.cs @@ -0,0 +1,72 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Configures the cell extents of one persistent raster placement. +/// +public sealed class TerminalRasterPlacementOptions { + /// + /// Gets or sets the requested placement width in terminal cells, or + /// to let the terminal derive the width from the image and other placement information. + /// + public int? Columns { + get; + set; + } + + /// + /// Gets or sets the requested placement height in terminal cells, or + /// to let the terminal derive the height from the image and other placement information. + /// + public int? Rows { + get; + set; + } + + internal void Validate() { + ValidateExtent( + this.Columns, + nameof( this.Columns ) + ); + ValidateExtent( + this.Rows, + nameof( this.Rows ) + ); + } + + private static void ValidateExtent( + int? value, + string parameterName + ) { + ArgumentException.ThrowIfNullOrEmpty( parameterName ); + if ( !value.HasValue ) { + return; + } + if ( value.Value is < 1 or > TerminalRasterImage.MaximumDimension ) { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"A persistent raster placement extent must be between 1 and {TerminalRasterImage.MaximumDimension}." + ); + } + } +} diff --git a/src/Graphics/TerminalRasterResource.cs b/src/Graphics/TerminalRasterResource.cs new file mode 100644 index 000000000..512ed387a --- /dev/null +++ b/src/Graphics/TerminalRasterResource.cs @@ -0,0 +1,89 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Represents one opaque terminal-resident raster resource owned by a terminal session. +/// +public sealed class TerminalRasterResource : IAsyncDisposable { + private TerminalSession? session; + + internal TerminalRasterResource( + TerminalSession session, + TerminalPersistentRasterResourceState state + ) { + ArgumentNullException.ThrowIfNull( session ); + ArgumentNullException.ThrowIfNull( state ); + + this.session = session; + this.State = state; + } + + internal TerminalPersistentRasterResourceState State { + get; + } + + /// + /// Creates one opaque placement of this resource at the terminal's current cursor position. + /// + /// Optional terminal-cell placement extents. + /// Cancellation observed before placement output commits. + /// + /// An available opaque placement, or a controlled unavailable result when the session cannot + /// reserve another persistent placement. + /// + public ValueTask> CreatePlacementAsync( + TerminalRasterPlacementOptions? options = null, + CancellationToken cancellationToken = default + ) { + options?.Validate(); + cancellationToken.ThrowIfCancellationRequested(); + + TerminalSession? owner = Volatile.Read( ref this.session ); + if ( owner is null ) { + throw new ObjectDisposedException( + nameof( TerminalRasterResource ), + "The persistent raster resource has already been disposed." + ); + } + + return owner.CreatePersistentRasterPlacementAsync( + this.State, + options, + cancellationToken + ); + } + + /// + /// Releases this resource's local ownership, deletes its current placements, and then attempts + /// one terminal-side resource-data deletion while its terminal identity remains current. + /// + public ValueTask DisposeAsync() { + TerminalSession? owner = Interlocked.Exchange( + ref this.session, + null + ); + return owner is null + ? ValueTask.CompletedTask + : owner.ReleasePersistentRasterResourceAsync( this.State ) + ; + } +} diff --git a/src/Routing/TerminalControlLanguageVocabulary.cs b/src/Routing/TerminalControlLanguageVocabulary.cs index 8bd887e91..8cec27d30 100644 --- a/src/Routing/TerminalControlLanguageVocabulary.cs +++ b/src/Routing/TerminalControlLanguageVocabulary.cs @@ -44,7 +44,8 @@ internal enum TerminalSemanticOperation { MouseReporting, FocusReporting, BracketedPaste, - RasterGraphics + RasterGraphics, + PersistentRasterGraphics } /// diff --git a/src/Routing/TerminalSemanticBackendRegistry.cs b/src/Routing/TerminalSemanticBackendRegistry.cs index e8285a40f..8a4ec261f 100644 --- a/src/Routing/TerminalSemanticBackendRegistry.cs +++ b/src/Routing/TerminalSemanticBackendRegistry.cs @@ -206,6 +206,10 @@ private static ReadOnlyDictionary< TerminalSemanticOperation.RasterGraphics, TerminalProtocolBackend.DcsSixel, TerminalProtocolBackend.ApcKittyGraphics + ), + [ TerminalSemanticOperation.PersistentRasterGraphics ] = CreateCandidates( + TerminalSemanticOperation.PersistentRasterGraphics, + TerminalProtocolBackend.ApcKittyGraphics ) }; diff --git a/src/Routing/TerminalSemanticBackendResolver.cs b/src/Routing/TerminalSemanticBackendResolver.cs index 23e516b99..682250893 100644 --- a/src/Routing/TerminalSemanticBackendResolver.cs +++ b/src/Routing/TerminalSemanticBackendResolver.cs @@ -493,6 +493,11 @@ private static ReadOnlyDictionary< ( TerminalProtocolBackend.ApcKittyGraphics, false ), ( TerminalProtocolBackend.DcsSixel, false ) ); + AddPolicy( + policies, + TerminalSemanticOperation.PersistentRasterGraphics, + ( TerminalProtocolBackend.ApcKittyGraphics, false ) + ); ValidatePolicies( policies ); return new ReadOnlyDictionary< diff --git a/src/Session/TerminalCapabilityStatus.cs b/src/Session/TerminalCapabilityStatus.cs index e57c3591a..407315a9f 100644 --- a/src/Session/TerminalCapabilityStatus.cs +++ b/src/Session/TerminalCapabilityStatus.cs @@ -68,7 +68,12 @@ public enum TerminalCapability { /// /// Displaying backend-neutral raster graphics. /// - RasterGraphics + RasterGraphics, + + /// + /// Owning terminal-resident persistent raster resources and placements. + /// + PersistentRasterGraphics } /// diff --git a/src/Session/TerminalQueryTransactionManager.cs b/src/Session/TerminalQueryTransactionManager.cs index 146293562..957672ba3 100644 --- a/src/Session/TerminalQueryTransactionManager.cs +++ b/src/Session/TerminalQueryTransactionManager.cs @@ -99,68 +99,49 @@ CancellationToken cancellationToken $"A terminal query request cannot exceed {MaximumRequestBytes} bytes." ); } - if ( TimeSpan.Zero > timeout || MaximumCallerTimeout < timeout ) { - throw new ArgumentOutOfRangeException( - nameof( timeout ), - timeout, - $"A terminal query timeout must be between zero and {MaximumCallerTimeout}." - ); - } - if ( TimeSpan.Zero >= lateResponseOwnership - || MaximumLateResponseOwnership < lateResponseOwnership ) { - throw new ArgumentOutOfRangeException( - nameof( lateResponseOwnership ), - lateResponseOwnership, - "Late-response ownership must be positive and no greater than " - + $"{MaximumLateResponseOwnership}." - ); - } + ValidateTiming( + timeout, + lateResponseOwnership + ); cancellationToken.ThrowIfCancellationRequested(); - string? unavailable = this.session.GetQueryUnavailableReason(); - if ( unavailable is not null ) { - throw new InvalidOperationException( unavailable ); - } - - TerminalQueryTransaction transaction; - long transactionGeneration; - lock ( this.sync ) { - this.ThrowIfClosed(); - if ( this.suspended ) { - throw new InvalidOperationException( - "Terminal queries are unavailable while the session is suspended." - ); - } - if ( MaximumPendingTransactions <= this.pendingCount ) { - throw new InvalidOperationException( - $"The terminal query queue is limited to {MaximumPendingTransactions} transactions." - ); - } - - transactionGeneration = this.generation; - transaction = new TerminalQueryTransaction( - request.ToArray(), - responsePlan, - timeout, - lateResponseOwnership, - this.session.Options.MonotonicClock, - cancellationToken - ); + TerminalQueryTransaction transaction = new( + request.ToArray(), + responsePlan, + timeout, + lateResponseOwnership, + this.session.Options.MonotonicClock, + cancellationToken + ); + return this.EnqueueTransaction( transaction ); + } - if ( 0 == this.pendingCount ) { - this.idleCompletion = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously - ); - } - ++this.pendingCount; - this.transactions.Add( transaction ); - } + internal ValueTask ExecuteAsync( + Func emission, + TerminalQueryResponsePlan responsePlan, + TimeSpan timeout, + TimeSpan lateResponseOwnership, + CancellationToken cancellationToken, + Action? abandonedCleanup = null + ) { + ArgumentNullException.ThrowIfNull( emission ); + ArgumentNullException.ThrowIfNull( responsePlan ); + ValidateTiming( + timeout, + lateResponseOwnership + ); + cancellationToken.ThrowIfCancellationRequested(); - _ = this.RunTransactionAsync( - transaction, - transactionGeneration + TerminalQueryTransaction transaction = new( + emission, + responsePlan, + timeout, + lateResponseOwnership, + this.session.Options.MonotonicClock, + cancellationToken, + abandonedCleanup ); - return new ValueTask( transaction.CallerTask ); + return this.EnqueueTransaction( transaction ); } internal void Suspend() { @@ -229,6 +210,50 @@ internal async ValueTask CloseAsync() { await idleTask.ConfigureAwait( false ); } + private ValueTask EnqueueTransaction( + TerminalQueryTransaction transaction + ) { + ArgumentNullException.ThrowIfNull( transaction ); + + string? unavailable = this.session.GetQueryUnavailableReason(); + if ( unavailable is not null ) { + transaction.Dispose(); + throw new InvalidOperationException( unavailable ); + } + + long transactionGeneration; + lock ( this.sync ) { + this.ThrowIfClosed(); + if ( this.suspended ) { + transaction.Dispose(); + throw new InvalidOperationException( + "Terminal queries are unavailable while the session is suspended." + ); + } + if ( MaximumPendingTransactions <= this.pendingCount ) { + transaction.Dispose(); + throw new InvalidOperationException( + $"The terminal query queue is limited to {MaximumPendingTransactions} transactions." + ); + } + + transactionGeneration = this.generation; + if ( 0 == this.pendingCount ) { + this.idleCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + } + ++this.pendingCount; + this.transactions.Add( transaction ); + } + + _ = this.RunTransactionAsync( + transaction, + transactionGeneration + ); + return new ValueTask( transaction.CallerTask ); + } + private async ValueTask ExecuteSingleFamilyAsync( ReadOnlyMemory request, TerminalQueryResponsePlan responsePlan, @@ -315,13 +340,19 @@ await this.ambiguityGate.WaitAsync( coordinator.ArmResponseExpectation( expectation ); try { - await this.session.Output.WriteAsync( - transaction.Request, - this.stop.Token - ).ConfigureAwait( false ); - await this.session.Output.FlushAsync( - this.stop.Token - ).ConfigureAwait( false ); + if ( transaction.Emission is not null ) { + await transaction.Emission( + this.stop.Token + ).ConfigureAwait( false ); + } else { + await this.session.Output.WriteAsync( + transaction.Request, + this.stop.Token + ).ConfigureAwait( false ); + await this.session.Output.FlushAsync( + this.stop.Token + ).ConfigureAwait( false ); + } } catch ( OperationCanceledException ) when ( this.stop.IsCancellationRequested ) { throw; } catch ( Exception exception ) { @@ -359,8 +390,12 @@ await this.WaitForResponseOwnershipAsync( this.ambiguityGate.Release(); } - transaction.Dispose(); - this.CompletePendingTransaction( transaction ); + try { + transaction.CompleteOwnership(); + } finally { + transaction.Dispose(); + this.CompletePendingTransaction( transaction ); + } } } @@ -453,6 +488,28 @@ private void ThrowIfClosed() { } } + private static void ValidateTiming( + TimeSpan timeout, + TimeSpan lateResponseOwnership + ) { + if ( TimeSpan.Zero > timeout || MaximumCallerTimeout < timeout ) { + throw new ArgumentOutOfRangeException( + nameof( timeout ), + timeout, + $"A terminal query timeout must be between zero and {MaximumCallerTimeout}." + ); + } + if ( TimeSpan.Zero >= lateResponseOwnership + || MaximumLateResponseOwnership < lateResponseOwnership ) { + throw new ArgumentOutOfRangeException( + nameof( lateResponseOwnership ), + lateResponseOwnership, + "Late-response ownership must be positive and no greater than " + + $"{MaximumLateResponseOwnership}." + ); + } + } + private static TaskCompletionSource CreateCompletedCompletion() { TaskCompletionSource completion = new( TaskCreationOptions.RunContinuationsAsynchronously @@ -478,6 +535,7 @@ internal sealed class TerminalQueryTransaction : IDisposable { TaskCreationOptions.RunContinuationsAsynchronously ); private readonly CancellationTokenRegistration callerCancellationRegistration; + private readonly Action? abandonedCleanup; private long? callerStoppedTimestamp; private bool callerTimedOut; @@ -491,9 +549,59 @@ internal TerminalQueryTransaction( TimeSpan lateResponseOwnership, IMonotonicClock monotonicClock, CancellationToken callerCancellationToken + ) : this( + request, + emission: null, + responsePlan, + timeout, + lateResponseOwnership, + monotonicClock, + callerCancellationToken, + abandonedCleanup: null + ) { + } + + internal TerminalQueryTransaction( + Func emission, + TerminalQueryResponsePlan responsePlan, + TimeSpan timeout, + TimeSpan lateResponseOwnership, + IMonotonicClock monotonicClock, + CancellationToken callerCancellationToken, + Action? abandonedCleanup + ) : this( + request: null, + emission, + responsePlan, + timeout, + lateResponseOwnership, + monotonicClock, + callerCancellationToken, + abandonedCleanup ) { - ArgumentNullException.ThrowIfNull( request ); - if ( 0 == request.Length ) { + } + + private TerminalQueryTransaction( + byte[]? request, + Func? emission, + TerminalQueryResponsePlan responsePlan, + TimeSpan timeout, + TimeSpan lateResponseOwnership, + IMonotonicClock monotonicClock, + CancellationToken callerCancellationToken, + Action? abandonedCleanup + ) { + if ( request is null && emission is null ) { + throw new ArgumentException( + "A terminal query transaction requires request bytes or a committed emission callback." + ); + } + if ( request is not null && emission is not null ) { + throw new ArgumentException( + "A terminal query transaction cannot own both request bytes and an emission callback." + ); + } + if ( request is not null && 0 == request.Length ) { throw new ArgumentException( "A terminal query request cannot be empty.", nameof( request ) @@ -508,13 +616,18 @@ CancellationToken callerCancellationToken throw new ArgumentOutOfRangeException( nameof( lateResponseOwnership ) ); } - this.Request = request.ToArray(); + this.Request = request is null + ? ReadOnlyMemory.Empty + : request.ToArray() + ; + this.Emission = emission; this.ResponsePlan = responsePlan; this.monotonicClock = monotonicClock; this.timeout = timeout; this.lateResponseOwnership = lateResponseOwnership; this.timeoutStartedTimestamp = monotonicClock.GetTimestamp(); this.callerCancellationToken = callerCancellationToken; + this.abandonedCleanup = abandonedCleanup; this.callerCancellationRegistration = callerCancellationToken.Register( static state => ( (TerminalQueryTransaction)state! ).CancelCaller(), this @@ -526,6 +639,10 @@ internal ReadOnlyMemory Request { get; } + internal Func? Emission { + get; + } + internal TerminalQueryResponsePlan ResponsePlan { get; } @@ -618,6 +735,14 @@ internal TimeSpan GetRemainingLateResponseOwnership() { } } + internal void CompleteOwnership() { + if ( this.CallerTask.IsCompletedSuccessfully ) { + return; + } + + this.abandonedCleanup?.Invoke(); + } + public void Dispose() { if ( 0 != Interlocked.Exchange( ref this.disposed, 1 ) ) { return; diff --git a/src/Session/TerminalSession.CapabilityInspection.cs b/src/Session/TerminalSession.CapabilityInspection.cs index 02daf7f46..c5cfd8c4a 100644 --- a/src/Session/TerminalSession.CapabilityInspection.cs +++ b/src/Session/TerminalSession.CapabilityInspection.cs @@ -73,11 +73,11 @@ TerminalCapability capability /// capability through an existing reviewed bounded live probe when such a probe exists. /// /// - /// Verification is intentionally narrower than inspection. In version 1.10, live support - /// verification is available for modern keyboard reporting and raster graphics. Other semantic - /// capabilities return their current inspection status unchanged rather than emitting invented - /// or behavior-changing probe traffic. Existing decisive live evidence and unavailable endpoints - /// are also returned without probing. + /// Verification is intentionally narrower than inspection. Live support verification is + /// available for modern keyboard reporting, raster graphics, and persistent raster graphics. + /// Other semantic capabilities return their current inspection status unchanged rather than + /// emitting invented or behavior-changing probe traffic. Existing decisive live evidence and + /// unavailable endpoints are also returned without probing. /// /// The semantic capability to verify when a reviewed probe exists. /// Cancellation for any live verification attempt. @@ -122,6 +122,12 @@ await this.ProbeRasterGraphicsBackendsAsync( ).ConfigureAwait( false ); break; + case TerminalCapability.PersistentRasterGraphics: + _ = await this.ProbeKittyGraphicsSupportAsync( + cancellationToken + ).ConfigureAwait( false ); + break; + default: return status; } @@ -143,6 +149,7 @@ TerminalCapability capability TerminalCapability.FocusReporting => TerminalSemanticOperation.FocusReporting, TerminalCapability.BracketedPaste => TerminalSemanticOperation.BracketedPaste, TerminalCapability.RasterGraphics => TerminalSemanticOperation.RasterGraphics, + TerminalCapability.PersistentRasterGraphics => TerminalSemanticOperation.PersistentRasterGraphics, _ => throw new ArgumentOutOfRangeException( nameof( capability ), capability, diff --git a/src/Session/TerminalSession.PersistentRasterGraphics.cs b/src/Session/TerminalSession.PersistentRasterGraphics.cs new file mode 100644 index 000000000..9442485ca --- /dev/null +++ b/src/Session/TerminalSession.PersistentRasterGraphics.cs @@ -0,0 +1,570 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// +/// Creates and owns persistent terminal-resident raster resources for a live session. +/// +public sealed partial class TerminalSession { + private static TimeSpan PersistentRasterCreationTimeout { + get; + } = TimeSpan.FromSeconds( 1 ); + + private readonly TerminalPersistentRasterRegistry persistentRasterRegistry = new(); + + /// + /// Uploads one raster as a persistent terminal-resident resource through a verified backend. + /// + /// The immutable backend-neutral raster image. + /// + /// Cancellation observed before upload commitment and while the caller waits for the correlated + /// acknowledgement. Once output commits, caller cancellation does not truncate the upload frames. + /// + /// + /// An available opaque resource after correlated acknowledgement, or a controlled unavailable, + /// unsupported, or failed result when the persistent operation cannot be established. + /// + public async ValueTask> CreateRasterResourceAsync( + TerminalRasterImage image, + CancellationToken cancellationToken = default + ) { + ArgumentNullException.ThrowIfNull( image ); + cancellationToken.ThrowIfCancellationRequested(); + this.ThrowIfSessionOutputClosed(); + + TerminalCapabilityStatus capability = this.InspectCapability( + TerminalCapability.PersistentRasterGraphics + ); + if ( TerminalCapabilityEndpointAvailability.Unavailable + == capability.EndpointAvailability ) { + return TerminalControlResult.Unavailable( + "Persistent raster graphics require an interactive terminal output endpoint." + ); + } + if ( TerminalCapabilitySupport.Unsupported == capability.Support ) { + return TerminalControlResult.Unsupported( + "The verified terminal backend does not support persistent raster graphics." + ); + } + if ( TerminalCapabilitySupport.Verified != capability.Support + || !capability.IsUsable ) { + return TerminalControlResult.Unavailable( + "Persistent raster graphics must be explicitly verified before resource creation." + ); + } + + KittyRasterData raster = KittyRasterAdapter.Adapt( image ); + if ( !this.persistentRasterRegistry.TryReserveResource( + out TerminalPersistentRasterResourceState? resourceState + ) ) { + return TerminalControlResult.Unavailable( + $"The session already owns the maximum {TerminalPersistentRasterRegistry.MaximumResources} persistent raster resources." + ); + } + if ( resourceState is null ) { + throw new InvalidOperationException( + "The persistent raster registry reported a successful reservation without state." + ); + } + + KittyGraphicsPersistentResponseMatcher matcher = new( + resourceState.ImageNumber, + validateMatchedResponse: false + ); + ValueTask transaction; + try { + transaction = this.GetQueryTransactionManager().ExecuteAsync( + _ => KittyGraphicsPersistentUploadTransaction.WriteAsync( + this, + raster, + resourceState.ImageNumber + ), + TerminalQueryResponsePlan.ForCompletion( matcher ), + PersistentRasterCreationTimeout, + TerminalQueryTransactionManager.DefaultLateResponseOwnership, + cancellationToken, + abandonedCleanup: () => { + _ = this.persistentRasterRegistry.TryReleaseResource( + resourceState + ); + } + ); + } catch { + _ = this.persistentRasterRegistry.TryReleaseResource( resourceState ); + throw; + } + + TerminalQueryResponseResult queryResult = await transaction.ConfigureAwait( false ); + KittyGraphicsPersistentCreationResponse response; + try { + response = KittyGraphicsPersistentCreationResponse.Parse( + queryResult.Frame, + resourceState.ImageNumber + ); + } catch { + _ = this.persistentRasterRegistry.TryReleaseResource( resourceState ); + throw; + } + + if ( !response.IsSuccess ) { + _ = this.persistentRasterRegistry.TryReleaseResource( resourceState ); + return response.IsUnavailable + ? TerminalControlResult.Unavailable( + response.Message + ) + : TerminalControlResult.Failed( + response.Message + ) + ; + } + if ( !response.ImageId.HasValue ) { + _ = this.persistentRasterRegistry.TryReleaseResource( resourceState ); + throw new FormatException( + "A successful persistent raster upload did not return a terminal-assigned image id." + ); + } + + resourceState.BindImageId( response.ImageId.Value ); + if ( !this.persistentRasterRegistry.IsResourceCurrent( resourceState ) ) { + return TerminalControlResult.Unavailable( + "The persistent raster resource lost generation ownership before creation completed." + ); + } + + return TerminalControlResult.Available( + new TerminalRasterResource( + this, + resourceState + ) + ); + } + + internal async ValueTask> CreatePersistentRasterPlacementAsync( + TerminalPersistentRasterResourceState resourceState, + TerminalRasterPlacementOptions? options, + CancellationToken cancellationToken + ) { + ArgumentNullException.ThrowIfNull( resourceState ); + options?.Validate(); + cancellationToken.ThrowIfCancellationRequested(); + this.ThrowIfSessionOutputClosed(); + if ( resourceState.IsClosed ) { + throw new ObjectDisposedException( + nameof( TerminalRasterResource ), + "The persistent raster resource has already been disposed." + ); + } + if ( !this.persistentRasterRegistry.IsResourceCurrent( resourceState ) ) { + return TerminalControlResult.Unavailable( + "The persistent raster resource is no longer current for this session generation." + ); + } + + uint imageId = resourceState.ImageId; + if ( 0u == imageId ) { + throw new InvalidOperationException( + "The persistent raster resource does not have a terminal-assigned image id." + ); + } + + if ( !this.persistentRasterRegistry.TryReservePlacement( + resourceState, + out TerminalPersistentRasterPlacementState? placementState + ) ) { + if ( resourceState.IsClosed ) { + throw new ObjectDisposedException( + nameof( TerminalRasterResource ), + "The persistent raster resource has already been disposed." + ); + } + if ( !this.persistentRasterRegistry.IsResourceCurrent( resourceState ) ) { + return TerminalControlResult.Unavailable( + "The persistent raster resource is no longer current for this session generation." + ); + } + return TerminalControlResult.Unavailable( + $"The session already owns the maximum {TerminalPersistentRasterRegistry.MaximumPlacements} persistent raster placements." + ); + } + if ( placementState is null ) { + throw new InvalidOperationException( + "The persistent raster registry reported a successful placement reservation without state." + ); + } + + KittyGraphicsPersistentPlacementResponseMatcher matcher = new( + imageId, + placementState.PlacementId + ); + ValueTask transaction; + try { + transaction = this.GetQueryTransactionManager().ExecuteAsync( + _ => KittyGraphicsPersistentPlacementTransaction.WriteCoreAsync( + this, + imageId, + placementState.PlacementId, + options + ), + TerminalQueryResponsePlan.ForCompletion( matcher ), + PersistentRasterCreationTimeout, + TerminalQueryTransactionManager.DefaultLateResponseOwnership, + cancellationToken, + abandonedCleanup: () => { + _ = this.persistentRasterRegistry.TryReleasePlacement( + placementState + ); + } + ); + } catch { + _ = this.persistentRasterRegistry.TryReleasePlacement( placementState ); + throw; + } + + TerminalQueryResponseResult queryResult; + try { + queryResult = await transaction.ConfigureAwait( false ); + } catch { + _ = this.persistentRasterRegistry.TryReleasePlacement( placementState ); + throw; + } + + KittyGraphicsPersistentPlacementResponse response; + try { + response = KittyGraphicsPersistentPlacementResponse.Parse( + queryResult.Frame, + imageId, + placementState.PlacementId + ); + } catch { + _ = this.persistentRasterRegistry.TryReleasePlacement( placementState ); + throw; + } + + if ( !response.IsSuccess ) { + if ( response.IsUnavailable ) { + _ = this.persistentRasterRegistry.InvalidateResource( resourceState ); + return TerminalControlResult.Unavailable( + response.Message + ); + } + + _ = this.persistentRasterRegistry.TryReleasePlacement( placementState ); + return TerminalControlResult.Failed( + response.Message + ); + } + + if ( !this.persistentRasterRegistry.IsPlacementCurrent( placementState ) ) { + return TerminalControlResult.Unavailable( + "The persistent raster placement lost generation ownership before creation completed." + ); + } + + return TerminalControlResult.Available( + new TerminalRasterPlacement( + this, + placementState + ) + ); + } + + internal async ValueTask UpdatePersistentRasterPlacementAsync( + TerminalPersistentRasterPlacementState placementState, + TerminalRasterPlacementOptions? options, + CancellationToken cancellationToken + ) { + ArgumentNullException.ThrowIfNull( placementState ); + options?.Validate(); + cancellationToken.ThrowIfCancellationRequested(); + this.ThrowIfSessionOutputClosed(); + + if ( !this.persistentRasterRegistry.IsPlacementCurrent( placementState ) ) { + return TerminalControlMutationResult.Unavailable( + "The persistent raster placement is no longer current for this session generation." + ); + } + + uint imageId = placementState.Resource.ImageId; + if ( 0u == imageId ) { + return TerminalControlMutationResult.Unavailable( + "The persistent raster resource no longer has a usable terminal image identity." + ); + } + + KittyGraphicsPersistentPlacementResponseMatcher matcher = new( + imageId, + placementState.PlacementId + ); + TerminalQueryResponseResult queryResult = await this.GetQueryTransactionManager().ExecuteAsync( + _ => KittyGraphicsPersistentPlacementTransaction.WriteCoreAsync( + this, + imageId, + placementState.PlacementId, + options + ), + TerminalQueryResponsePlan.ForCompletion( matcher ), + PersistentRasterCreationTimeout, + TerminalQueryTransactionManager.DefaultLateResponseOwnership, + cancellationToken + ).ConfigureAwait( false ); + + KittyGraphicsPersistentPlacementResponse response = + KittyGraphicsPersistentPlacementResponse.Parse( + queryResult.Frame, + imageId, + placementState.PlacementId + ); + if ( !response.IsSuccess ) { + if ( response.IsUnavailable ) { + _ = this.persistentRasterRegistry.InvalidateResource( + placementState.Resource + ); + return TerminalControlMutationResult.Unavailable( + response.Message + ); + } + + return TerminalControlMutationResult.Failed( + response.Message + ); + } + + if ( !this.persistentRasterRegistry.IsPlacementCurrent( placementState ) ) { + return TerminalControlMutationResult.Unavailable( + "The persistent raster placement is no longer current for this session generation." + ); + } + return TerminalControlMutationResult.Success(); + } + + internal async ValueTask ReleasePersistentRasterResourceAsync( + TerminalPersistentRasterResourceState resourceState + ) { + ArgumentNullException.ThrowIfNull( resourceState ); + if ( !this.persistentRasterRegistry.IsResourceCurrent( resourceState ) ) { + _ = this.persistentRasterRegistry.TryReleaseResource( resourceState ); + return; + } + + IDisposable outputLease; + try { + outputLease = await this.AcquireControlOutputAsync( + CancellationToken.None + ).ConfigureAwait( false ); + } catch { + _ = this.persistentRasterRegistry.TryReleaseResource( resourceState ); + throw; + } + + using ( outputLease ) { + if ( !this.persistentRasterRegistry.TryReleaseResource( + resourceState, + out TerminalPersistentRasterPlacementState[] releasedPlacements + ) ) { + return; + } + + uint imageId = resourceState.ImageId; + if ( 0u == imageId ) { + return; + } + + List failures = []; + foreach ( TerminalPersistentRasterPlacementState placement in releasedPlacements ) { + try { + await this.WritePersistentRasterControlFrameCoreAsync( + KittyGraphicsPersistentEncoder.EncodeDeletePlacementPayload( + imageId, + placement.PlacementId + ) + ).ConfigureAwait( false ); + } catch ( Exception exception ) { + failures.Add( exception ); + } + } + + try { + await this.WritePersistentRasterControlFrameCoreAsync( + KittyGraphicsPersistentEncoder.EncodeDeleteResourcePayload( + imageId + ) + ).ConfigureAwait( false ); + } catch ( Exception exception ) { + failures.Add( exception ); + } + + try { + await this.Output.FlushAsync( + CancellationToken.None + ).ConfigureAwait( false ); + } catch ( Exception exception ) { + failures.Add( exception ); + } + + if ( 0 < failures.Count ) { + throw new AggregateException( + "One or more persistent raster resource cleanup operations failed.", + failures + ); + } + } + } + + internal async ValueTask ReleasePersistentRasterPlacementAsync( + TerminalPersistentRasterPlacementState placementState + ) { + ArgumentNullException.ThrowIfNull( placementState ); + if ( !this.persistentRasterRegistry.IsPlacementCurrent( placementState ) ) { + _ = this.persistentRasterRegistry.TryReleasePlacement( placementState ); + return; + } + + IDisposable outputLease; + try { + outputLease = await this.AcquireControlOutputAsync( + CancellationToken.None + ).ConfigureAwait( false ); + } catch { + _ = this.persistentRasterRegistry.TryReleasePlacement( placementState ); + throw; + } + + using ( outputLease ) { + if ( !this.persistentRasterRegistry.TryReleasePlacement( placementState ) ) { + return; + } + + uint imageId = placementState.Resource.ImageId; + if ( 0u == imageId ) { + return; + } + + await this.WritePersistentRasterControlFrameCoreAsync( + KittyGraphicsPersistentEncoder.EncodeDeletePlacementPayload( + imageId, + placementState.PlacementId + ) + ).ConfigureAwait( false ); + await this.Output.FlushAsync( + CancellationToken.None + ).ConfigureAwait( false ); + } + } + + private void InvalidatePersistentRasterState() { + this.persistentRasterRegistry.Invalidate(); + } + + private async ValueTask ClosePersistentRasterStateAsync() { + IDisposable outputLease; + try { + outputLease = await this.AcquireControlOutputAsync( + CancellationToken.None + ).ConfigureAwait( false ); + } catch ( Exception exception ) { + this.persistentRasterRegistry.DrainCurrent( + out _, + out _ + ); + return exception; + } + + using ( outputLease ) { + this.persistentRasterRegistry.DrainCurrent( + out TerminalPersistentRasterPlacementState[] releasedPlacements, + out TerminalPersistentRasterResourceState[] releasedResources + ); + if ( 0 == releasedPlacements.Length + && 0 == releasedResources.Length ) { + return null; + } + + List failures = []; + foreach ( TerminalPersistentRasterPlacementState placement in releasedPlacements ) { + uint imageId = placement.Resource.ImageId; + if ( 0u == imageId ) { + continue; + } + + try { + await this.WritePersistentRasterControlFrameCoreAsync( + KittyGraphicsPersistentEncoder.EncodeDeletePlacementPayload( + imageId, + placement.PlacementId + ) + ).ConfigureAwait( false ); + } catch ( Exception exception ) { + failures.Add( exception ); + } + } + + foreach ( TerminalPersistentRasterResourceState resource in releasedResources ) { + uint imageId = resource.ImageId; + if ( 0u == imageId ) { + continue; + } + + try { + await this.WritePersistentRasterControlFrameCoreAsync( + KittyGraphicsPersistentEncoder.EncodeDeleteResourcePayload( + imageId + ) + ).ConfigureAwait( false ); + } catch ( Exception exception ) { + failures.Add( exception ); + } + } + + try { + await this.Output.FlushAsync( + CancellationToken.None + ).ConfigureAwait( false ); + } catch ( Exception exception ) { + failures.Add( exception ); + } + + return failures.Count switch { + 0 => null, + 1 => failures[ 0 ], + _ => new AggregateException( + "Multiple persistent raster cleanup operations failed.", + failures + ) + }; + } + } + + private ValueTask WritePersistentRasterControlFrameCoreAsync( + ReadOnlyMemory payload + ) { + if ( payload.IsEmpty ) { + throw new ArgumentException( + "A persistent raster control payload cannot be empty.", + nameof( payload ) + ); + } + + return this.Output.WriteAsync( + ApcWriter.EncodeFrame( payload.Span ), + CancellationToken.None + ); + } +} diff --git a/src/Session/TerminalSession.Presentation.cs b/src/Session/TerminalSession.Presentation.cs index 04c9299d2..4bcf7bcd5 100644 --- a/src/Session/TerminalSession.Presentation.cs +++ b/src/Session/TerminalSession.Presentation.cs @@ -59,6 +59,7 @@ private void InvalidatePresentationState() { this.InvalidatePointerShapeState(); this.InvalidatePaletteColorState(); this.InvalidateDynamicColorState(); + this.InvalidatePersistentRasterState(); } private async ValueTask SuspendPresentationStateAsync() { @@ -98,6 +99,12 @@ private async ValueTask ResumePresentationStateAsync() { exceptions.Add( exception ); } + Exception? persistentRasterException = + await this.ClosePersistentRasterStateAsync().ConfigureAwait( false ); + if ( persistentRasterException is not null ) { + exceptions.Add( persistentRasterException ); + } + Exception? cursorStyleException = await this.CloseCursorStyleStateAsync().ConfigureAwait( false ); if ( cursorStyleException is not null ) { diff --git a/tests/Icod.Terminal.Tests/src/Graphics/FrozenMonotonicClock.cs b/tests/Icod.Terminal.Tests/src/Graphics/FrozenMonotonicClock.cs new file mode 100644 index 000000000..6a987e346 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/FrozenMonotonicClock.cs @@ -0,0 +1,60 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using Icod.Timing; + +/// +/// Holds monotonic time fixed for scripted graphics exchanges whose replies are +/// driven explicitly by the test rather than by elapsed wall-clock time. +/// +internal sealed class FrozenMonotonicClock : IMonotonicClock { + public long GetTimestamp() { + return 0L; + } + + public TimeSpan GetElapsedTime( + long startingTimestamp, + long endingTimestamp + ) { + return TimeSpan.Zero; + } + + public ValueTask DelayAsync( + TimeSpan delay, + CancellationToken cancellationToken = default + ) { + if ( TimeSpan.Zero > delay ) { + throw new ArgumentOutOfRangeException( nameof( delay ) ); + } + cancellationToken.ThrowIfCancellationRequested(); + if ( TimeSpan.Zero == delay ) { + return ValueTask.CompletedTask; + } + + return new ValueTask( + Task.Delay( + Timeout.InfiniteTimeSpan, + cancellationToken + ) + ); + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/KittyGraphicsPersistentEncoderTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/KittyGraphicsPersistentEncoderTests.cs new file mode 100644 index 000000000..a7b395d11 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/KittyGraphicsPersistentEncoderTests.cs @@ -0,0 +1,244 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Text; +using Icod.Terminal; +using Xunit; + +/// +/// Defines the C111 typed persistent Kitty Graphics wire contract. +/// +public sealed class KittyGraphicsPersistentEncoderTests { + [Fact] + public void OneRgbPixelUsesAcknowledgedTransmitOnlyUpload() { + KittyRasterData raster = KittyRasterAdapter.Adapt( + TerminalRasterImage.CreateRgb24( + 1, + 1, + [ 0, 0, 0 ] + ) + ); + + ReadOnlyMemory payload = Assert.Single( + KittyGraphicsPersistentEncoder.EncodeUploadPayloads( + raster, + 31 + ) + ); + + Assert.Equal( + "Ga=t,f=24,s=1,v=1,t=d,I=31,m=0;AAAA", + Encoding.ASCII.GetString( payload.Span ) + ); + Assert.DoesNotContain( + "q=", + Encoding.ASCII.GetString( payload.Span ), + StringComparison.Ordinal + ); + } + + [Fact] + public void MultiChunkUploadRetainsAcknowledgementAndChunkBounds() { + byte[] pixels = Enumerable.Range( + 0, + KittyGraphicsDirectEncoder.MaximumRawChunkBytes + 3 + ).Select( index => unchecked( (byte)( index * 17 ) ) ).ToArray(); + KittyRasterData raster = KittyRasterAdapter.Adapt( + TerminalRasterImage.CreateRgb24( + 1025, + 1, + pixels + ) + ); + + ReadOnlyMemory[] payloads = KittyGraphicsPersistentEncoder + .EncodeUploadPayloads( + raster, + 31 + ) + .ToArray(); + + Assert.Equal( 2, payloads.Length ); + SplitPayload( + payloads[ 0 ], + out string firstControl, + out byte[] firstEncoded + ); + SplitPayload( + payloads[ 1 ], + out string finalControl, + out byte[] finalEncoded + ); + + Assert.Equal( + "Ga=t,f=24,s=1025,v=1,t=d,I=31,m=1;", + firstControl + ); + Assert.Equal( "Gm=0;", finalControl ); + Assert.DoesNotContain( "q=", firstControl, StringComparison.Ordinal ); + Assert.DoesNotContain( "q=", finalControl, StringComparison.Ordinal ); + Assert.True( + firstEncoded.Length + <= KittyGraphicsDirectEncoder.MaximumEncodedPayloadBytes + ); + Assert.True( + finalEncoded.Length + <= KittyGraphicsDirectEncoder.MaximumEncodedPayloadBytes + ); + Assert.Equal( pixels, DecodeCombinedPayloads( payloads ) ); + } + + [Fact] + public void PlacementUsesCurrentCursorWithoutMovingIt() { + ReadOnlyMemory payload = KittyGraphicsPersistentEncoder.EncodePlacementPayload( + imageId: 99, + placementId: 7, + columns: 3, + rows: 2 + ); + + Assert.Equal( + "Ga=p,i=99,p=7,C=1,c=3,r=2", + Encoding.ASCII.GetString( payload.Span ) + ); + } + + [Fact] + public void PlacementCanUseIntrinsicSizing() { + ReadOnlyMemory payload = KittyGraphicsPersistentEncoder.EncodePlacementPayload( + imageId: 99, + placementId: 7, + columns: null, + rows: null + ); + + Assert.Equal( + "Ga=p,i=99,p=7,C=1", + Encoding.ASCII.GetString( payload.Span ) + ); + } + + [Fact] + public void RepeatingPlacementIdentityProducesIdenticalReplacementPayload() { + ReadOnlyMemory first = KittyGraphicsPersistentEncoder.EncodePlacementPayload( + imageId: 99, + placementId: 7, + columns: 4, + rows: 3 + ); + ReadOnlyMemory second = KittyGraphicsPersistentEncoder.EncodePlacementPayload( + imageId: 99, + placementId: 7, + columns: 4, + rows: 3 + ); + + Assert.Equal( first.ToArray(), second.ToArray() ); + } + + [Fact] + public void PlacementDeleteKeepsResourceDataAndSuppressesReply() { + ReadOnlyMemory payload = KittyGraphicsPersistentEncoder.EncodeDeletePlacementPayload( + imageId: 99, + placementId: 7 + ); + + Assert.Equal( + "Ga=d,d=i,i=99,p=7,q=2", + Encoding.ASCII.GetString( payload.Span ) + ); + } + + [Fact] + public void ResourceDeleteUsesHardImageSelectorAndSuppressesReply() { + ReadOnlyMemory payload = KittyGraphicsPersistentEncoder.EncodeDeleteResourcePayload( + imageId: 99 + ); + + Assert.Equal( + "Ga=d,d=I,i=99,q=2", + Encoding.ASCII.GetString( payload.Span ) + ); + } + + [Theory] + [InlineData( 0u, 7u )] + [InlineData( 99u, 0u )] + public void PlacementRejectsZeroIdentity( + uint imageId, + uint placementId + ) { + Assert.Throws( + () => KittyGraphicsPersistentEncoder.EncodePlacementPayload( + imageId, + placementId, + null, + null + ) + ); + } + + [Fact] + public void UploadRejectsZeroImageNumberBeforeEnumeration() { + KittyRasterData raster = KittyRasterAdapter.Adapt( + TerminalRasterImage.CreateRgb24( + 1, + 1, + [ 0, 0, 0 ] + ) + ); + + Assert.Throws( + () => KittyGraphicsPersistentEncoder.EncodeUploadPayloads( + raster, + 0 + ) + ); + } + + private static void SplitPayload( + ReadOnlyMemory payload, + out string controlData, + out byte[] encoded + ) { + int separator = payload.Span.IndexOf( (byte)';' ); + Assert.True( 0 < separator ); + controlData = Encoding.ASCII.GetString( payload.Span[..( separator + 1 )] ); + encoded = payload.Span[( separator + 1 )..].ToArray(); + } + + private static byte[] DecodeCombinedPayloads( + IEnumerable> payloads + ) { + StringBuilder encoded = new(); + foreach ( ReadOnlyMemory payload in payloads ) { + int separator = payload.Span.IndexOf( (byte)';' ); + Assert.True( 0 < separator ); + encoded.Append( + Encoding.ASCII.GetString( + payload.Span[( separator + 1 )..] + ) + ); + } + return Convert.FromBase64String( encoded.ToString() ); + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/KittyGraphicsPersistentResponseMatcherTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/KittyGraphicsPersistentResponseMatcherTests.cs new file mode 100644 index 000000000..97ae1ec42 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/KittyGraphicsPersistentResponseMatcherTests.cs @@ -0,0 +1,125 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Text; +using Icod.Terminal; +using Xunit; + +/// +/// Defines C111 image-number acknowledgement correlation for persistent Kitty Graphics. +/// +public sealed class KittyGraphicsPersistentResponseMatcherTests { + [Fact] + public void MatcherOwnsApcResponsesForExpectedImageNumber() { + KittyGraphicsPersistentResponseMatcher matcher = new( 31 ); + TerminalResponseFrame frame = CreateApcResponse( + "Gi=99,I=31;OK" + ); + + Assert.Equal( TerminalResponseFrameKind.Apc, matcher.FrameKind ); + Assert.True( matcher.IsMatch( frame ) ); + } + + [Fact] + public void MatcherRejectsDifferentImageNumber() { + KittyGraphicsPersistentResponseMatcher matcher = new( 31 ); + TerminalResponseFrame frame = CreateApcResponse( + "Gi=99,I=32;OK" + ); + + Assert.False( matcher.IsMatch( frame ) ); + } + + [Fact] + public void MatcherRejectsResponseWithoutImageNumber() { + KittyGraphicsPersistentResponseMatcher matcher = new( 31 ); + TerminalResponseFrame frame = CreateApcResponse( + "Gi=99;OK" + ); + + Assert.False( matcher.IsMatch( frame ) ); + } + + [Fact] + public void CorrelatedPrefixRequiresCompleteExpectedImageNumberField() { + KittyGraphicsPersistentResponseMatcher matcher = new( 31 ); + + Assert.True( + matcher.IsCorrelatedPrefix( + Encoding.ASCII.GetBytes( "\u001b_Gi=99,I=31;" ) + ) + ); + Assert.False( + matcher.IsCorrelatedPrefix( + Encoding.ASCII.GetBytes( "\u001b_Gi=99,I=3" ) + ) + ); + Assert.False( + matcher.IsCorrelatedPrefix( + Encoding.ASCII.GetBytes( "\u001b_Gi=99,I=32;" ) + ) + ); + } + + [Fact] + public void ConstructorRejectsZeroImageNumber() { + Assert.Throws( + () => new KittyGraphicsPersistentResponseMatcher( 0 ) + ); + } + + [Fact] + public void DuplicateImageNumberIsMalformedWhenCorrelated() { + KittyGraphicsPersistentResponseMatcher matcher = new( 31 ); + TerminalResponseFrame frame = CreateApcResponse( + "Gi=99,I=31,I=31;OK" + ); + + Assert.Throws( + () => matcher.IsMatch( frame ) + ); + } + + [Fact] + public void OverflowingAssignedImageIdIsMalformedWhenCorrelated() { + KittyGraphicsPersistentResponseMatcher matcher = new( 31 ); + TerminalResponseFrame frame = CreateApcResponse( + "Gi=4294967296,I=31;OK" + ); + + Assert.Throws( + () => matcher.IsMatch( frame ) + ); + } + + private static TerminalResponseFrame CreateApcResponse( + string payload + ) { + ArgumentException.ThrowIfNullOrEmpty( payload ); + return new TerminalResponseFrame( + TerminalResponseFrameKind.Apc, + ApcWriter.EncodeFrame( + Encoding.ASCII.GetBytes( payload ) + ) + ); + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/RecordingTerminalControlProvider.cs b/tests/Icod.Terminal.Tests/src/Graphics/RecordingTerminalControlProvider.cs new file mode 100644 index 000000000..842480f4e --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/RecordingTerminalControlProvider.cs @@ -0,0 +1,87 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using Icod.Terminal; +using Icod.TermInfo; + +/// +/// Provides the minimal interactive terminal-control surface required by persistent-raster tests. +/// +internal sealed class RecordingTerminalControlProvider : ITerminalControlProvider { + private readonly TerminalModeSnapshot baseline = TerminalModeSnapshot.CreatePosix( + 0, + 0, + 0, + 0x0002UL, + new byte[ 32 ], + 0, + 32, + 0, + new TerminalSpeed( 13, 9600 ), + new TerminalSpeed( 13, 9600 ) + ); + + public TerminalControlResult Observe( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + new TerminalEndpointObservation( + true, + null, + TerminalPlatformKind.PosixTermios, + TerminalControlCapabilities.Attachment + | TerminalControlCapabilities.ModeRead + | TerminalControlCapabilities.ModeWrite + ) + ); + } + + public TerminalControlResult GetSize( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Unavailable( + "No scripted live size." + ); + } + + public TerminalControlResult GetMode( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( this.baseline ); + } + + public TerminalControlMutationResult SetMode( + TerminalEndpoint endpoint, + TerminalModeSnapshot mode, + TerminalModeApplyTiming timing + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + ArgumentNullException.ThrowIfNull( mode ); + if ( !Enum.IsDefined( timing ) ) { + throw new ArgumentOutOfRangeException( nameof( timing ) ); + } + return TerminalControlMutationResult.Success(); + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterHardeningTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterHardeningTests.cs new file mode 100644 index 000000000..d0d8a7cac --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterHardeningTests.cs @@ -0,0 +1,326 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Text; +using System.Threading.Channels; +using Icod.Terminal; +using Icod.TermInfo; +using Xunit; + +/// +/// Defines the C118 repeated ownership-cycle and generation-invalidation hardening contract. +/// +public sealed class TerminalPersistentRasterHardeningTests { + private const int OwnershipCycleCount = 24; + private const int GenerationInvalidationCount = 64; + private const int RegistryChurnCount = 8192; + + [Fact] + public async Task RepeatedCreatePlaceUpdateDeleteCyclesRemainUsableAndIdempotent() { + AcknowledgingTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + + for ( int cycle = 0; cycle < OwnershipCycleCount; ++cycle ) { + TerminalControlResult resourceResult = + await session.CreateRasterResourceAsync( CreateSmallImage() ); + Assert.Equal( TerminalControlStatus.Available, resourceResult.Status ); + TerminalRasterResource resource = Assert.IsType( + resourceResult.Value + ); + + TerminalControlResult placementResult = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = 3, + Rows = 2 + } + ); + Assert.Equal( TerminalControlStatus.Available, placementResult.Status ); + TerminalRasterPlacement placement = Assert.IsType( + placementResult.Value + ); + + TerminalControlMutationResult update = await placement.UpdateAsync( + new TerminalRasterPlacementOptions { + Columns = 2, + Rows = 1 + } + ); + Assert.True( update.Succeeded ); + + await placement.DisposeAsync(); + int afterPlacementDispose = transport.Writes.Count; + await placement.DisposeAsync(); + Assert.Equal( afterPlacementDispose, transport.Writes.Count ); + + await resource.DisposeAsync(); + int afterResourceDispose = transport.Writes.Count; + await resource.DisposeAsync(); + Assert.Equal( afterResourceDispose, transport.Writes.Count ); + } + + Assert.Equal( + OwnershipCycleCount * 5, + transport.Writes.Count + ); + } + + [Fact] + public async Task RepeatedGenerationInvalidationNeverReplaysOrRevivesStaleHandles() { + AcknowledgingTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalControlResult resourceResult = + await session.CreateRasterResourceAsync( CreateSmallImage() ); + TerminalRasterResource resource = Assert.IsType( + resourceResult.Value + ); + TerminalControlResult placementResult = + await resource.CreatePlacementAsync(); + TerminalRasterPlacement placement = Assert.IsType( + placementResult.Value + ); + int baselineWrites = transport.Writes.Count; + + for ( int iteration = 0; iteration < GenerationInvalidationCount; ++iteration ) { + session.InvalidateState(); + + Assert.Equal( + TerminalControlStatus.Unavailable, + ( await placement.UpdateAsync() ).Status + ); + Assert.Equal( + TerminalControlStatus.Unavailable, + ( await resource.CreatePlacementAsync() ).Status + ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + await placement.DisposeAsync(); + await resource.DisposeAsync(); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public void RegistryChurnReleasesEveryPlacementAndAvoidsLiveIdentityReuse() { + TerminalPersistentRasterRegistry registry = new(); + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? resource + ) + ); + Assert.NotNull( resource ); + HashSet identities = []; + + for ( int iteration = 0; iteration < RegistryChurnCount; ++iteration ) { + Assert.True( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? placement + ) + ); + Assert.NotNull( placement ); + Assert.True( identities.Add( placement.PlacementId ) ); + Assert.True( registry.TryReleasePlacement( placement ) ); + Assert.Equal( 0, registry.LivePlacementCount ); + } + + Assert.Equal( RegistryChurnCount, identities.Count ); + Assert.True( registry.TryReleaseResource( resource ) ); + Assert.Equal( 0, registry.LiveResourceCount ); + Assert.Equal( 0, registry.LivePlacementCount ); + } + + private static TerminalRasterImage CreateSmallImage() { + return TerminalRasterImage.CreateRgb24( + 1, + 1, + [ 1, 2, 3 ] + ); + } + + private static async ValueTask OpenSessionAsync( + AcknowledgingTransport transport + ) { + ArgumentNullException.ThrowIfNull( transport ); + TerminalSession session = await TerminalSession.OpenAsync( + new RecordingTerminalControlProvider(), + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + transport, + transport, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + ObserveLifecycleEvents = false, + RequireInteractiveOutput = false + } + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.ApcKittyGraphics, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + return session; + } + + private sealed class AcknowledgingTransport : ITerminalInput, ITerminalOutput { + private readonly Channel input = Channel.CreateUnbounded( + new UnboundedChannelOptions { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + } + ); + private readonly object synchronization = new(); + private readonly List writes = []; + + internal IReadOnlyList Writes { + get { + lock ( this.synchronization ) { + return this.writes.Select( + static value => value.ToArray() + ).ToArray(); + } + } + } + + public async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + byte[] value = await this.input.Reader.ReadAsync( + cancellationToken + ).ConfigureAwait( false ); + if ( value.Length > buffer.Length ) { + throw new InvalidOperationException( + "The scripted response exceeds the terminal input buffer." + ); + } + + value.AsSpan().CopyTo( buffer.Span ); + return value.Length; + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + lock ( this.synchronization ) { + this.writes.Add( buffer.ToArray() ); + } + this.PublishAcknowledgement( buffer.Span ); + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + + private void PublishAcknowledgement( + ReadOnlySpan frame + ) { + string text = Encoding.ASCII.GetString( frame ); + if ( text.StartsWith( + "\u001b_Ga=t,", + StringComparison.Ordinal + ) && TryReadIdentityField( + text, + ",I=", + out uint imageNumber + ) ) { + this.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={1000u + imageNumber},I={imageNumber};OK\u001b\\" + ) + ); + return; + } + + if ( text.StartsWith( + "\u001b_Ga=p,", + StringComparison.Ordinal + ) && TryReadIdentityField( + text, + ",i=", + out uint imageId + ) && TryReadIdentityField( + text, + ",p=", + out uint placementId + ) ) { + this.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},p={placementId};OK\u001b\\" + ) + ); + } + } + + private void Publish( + byte[] value + ) { + ArgumentNullException.ThrowIfNull( value ); + if ( !this.input.Writer.TryWrite( value.ToArray() ) ) { + throw new InvalidOperationException( + "The scripted terminal input channel rejected a response." + ); + } + } + + private static bool TryReadIdentityField( + string text, + string marker, + out uint value + ) { + ArgumentNullException.ThrowIfNull( text ); + ArgumentException.ThrowIfNullOrEmpty( marker ); + value = 0u; + + int start = text.IndexOf( + marker, + StringComparison.Ordinal + ); + if ( 0 > start ) { + return false; + } + start += marker.Length; + int end = start; + while ( end < text.Length + && text[ end ] is >= '0' and <= '9' ) { + ++end; + } + return start < end + && uint.TryParse( + text.AsSpan( + start, + end - start + ), + out value + ) + ; + } + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterLifecycleTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterLifecycleTests.cs new file mode 100644 index 000000000..1cec3694b --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterLifecycleTests.cs @@ -0,0 +1,516 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Text; +using System.Threading.Channels; +using Icod.Terminal; +using Icod.TermInfo; +using Xunit; + +/// +/// Defines C117 persistent-raster invalidation, managed lifecycle, and session-teardown semantics. +/// +public sealed class TerminalPersistentRasterLifecycleTests { + [Fact] + public async Task ExplicitInvalidationStalesResourceAndPlacementWithoutOutput() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + int baselineWrites = transport.Writes.Count; + + session.InvalidateState(); + + TerminalControlMutationResult update = await placement.UpdateAsync(); + TerminalControlResult create = + await resource.CreatePlacementAsync(); + + Assert.Equal( TerminalControlStatus.Unavailable, update.Status ); + Assert.Equal( TerminalControlStatus.Unavailable, create.Status ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task StaleDisposalPerformsLocalCleanupOnly() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + session.InvalidateState(); + int baselineWrites = transport.Writes.Count; + + await placement.DisposeAsync(); + await resource.DisposeAsync(); + + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task SuspendResumeStalesPersistentHandlesWithoutReplay() { + ScriptedTransport transport = new(); + TestLifecycleSource lifecycle = new() { + AutoResume = true + }; + await using TerminalSession session = await OpenSessionAsync( + transport, + lifecycle + ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + int baselineWrites = transport.Writes.Count; + using CancellationTokenSource timeout = new( TimeSpan.FromSeconds( 5 ) ); + + lifecycle.Publish( TerminalLifecycleSignalKind.Suspend ); + TerminalLifecycleEvent suspending = await session.ReadLifecycleEventAsync( + timeout.Token + ); + TerminalLifecycleEvent resumed = await session.ReadLifecycleEventAsync( + timeout.Token + ); + + Assert.Equal( TerminalLifecycleEventKind.Suspending, suspending.Kind ); + Assert.Equal( TerminalLifecycleEventKind.Resumed, resumed.Kind ); + Assert.True( session.IsStateValid ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + Assert.DoesNotContain( + transport.Writes.Skip( baselineWrites ), + static value => Encoding.ASCII.GetString( value ).Contains( + "Ga=t", + StringComparison.Ordinal + ) + ); + + TerminalControlMutationResult update = await placement.UpdateAsync(); + TerminalControlResult create = + await resource.CreatePlacementAsync(); + Assert.Equal( TerminalControlStatus.Unavailable, update.Status ); + Assert.Equal( TerminalControlStatus.Unavailable, create.Status ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task ExternalResumeAlsoStalesPersistentHandlesWithoutReplay() { + ScriptedTransport transport = new(); + TestLifecycleSource lifecycle = new(); + await using TerminalSession session = await OpenSessionAsync( + transport, + lifecycle + ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + int baselineWrites = transport.Writes.Count; + using CancellationTokenSource timeout = new( TimeSpan.FromSeconds( 5 ) ); + + lifecycle.Publish( TerminalLifecycleSignalKind.Resume ); + TerminalLifecycleEvent resumed = await session.ReadLifecycleEventAsync( + timeout.Token + ); + + Assert.Equal( TerminalLifecycleEventKind.Resumed, resumed.Kind ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + Assert.Equal( + TerminalControlStatus.Unavailable, + ( await placement.UpdateAsync() ).Status + ); + Assert.Equal( + TerminalControlStatus.Unavailable, + ( await resource.CreatePlacementAsync() ).Status + ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task SessionDisposeDeletesAllPlacementsBeforeAnyResourceData() { + ScriptedTransport transport = new(); + TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource firstResource = await CreateResourceAsync( + session, + transport, + imageId: 71, + imageNumber: 1 + ); + _ = await CreatePlacementAsync( firstResource ); + TerminalRasterResource secondResource = await CreateResourceAsync( + session, + transport, + imageId: 72, + imageNumber: 2 + ); + _ = await CreatePlacementAsync( secondResource ); + int baselineWrites = transport.Writes.Count; + + await session.DisposeAsync(); + + string[] cleanup = transport.Writes + .Skip( baselineWrites ) + .Select( static value => Encoding.ASCII.GetString( value ) ) + .Where( static value => value.Contains( "Ga=d", StringComparison.Ordinal ) ) + .ToArray(); + + Assert.Equal( 4, cleanup.Length ); + Assert.Contains( "d=i", cleanup[ 0 ], StringComparison.Ordinal ); + Assert.Contains( "d=i", cleanup[ 1 ], StringComparison.Ordinal ); + Assert.Contains( "d=I", cleanup[ 2 ], StringComparison.Ordinal ); + Assert.Contains( "d=I", cleanup[ 3 ], StringComparison.Ordinal ); + } + + [Fact] + public async Task SessionDisposeAfterInvalidationEmitsNoPersistentIdentifiers() { + ScriptedTransport transport = new(); + TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + _ = await CreatePlacementAsync( resource ); + session.InvalidateState(); + int baselineWrites = transport.Writes.Count; + + await session.DisposeAsync(); + + Assert.DoesNotContain( + transport.Writes.Skip( baselineWrites ), + static value => Encoding.ASCII.GetString( value ).Contains( + "Ga=d", + StringComparison.Ordinal + ) + ); + } + + [Fact] + public async Task SessionCleanupContinuesAfterPersistentDeleteFailure() { + ScriptedTransport transport = new(); + TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 91, + imageNumber: 1 + ); + _ = await CreatePlacementAsync( resource ); + int baselineWrites = transport.Writes.Count; + transport.FailOnWriteNumber = baselineWrites + 1; + + await Assert.ThrowsAnyAsync( + async () => await session.DisposeAsync() + ); + + string[] cleanup = transport.Writes + .Skip( baselineWrites ) + .Select( static value => Encoding.ASCII.GetString( value ) ) + .Where( static value => value.Contains( "Ga=d", StringComparison.Ordinal ) ) + .ToArray(); + Assert.Equal( 2, cleanup.Length ); + Assert.Contains( "d=i", cleanup[ 0 ], StringComparison.Ordinal ); + Assert.Contains( "d=I", cleanup[ 1 ], StringComparison.Ordinal ); + } + + private static async Task CreatePlacementAsync( + TerminalRasterResource resource + ) { + ArgumentNullException.ThrowIfNull( resource ); + TerminalControlResult result = + await resource.CreatePlacementAsync(); + Assert.Equal( TerminalControlStatus.Available, result.Status ); + return Assert.IsType( result.Value ); + } + + private static TerminalRasterImage CreateSmallImage() { + return TerminalRasterImage.CreateRgb24( + 1, + 1, + [ 1, 2, 3 ] + ); + } + + private static async Task CreateResourceAsync( + TerminalSession session, + ScriptedTransport transport, + uint imageId, + uint imageNumber + ) { + ArgumentNullException.ThrowIfNull( session ); + ArgumentNullException.ThrowIfNull( transport ); + if ( 0u == imageId ) { + throw new ArgumentOutOfRangeException( nameof( imageId ) ); + } + if ( 0u == imageNumber ) { + throw new ArgumentOutOfRangeException( nameof( imageNumber ) ); + } + + int expectedWriteCount = transport.Writes.Count + 1; + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( expectedWriteCount ); + transport.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},I={imageNumber};OK\u001b\\" + ) + ); + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Available, result.Status ); + return Assert.IsType( result.Value ); + } + + private static async ValueTask OpenSessionAsync( + ScriptedTransport transport, + TestLifecycleSource? lifecycle = null + ) { + ArgumentNullException.ThrowIfNull( transport ); + TerminalSession session = await TerminalSession.OpenAsync( + new RecordingTerminalControlProvider(), + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + transport, + transport, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + LifecycleSource = lifecycle, + MonotonicClock = new FrozenMonotonicClock(), + ObserveLifecycleEvents = false, + RequireInteractiveOutput = false + } + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.ApcKittyGraphics, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + return session; + } + + private sealed class TestLifecycleSource + : ITerminalLifecycleSource, + ITerminalSuspendController { + private readonly Channel signals = + Channel.CreateUnbounded(); + + internal bool AutoResume { + get; + init; + } + + internal void Publish( + TerminalLifecycleSignalKind kind + ) { + Assert.True( + this.signals.Writer.TryWrite( new TerminalLifecycleSignal( kind ) ) + ); + } + + public ValueTask ReadAsync( + CancellationToken cancellationToken = default + ) { + return this.signals.Reader.ReadAsync( cancellationToken ); + } + + public TerminalControlMutationResult SuspendCurrentProcess() { + if ( this.AutoResume ) { + this.Publish( TerminalLifecycleSignalKind.Resume ); + } + return TerminalControlMutationResult.Success(); + } + + public void Dispose() { + this.signals.Writer.TryComplete(); + } + } + + private sealed class ScriptedTransport : ITerminalInput, ITerminalOutput { + private readonly Channel input = Channel.CreateUnbounded( + new UnboundedChannelOptions { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + } + ); + private readonly object synchronization = new(); + private readonly SemaphoreSlim writeSignal = new( 0 ); + private readonly List writes = []; + private int writeNumber; + + internal int? FailOnWriteNumber { + get; + set; + } + + internal IReadOnlyList Writes { + get { + lock ( this.synchronization ) { + return this.writes.Select( + static value => value.ToArray() + ).ToArray(); + } + } + } + + public async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + byte[] value = await this.input.Reader.ReadAsync( + cancellationToken + ).ConfigureAwait( false ); + if ( value.Length > buffer.Length ) { + throw new InvalidOperationException( + "The scripted response exceeds the terminal input buffer." + ); + } + value.AsSpan().CopyTo( buffer.Span ); + return value.Length; + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + int current = Interlocked.Increment( ref this.writeNumber ); + lock ( this.synchronization ) { + this.writes.Add( buffer.ToArray() ); + } + this.writeSignal.Release(); + if ( this.FailOnWriteNumber == current ) { + throw new IOException( "Synthetic persistent raster lifecycle cleanup failure." ); + } + this.PublishPlacementAcknowledgement( buffer.Span ); + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + + internal void Publish( + byte[] value + ) { + ArgumentNullException.ThrowIfNull( value ); + if ( !this.input.Writer.TryWrite( value.ToArray() ) ) { + throw new InvalidOperationException( + "The scripted terminal input channel rejected a response." + ); + } + } + + internal async Task WaitForWriteCountAsync( + int count + ) { + if ( 0 > count ) { + throw new ArgumentOutOfRangeException( nameof( count ) ); + } + while ( true ) { + lock ( this.synchronization ) { + if ( count <= this.writes.Count ) { + return; + } + } + await this.writeSignal.WaitAsync().ConfigureAwait( false ); + } + } + + private void PublishPlacementAcknowledgement( + ReadOnlySpan frame + ) { + string text = Encoding.ASCII.GetString( frame ); + if ( !text.StartsWith( + "\u001b_Ga=p,", + StringComparison.Ordinal + ) || !TryReadIdentityField( + text, + ",i=", + out uint imageId + ) || !TryReadIdentityField( + text, + ",p=", + out uint placementId + ) ) { + return; + } + + this.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},p={placementId};OK\u001b\\" + ) + ); + } + + private static bool TryReadIdentityField( + string text, + string marker, + out uint value + ) { + ArgumentNullException.ThrowIfNull( text ); + ArgumentException.ThrowIfNullOrEmpty( marker ); + + int start = text.IndexOf( + marker, + StringComparison.Ordinal + ); + if ( 0 > start ) { + value = 0u; + return false; + } + start += marker.Length; + int end = start; + while ( end < text.Length + && text[ end ] is >= '0' and <= '9' ) { + ++end; + } + value = 0u; + return start < end + && uint.TryParse( + text.AsSpan( + start, + end - start + ), + out value + ) + ; + } + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementAcknowledgementTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementAcknowledgementTests.cs new file mode 100644 index 000000000..e1b7874d9 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementAcknowledgementTests.cs @@ -0,0 +1,366 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Text; +using System.Threading.Channels; +using Icod.Terminal; +using Icod.TermInfo; +using Xunit; + +/// +/// Defines C118 acknowledgement ownership and terminal-side disappearance handling for +/// persistent raster placements. +/// +public sealed class TerminalPersistentRasterPlacementAcknowledgementTests { + [Fact] + public async Task PlacementCreationWaitsForMatchingImageAndPlacementAcknowledgement() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + int expectedWriteCount = transport.Writes.Count + 1; + + Task> creation = + resource.CreatePlacementAsync().AsTask(); + await transport.WaitForWriteCountAsync( expectedWriteCount ); + + Assert.False( creation.IsCompleted ); + Assert.Equal( + Encoding.ASCII.GetBytes( "\u001b_Ga=p,i=77,p=1,C=1\u001b\\" ), + transport.Writes[ expectedWriteCount - 1 ] + ); + + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=88,p=1;OK\u001b\\" ) + ); + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=77,p=2;OK\u001b\\" ) + ); + Assert.False( creation.IsCompleted ); + + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=77,p=1;OK\u001b\\" ) + ); + TerminalControlResult result = await creation; + + Assert.Equal( TerminalControlStatus.Available, result.Status ); + Assert.NotNull( result.Value ); + } + + [Fact] + public async Task PlacementCreationEnoentInvalidatesOwningResource() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + int expectedWriteCount = transport.Writes.Count + 1; + + Task> creation = + resource.CreatePlacementAsync().AsTask(); + await transport.WaitForWriteCountAsync( expectedWriteCount ); + transport.Publish( + Encoding.ASCII.GetBytes( + "\u001b_Gi=77,p=1;ENOENT:synthetic missing image\u001b\\" + ) + ); + + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Unavailable, result.Status ); + Assert.Null( result.Value ); + + int baselineWrites = transport.Writes.Count; + TerminalControlResult retry = + await resource.CreatePlacementAsync(); + Assert.Equal( TerminalControlStatus.Unavailable, retry.Status ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task PlacementUpdateEnoentInvalidatesResourceAndPlacement() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( + resource, + transport, + imageId: 77, + placementId: 1 + ); + int expectedWriteCount = transport.Writes.Count + 1; + + Task update = placement.UpdateAsync( + new TerminalRasterPlacementOptions { + Columns = 3, + Rows = 2 + } + ).AsTask(); + await transport.WaitForWriteCountAsync( expectedWriteCount ); + + Assert.False( update.IsCompleted ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=77,p=1,C=1,c=3,r=2\u001b\\" + ), + transport.Writes[ expectedWriteCount - 1 ] + ); + transport.Publish( + Encoding.ASCII.GetBytes( + "\u001b_Gi=77,p=1;ENOENT:synthetic missing image\u001b\\" + ) + ); + + TerminalControlMutationResult result = await update; + Assert.Equal( TerminalControlStatus.Unavailable, result.Status ); + + int baselineWrites = transport.Writes.Count; + Assert.Equal( + TerminalControlStatus.Unavailable, + ( await placement.UpdateAsync() ).Status + ); + Assert.Equal( + TerminalControlStatus.Unavailable, + ( await resource.CreatePlacementAsync() ).Status + ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task MalformedCorrelatedPlacementAcknowledgementThrowsFormatException() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77, + imageNumber: 1 + ); + int expectedWriteCount = transport.Writes.Count + 1; + + Task> creation = + resource.CreatePlacementAsync().AsTask(); + await transport.WaitForWriteCountAsync( expectedWriteCount ); + transport.Publish( + Encoding.ASCII.GetBytes( + "\u001b_Gi=77,p=1,p=1;OK\u001b\\" + ) + ); + + await Assert.ThrowsAsync( () => creation ); + } + + private static async Task CreateResourceAsync( + TerminalSession session, + ScriptedTransport transport, + uint imageId, + uint imageNumber + ) { + ArgumentNullException.ThrowIfNull( session ); + ArgumentNullException.ThrowIfNull( transport ); + if ( 0u == imageId ) { + throw new ArgumentOutOfRangeException( nameof( imageId ) ); + } + if ( 0u == imageNumber ) { + throw new ArgumentOutOfRangeException( nameof( imageNumber ) ); + } + + int expectedWriteCount = transport.Writes.Count + 1; + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( expectedWriteCount ); + transport.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},I={imageNumber};OK\u001b\\" + ) + ); + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Available, result.Status ); + return Assert.IsType( result.Value ); + } + + private static async Task CreatePlacementAsync( + TerminalRasterResource resource, + ScriptedTransport transport, + uint imageId, + uint placementId + ) { + ArgumentNullException.ThrowIfNull( resource ); + ArgumentNullException.ThrowIfNull( transport ); + if ( 0u == imageId ) { + throw new ArgumentOutOfRangeException( nameof( imageId ) ); + } + if ( 0u == placementId ) { + throw new ArgumentOutOfRangeException( nameof( placementId ) ); + } + + int expectedWriteCount = transport.Writes.Count + 1; + Task> creation = + resource.CreatePlacementAsync().AsTask(); + await transport.WaitForWriteCountAsync( expectedWriteCount ); + transport.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},p={placementId};OK\u001b\\" + ) + ); + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Available, result.Status ); + return Assert.IsType( result.Value ); + } + + private static TerminalRasterImage CreateSmallImage() { + return TerminalRasterImage.CreateRgb24( + 1, + 1, + [ 1, 2, 3 ] + ); + } + + private static async ValueTask OpenSessionAsync( + ScriptedTransport transport + ) { + ArgumentNullException.ThrowIfNull( transport ); + TerminalSession session = await TerminalSession.OpenAsync( + new RecordingTerminalControlProvider(), + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + transport, + transport, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + MonotonicClock = new FrozenMonotonicClock(), + ObserveLifecycleEvents = false, + RequireInteractiveOutput = false + } + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.ApcKittyGraphics, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + return session; + } + + private sealed class ScriptedTransport : ITerminalInput, ITerminalOutput { + private readonly Channel input = Channel.CreateUnbounded( + new UnboundedChannelOptions { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + } + ); + private readonly object synchronization = new(); + private readonly SemaphoreSlim writeSignal = new( 0 ); + private readonly List writes = []; + + internal IReadOnlyList Writes { + get { + lock ( this.synchronization ) { + return this.writes.Select( + static item => item.ToArray() + ).ToArray(); + } + } + } + + public async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + byte[] value = await this.input.Reader.ReadAsync( + cancellationToken + ).ConfigureAwait( false ); + if ( value.Length > buffer.Length ) { + throw new InvalidOperationException( + "The scripted response exceeds the terminal input buffer." + ); + } + + value.AsSpan().CopyTo( buffer.Span ); + return value.Length; + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + lock ( this.synchronization ) { + this.writes.Add( buffer.ToArray() ); + } + this.writeSignal.Release(); + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + + internal void Publish( + byte[] bytes + ) { + ArgumentNullException.ThrowIfNull( bytes ); + if ( !this.input.Writer.TryWrite( bytes.ToArray() ) ) { + throw new InvalidOperationException( + "The scripted terminal input channel is closed." + ); + } + } + + internal async Task WaitForWriteCountAsync( + int expected + ) { + if ( 0 > expected ) { + throw new ArgumentOutOfRangeException( nameof( expected ) ); + } + + using CancellationTokenSource timeout = new( + TimeSpan.FromSeconds( 5 ) + ); + while ( true ) { + lock ( this.synchronization ) { + if ( expected <= this.writes.Count ) { + return; + } + } + await this.writeSignal.WaitAsync( + timeout.Token + ).ConfigureAwait( false ); + } + } + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementCreationTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementCreationTests.cs new file mode 100644 index 000000000..17f45584c --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementCreationTests.cs @@ -0,0 +1,561 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Reflection; +using System.Text; +using System.Threading.Channels; +using Icod.Terminal; +using Icod.TermInfo; +using Xunit; + +/// +/// Defines the C115 public placement-creation and multi-placement ownership contract. +/// +public sealed class TerminalPersistentRasterPlacementCreationTests { + [Fact] + public void PublicPlacementCreationContractIsOpaque() { + Type optionsType = typeof( TerminalRasterPlacementOptions ); + Type placementType = typeof( TerminalRasterPlacement ); + + Assert.True( optionsType.IsPublic ); + Assert.True( optionsType.IsSealed ); + Assert.Equal( + typeof( int? ), + optionsType.GetProperty( nameof( TerminalRasterPlacementOptions.Columns ) )?.PropertyType + ); + Assert.Equal( + typeof( int? ), + optionsType.GetProperty( nameof( TerminalRasterPlacementOptions.Rows ) )?.PropertyType + ); + + Assert.True( placementType.IsPublic ); + Assert.True( placementType.IsSealed ); + Assert.Contains( typeof( IAsyncDisposable ), placementType.GetInterfaces() ); + Assert.DoesNotContain( + placementType.GetMembers( BindingFlags.Instance | BindingFlags.Public ), + static member => member.Name.Contains( "ImageId", StringComparison.Ordinal ) + || member.Name.Contains( "ImageNumber", StringComparison.Ordinal ) + || member.Name.Contains( "PlacementId", StringComparison.Ordinal ) + || member.Name.Contains( "Backend", StringComparison.Ordinal ) + || member.Name.Contains( "X", StringComparison.Ordinal ) + || member.Name.Contains( "Y", StringComparison.Ordinal ) + ); + + MethodInfo? create = typeof( TerminalRasterResource ).GetMethod( + "CreatePlacementAsync", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: [ + typeof( TerminalRasterPlacementOptions ), + typeof( CancellationToken ) + ], + modifiers: null + ); + Assert.NotNull( create ); + Assert.Equal( + typeof( ValueTask> ), + create.ReturnType + ); + } + + [Fact] + public async Task IntrinsicPlacementUsesCurrentCursorWithoutMovingIt() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + + TerminalControlResult result = + await resource.CreatePlacementAsync(); + await transport.WaitForWriteCountAsync( 2 ); + + Assert.Equal( TerminalControlStatus.Available, result.Status ); + Assert.NotNull( result.Value ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=77,p=1,C=1\u001b\\" + ), + transport.Writes[ 1 ] + ); + } + + [Fact] + public async Task PlacementCanSpecifyEitherOrBothCellExtents() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + + TerminalControlResult columns = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = 3 + } + ); + TerminalControlResult rows = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Rows = 2 + } + ); + TerminalControlResult both = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = 5, + Rows = 4 + } + ); + await transport.WaitForWriteCountAsync( 4 ); + + Assert.Equal( TerminalControlStatus.Available, columns.Status ); + Assert.Equal( TerminalControlStatus.Available, rows.Status ); + Assert.Equal( TerminalControlStatus.Available, both.Status ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=77,p=1,C=1,c=3\u001b\\" + ), + transport.Writes[ 1 ] + ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=77,p=2,C=1,r=2\u001b\\" + ), + transport.Writes[ 2 ] + ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=77,p=3,C=1,c=5,r=4\u001b\\" + ), + transport.Writes[ 3 ] + ); + } + + [Theory] + [InlineData( 0, null )] + [InlineData( -1, null )] + [InlineData( 16385, null )] + [InlineData( null, 0 )] + [InlineData( null, -1 )] + [InlineData( null, 16385 )] + public async Task InvalidPlacementExtentsAreRejectedBeforeOutput( + int? columns, + int? rows + ) { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + int baselineWrites = transport.Writes.Count; + + await Assert.ThrowsAsync( + async () => await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = columns, + Rows = rows + } + ) + ); + + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task PlacementExtentsAcceptReviewedUpperBound() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + + TerminalControlResult result = + await resource.CreatePlacementAsync( + new TerminalRasterPlacementOptions { + Columns = 16384, + Rows = 16384 + } + ); + await transport.WaitForWriteCountAsync( 2 ); + + Assert.Equal( TerminalControlStatus.Available, result.Status ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=77,p=1,C=1,c=16384,r=16384\u001b\\" + ), + transport.Writes[ 1 ] + ); + } + + [Fact] + public async Task MultiplePlacementsUseDistinctPrivatePlacementIdentities() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 91 + ); + + TerminalControlResult first = + await resource.CreatePlacementAsync(); + TerminalControlResult second = + await resource.CreatePlacementAsync(); + await transport.WaitForWriteCountAsync( 3 ); + + Assert.NotNull( first.Value ); + Assert.NotNull( second.Value ); + Assert.NotSame( first.Value, second.Value ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=91,p=1,C=1\u001b\\" + ), + transport.Writes[ 1 ] + ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=91,p=2,C=1\u001b\\" + ), + transport.Writes[ 2 ] + ); + } + + [Fact] + public async Task PreCanceledPlacementCreationEmitsNoPlacementOutput() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + int baselineWrites = transport.Writes.Count; + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await resource.CreatePlacementAsync( + options: null, + cancellation.Token + ) + ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task DisposedResourceRejectsNewPlacementBeforeOutput() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + await resource.DisposeAsync(); + int baselineWrites = transport.Writes.Count; + + await Assert.ThrowsAsync( + async () => await resource.CreatePlacementAsync() + ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + private static TerminalRasterImage CreateSmallImage() { + return TerminalRasterImage.CreateRgb24( + 1, + 1, + [ 1, 2, 3 ] + ); + } + + private static async Task CreateResourceAsync( + TerminalSession session, + ScriptedTransport transport, + uint imageId + ) { + ArgumentNullException.ThrowIfNull( session ); + ArgumentNullException.ThrowIfNull( transport ); + if ( 0u == imageId ) { + throw new ArgumentOutOfRangeException( nameof( imageId ) ); + } + + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( 1 ); + transport.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},I=1;OK\u001b\\" + ) + ); + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Available, result.Status ); + return Assert.IsType( result.Value ); + } + + private static ValueTask OpenSessionAsync( + ScriptedTransport transport + ) { + ArgumentNullException.ThrowIfNull( transport ); + return OpenSessionCoreAsync( transport ); + } + + private static async ValueTask OpenSessionCoreAsync( + ScriptedTransport transport + ) { + TerminalSession session = await TerminalSession.OpenAsync( + new RecordingTerminalControlProvider(), + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + transport, + transport, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + MonotonicClock = new FrozenMonotonicClock(), + ObserveLifecycleEvents = false, + RequireInteractiveOutput = false + } + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.ApcKittyGraphics, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + return session; + } + + private sealed class ScriptedTransport : ITerminalInput, ITerminalOutput { + private readonly Channel input = Channel.CreateUnbounded( + new UnboundedChannelOptions { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + } + ); + private readonly object synchronization = new(); + private readonly SemaphoreSlim writeSignal = new( 0 ); + private readonly List writes = []; + + internal IReadOnlyList Writes { + get { + lock ( this.synchronization ) { + return this.writes.Select( + static item => item.ToArray() + ).ToArray(); + } + } + } + + public async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + byte[] value = await this.input.Reader.ReadAsync( + cancellationToken + ).ConfigureAwait( false ); + if ( value.Length > buffer.Length ) { + throw new InvalidOperationException( + "The scripted response exceeds the terminal input buffer." + ); + } + + value.AsSpan().CopyTo( buffer.Span ); + return value.Length; + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + lock ( this.synchronization ) { + this.writes.Add( buffer.ToArray() ); + } + this.writeSignal.Release(); + this.PublishPlacementAcknowledgement( buffer.Span ); + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + + internal void Publish( + byte[] bytes + ) { + ArgumentNullException.ThrowIfNull( bytes ); + if ( !this.input.Writer.TryWrite( bytes.ToArray() ) ) { + throw new InvalidOperationException( + "The scripted terminal input channel is closed." + ); + } + } + + internal async Task WaitForWriteCountAsync( + int expected + ) { + if ( 0 > expected ) { + throw new ArgumentOutOfRangeException( nameof( expected ) ); + } + + using CancellationTokenSource timeout = new(); + timeout.CancelAfter( TimeSpan.FromSeconds( 5 ) ); + while ( this.Writes.Count < expected ) { + await this.writeSignal.WaitAsync( + timeout.Token + ).ConfigureAwait( false ); + } + } + + private void PublishPlacementAcknowledgement( + ReadOnlySpan frame + ) { + string text = Encoding.ASCII.GetString( frame ); + if ( !text.StartsWith( + "\u001b_Ga=p,", + StringComparison.Ordinal + ) || !TryReadIdentityField( + text, + ",i=", + out uint imageId + ) || !TryReadIdentityField( + text, + ",p=", + out uint placementId + ) ) { + return; + } + + this.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},p={placementId};OK\u001b\\" + ) + ); + } + + private static bool TryReadIdentityField( + string text, + string marker, + out uint value + ) { + ArgumentNullException.ThrowIfNull( text ); + ArgumentException.ThrowIfNullOrEmpty( marker ); + + int start = text.IndexOf( + marker, + StringComparison.Ordinal + ); + if ( 0 > start ) { + value = 0u; + return false; + } + start += marker.Length; + int end = start; + while ( end < text.Length + && text[ end ] is >= '0' and <= '9' ) { + ++end; + } + value = 0u; + return start < end + && uint.TryParse( + text.AsSpan( + start, + end - start + ), + out value + ) + ; + } + } + + private sealed class RecordingTerminalControlProvider : ITerminalControlProvider { + private readonly TerminalModeSnapshot baseline = TerminalModeSnapshot.CreatePosix( + 0, + 0, + 0, + 0x0002UL, + new byte[ 32 ], + 0, + 32, + 0, + new TerminalSpeed( 13, 9600 ), + new TerminalSpeed( 13, 9600 ) + ); + + public TerminalControlResult Observe( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + new TerminalEndpointObservation( + true, + null, + TerminalPlatformKind.PosixTermios, + TerminalControlCapabilities.Attachment + | TerminalControlCapabilities.ModeRead + | TerminalControlCapabilities.ModeWrite + ) + ); + } + + public TerminalControlResult GetSize( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Unavailable( + "No scripted live size." + ); + } + + public TerminalControlResult GetMode( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( this.baseline ); + } + + public TerminalControlMutationResult SetMode( + TerminalEndpoint endpoint, + TerminalModeSnapshot mode, + TerminalModeApplyTiming timing + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + ArgumentNullException.ThrowIfNull( mode ); + if ( !Enum.IsDefined( timing ) ) { + throw new ArgumentOutOfRangeException( nameof( timing ) ); + } + return TerminalControlMutationResult.Success(); + } + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementLifecycleTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementLifecycleTests.cs new file mode 100644 index 000000000..595f80502 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterPlacementLifecycleTests.cs @@ -0,0 +1,487 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Reflection; +using System.Text; +using System.Threading.Channels; +using Icod.Terminal; +using Icod.TermInfo; +using Xunit; + +/// +/// Defines the C116 placement-update and deterministic-cleanup contract. +/// +public sealed class TerminalPersistentRasterPlacementLifecycleTests { + [Fact] + public void PublicPlacementUpdateContractUsesExistingMutationResult() { + MethodInfo? update = typeof( TerminalRasterPlacement ).GetMethod( + "UpdateAsync", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: [ + typeof( TerminalRasterPlacementOptions ), + typeof( CancellationToken ) + ], + modifiers: null + ); + + Assert.NotNull( update ); + Assert.Equal( + typeof( ValueTask ), + update.ReturnType + ); + } + + [Fact] + public async Task UpdateReusesPlacementIdentityAtCurrentCursor() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + + TerminalControlMutationResult result = await placement.UpdateAsync( + new TerminalRasterPlacementOptions { + Columns = 4, + Rows = 3 + } + ); + await transport.WaitForWriteCountAsync( 3 ); + + Assert.True( result.Succeeded ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=p,i=77,p=1,C=1,c=4,r=3\u001b\\" + ), + transport.Writes[ 2 ] + ); + } + + [Theory] + [InlineData( 0, null )] + [InlineData( 16385, null )] + [InlineData( null, 0 )] + [InlineData( null, 16385 )] + public async Task InvalidUpdateExtentsAreRejectedBeforeOutput( + int? columns, + int? rows + ) { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + int baselineWrites = transport.Writes.Count; + + await Assert.ThrowsAsync( + async () => await placement.UpdateAsync( + new TerminalRasterPlacementOptions { + Columns = columns, + Rows = rows + } + ) + ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task DisposedPlacementRejectsUpdateBeforeOutput() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + await placement.DisposeAsync(); + int baselineWrites = transport.Writes.Count; + + await Assert.ThrowsAsync( + async () => await placement.UpdateAsync() + ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task PlacementDisposeSendsOneQuietSoftDelete() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + + await placement.DisposeAsync(); + await transport.WaitForWriteCountAsync( 3 ); + await placement.DisposeAsync(); + + Assert.Equal( 3, transport.Writes.Count ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=d,d=i,i=77,p=1,q=2\u001b\\" + ), + transport.Writes[ 2 ] + ); + } + + [Fact] + public async Task ResourceDisposeDeletesChildrenBeforeResourceData() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 91 + ); + TerminalRasterPlacement first = await CreatePlacementAsync( resource ); + TerminalRasterPlacement second = await CreatePlacementAsync( resource ); + + await resource.DisposeAsync(); + await transport.WaitForWriteCountAsync( 6 ); + + byte[] firstDelete = Encoding.ASCII.GetBytes( + "\u001b_Ga=d,d=i,i=91,p=1,q=2\u001b\\" + ); + byte[] secondDelete = Encoding.ASCII.GetBytes( + "\u001b_Ga=d,d=i,i=91,p=2,q=2\u001b\\" + ); + Assert.Contains( + transport.Writes.Skip( 3 ).Take( 2 ), + value => value.SequenceEqual( firstDelete ) + ); + Assert.Contains( + transport.Writes.Skip( 3 ).Take( 2 ), + value => value.SequenceEqual( secondDelete ) + ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=d,d=I,i=91,q=2\u001b\\" + ), + transport.Writes[ 5 ] + ); + + int baselineWrites = transport.Writes.Count; + await first.DisposeAsync(); + await second.DisposeAsync(); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task ParentDisposalMakesChildUpdateUnavailableWithoutOutput() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + await resource.DisposeAsync(); + int baselineWrites = transport.Writes.Count; + + TerminalControlMutationResult result = await placement.UpdateAsync(); + + Assert.Equal( TerminalControlStatus.Unavailable, result.Status ); + Assert.Equal( baselineWrites, transport.Writes.Count ); + } + + [Fact] + public async Task FailedPlacementCleanupIsNotRetried() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 77 + ); + TerminalRasterPlacement placement = await CreatePlacementAsync( resource ); + transport.FailOnWriteNumber = 3; + + await Assert.ThrowsAsync( + async () => await placement.DisposeAsync() + ); + Assert.Equal( 3, transport.Writes.Count ); + + await placement.DisposeAsync(); + Assert.Equal( 3, transport.Writes.Count ); + } + + [Fact] + public async Task ResourceCleanupContinuesAfterOneChildDeleteFailure() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport ); + TerminalRasterResource resource = await CreateResourceAsync( + session, + transport, + imageId: 91 + ); + _ = await CreatePlacementAsync( resource ); + _ = await CreatePlacementAsync( resource ); + transport.FailOnWriteNumber = 4; + + await Assert.ThrowsAnyAsync( + async () => await resource.DisposeAsync() + ); + await transport.WaitForWriteCountAsync( 6 ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=d,d=I,i=91,q=2\u001b\\" + ), + transport.Writes[ 5 ] + ); + } + + private static async Task CreatePlacementAsync( + TerminalRasterResource resource + ) { + ArgumentNullException.ThrowIfNull( resource ); + TerminalControlResult result = + await resource.CreatePlacementAsync(); + Assert.Equal( TerminalControlStatus.Available, result.Status ); + return Assert.IsType( result.Value ); + } + + private static TerminalRasterImage CreateSmallImage() { + return TerminalRasterImage.CreateRgb24( + 1, + 1, + [ 1, 2, 3 ] + ); + } + + private static async Task CreateResourceAsync( + TerminalSession session, + ScriptedTransport transport, + uint imageId + ) { + ArgumentNullException.ThrowIfNull( session ); + ArgumentNullException.ThrowIfNull( transport ); + if ( 0u == imageId ) { + throw new ArgumentOutOfRangeException( nameof( imageId ) ); + } + + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( 1 ); + transport.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},I=1;OK\u001b\\" + ) + ); + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Available, result.Status ); + return Assert.IsType( result.Value ); + } + + private static async ValueTask OpenSessionAsync( + ScriptedTransport transport + ) { + ArgumentNullException.ThrowIfNull( transport ); + TerminalSession session = await TerminalSession.OpenAsync( + new RecordingTerminalControlProvider(), + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + transport, + transport, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + MonotonicClock = new FrozenMonotonicClock(), + ObserveLifecycleEvents = false, + RequireInteractiveOutput = false + } + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.ApcKittyGraphics, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + return session; + } + + private sealed class ScriptedTransport : ITerminalInput, ITerminalOutput { + private readonly Channel input = Channel.CreateUnbounded( + new UnboundedChannelOptions { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + } + ); + private readonly object synchronization = new(); + private readonly SemaphoreSlim writeSignal = new( 0 ); + private readonly List writes = []; + private int writeNumber; + + internal int? FailOnWriteNumber { + get; + set; + } + + internal IReadOnlyList Writes { + get { + lock ( this.synchronization ) { + return this.writes.Select( + static item => item.ToArray() + ).ToArray(); + } + } + } + + public async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + byte[] value = await this.input.Reader.ReadAsync( + cancellationToken + ).ConfigureAwait( false ); + if ( value.Length > buffer.Length ) { + throw new InvalidOperationException( + "The scripted response exceeds the terminal input buffer." + ); + } + + value.AsSpan().CopyTo( buffer.Span ); + return value.Length; + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + int current = Interlocked.Increment( ref this.writeNumber ); + lock ( this.synchronization ) { + this.writes.Add( buffer.ToArray() ); + } + this.writeSignal.Release(); + if ( this.FailOnWriteNumber == current ) { + throw new IOException( "Synthetic persistent raster cleanup failure." ); + } + this.PublishPlacementAcknowledgement( buffer.Span ); + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + + internal void Publish( + byte[] value + ) { + ArgumentNullException.ThrowIfNull( value ); + if ( !this.input.Writer.TryWrite( value.ToArray() ) ) { + throw new InvalidOperationException( + "The scripted terminal input channel rejected a response." + ); + } + } + + internal async Task WaitForWriteCountAsync( + int count + ) { + if ( 0 > count ) { + throw new ArgumentOutOfRangeException( nameof( count ) ); + } + + while ( true ) { + lock ( this.synchronization ) { + if ( count <= this.writes.Count ) { + return; + } + } + await this.writeSignal.WaitAsync().ConfigureAwait( false ); + } + } + + private void PublishPlacementAcknowledgement( + ReadOnlySpan frame + ) { + string text = Encoding.ASCII.GetString( frame ); + if ( !text.StartsWith( + "\u001b_Ga=p,", + StringComparison.Ordinal + ) || !TryReadIdentityField( + text, + ",i=", + out uint imageId + ) || !TryReadIdentityField( + text, + ",p=", + out uint placementId + ) ) { + return; + } + + this.Publish( + Encoding.ASCII.GetBytes( + $"\u001b_Gi={imageId},p={placementId};OK\u001b\\" + ) + ); + } + + private static bool TryReadIdentityField( + string text, + string marker, + out uint value + ) { + ArgumentNullException.ThrowIfNull( text ); + ArgumentException.ThrowIfNullOrEmpty( marker ); + + int start = text.IndexOf( + marker, + StringComparison.Ordinal + ); + if ( 0 > start ) { + value = 0u; + return false; + } + start += marker.Length; + int end = start; + while ( end < text.Length + && text[ end ] is >= '0' and <= '9' ) { + ++end; + } + value = 0u; + return start < end + && uint.TryParse( + text.AsSpan( + start, + end - start + ), + out value + ) + ; + } + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterRegistryTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterRegistryTests.cs new file mode 100644 index 000000000..9f8d5e5fe --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterRegistryTests.cs @@ -0,0 +1,322 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Collections.Concurrent; +using System.Reflection; +using Xunit; + +/// +/// Defines the C113 bounded persistent-raster ownership registry contract. +/// +public sealed class TerminalPersistentRasterRegistryTests { + [Fact] + public void ResourceImageNumbersAreNonzeroAndWrapWithoutCollision() { + TerminalPersistentRasterRegistry registry = new( + initialImageNumber: uint.MaxValue + ); + + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? maximum + ) + ); + Assert.NotNull( maximum ); + Assert.Equal( uint.MaxValue, maximum.ImageNumber ); + + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? wrapped + ) + ); + Assert.NotNull( wrapped ); + Assert.Equal( 1u, wrapped.ImageNumber ); + Assert.NotEqual( maximum.ImageNumber, wrapped.ImageNumber ); + } + + [Fact] + public void ResourceCapacityIsBoundedAtTwoHundredFiftySix() { + TerminalPersistentRasterRegistry registry = new(); + HashSet imageNumbers = []; + + for ( int index = 0; index < TerminalPersistentRasterRegistry.MaximumResources; ++index ) { + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? resource + ) + ); + Assert.NotNull( resource ); + Assert.NotEqual( 0u, resource.ImageNumber ); + Assert.True( imageNumbers.Add( resource.ImageNumber ) ); + } + + Assert.Equal( + TerminalPersistentRasterRegistry.MaximumResources, + registry.LiveResourceCount + ); + Assert.False( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? unavailable + ) + ); + Assert.Null( unavailable ); + } + + [Fact] + public void PlacementIdsAreNonzeroWrapAndUseOneSessionWideCapacity() { + TerminalPersistentRasterRegistry registry = new( + initialPlacementId: uint.MaxValue + ); + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? firstResource + ) + ); + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? secondResource + ) + ); + Assert.NotNull( firstResource ); + Assert.NotNull( secondResource ); + + Assert.True( + registry.TryReservePlacement( + firstResource, + out TerminalPersistentRasterPlacementState? maximum + ) + ); + Assert.NotNull( maximum ); + Assert.Equal( uint.MaxValue, maximum.PlacementId ); + + Assert.True( + registry.TryReservePlacement( + secondResource, + out TerminalPersistentRasterPlacementState? wrapped + ) + ); + Assert.NotNull( wrapped ); + Assert.Equal( 1u, wrapped.PlacementId ); + + for ( int index = 2; index < TerminalPersistentRasterRegistry.MaximumPlacements; ++index ) { + Assert.True( + registry.TryReservePlacement( + firstResource, + out TerminalPersistentRasterPlacementState? placement + ) + ); + Assert.NotNull( placement ); + Assert.NotEqual( 0u, placement.PlacementId ); + } + + Assert.Equal( + TerminalPersistentRasterRegistry.MaximumPlacements, + registry.LivePlacementCount + ); + Assert.False( + registry.TryReservePlacement( + secondResource, + out TerminalPersistentRasterPlacementState? unavailable + ) + ); + Assert.Null( unavailable ); + } + + [Fact] + public void MultiplePlacementsRemainAssociatedWithTheirParent() { + TerminalPersistentRasterRegistry registry = new(); + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? resource + ) + ); + Assert.NotNull( resource ); + + Assert.True( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? first + ) + ); + Assert.True( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? second + ) + ); + Assert.NotNull( first ); + Assert.NotNull( second ); + Assert.Same( resource, first.Resource ); + Assert.Same( resource, second.Resource ); + Assert.NotEqual( first.PlacementId, second.PlacementId ); + } + + [Fact] + public void ResourceReleaseIsIdempotentAndBlocksNewChildren() { + TerminalPersistentRasterRegistry registry = new(); + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? resource + ) + ); + Assert.NotNull( resource ); + Assert.True( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? placement + ) + ); + Assert.NotNull( placement ); + + Assert.True( registry.TryReleaseResource( resource ) ); + Assert.True( resource.IsClosed ); + Assert.True( placement.IsClosed ); + Assert.Equal( 0, registry.LiveResourceCount ); + Assert.Equal( 0, registry.LivePlacementCount ); + Assert.False( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? unavailable + ) + ); + Assert.Null( unavailable ); + Assert.False( registry.TryReleaseResource( resource ) ); + } + + [Fact] + public void PlacementReleaseIsIdempotentAndMonotonicAllocationAvoidsLiveReuse() { + TerminalPersistentRasterRegistry registry = new(); + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? resource + ) + ); + Assert.NotNull( resource ); + Assert.True( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? first + ) + ); + Assert.True( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? second + ) + ); + Assert.NotNull( first ); + Assert.NotNull( second ); + + Assert.True( registry.TryReleasePlacement( first ) ); + Assert.True( first.IsClosed ); + Assert.False( registry.TryReleasePlacement( first ) ); + Assert.True( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? third + ) + ); + Assert.NotNull( third ); + Assert.NotEqual( second.PlacementId, third.PlacementId ); + Assert.NotEqual( first.PlacementId, third.PlacementId ); + } + + [Fact] + public void ResourceAndPlacementStatesCarryTheRegistryGeneration() { + TerminalPersistentRasterRegistry registry = new( + initialGeneration: 37 + ); + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? resource + ) + ); + Assert.NotNull( resource ); + Assert.True( + registry.TryReservePlacement( + resource, + out TerminalPersistentRasterPlacementState? placement + ) + ); + Assert.NotNull( placement ); + + Assert.Equal( 37L, registry.Generation ); + Assert.Equal( registry.Generation, resource.Generation ); + Assert.Equal( registry.Generation, placement.Generation ); + } + + [Fact] + public async Task ConcurrentResourceReservationProducesUniqueBoundedOwnership() { + TerminalPersistentRasterRegistry registry = new(); + ConcurrentBag identities = []; + Task[] reservations = Enumerable.Range( + 0, + TerminalPersistentRasterRegistry.MaximumResources + ).Select( + _ => Task.Run( + () => { + Assert.True( + registry.TryReserveResource( + out TerminalPersistentRasterResourceState? resource + ) + ); + Assert.NotNull( resource ); + identities.Add( resource.ImageNumber ); + } + ) + ).ToArray(); + + await Task.WhenAll( reservations ); + + Assert.Equal( + TerminalPersistentRasterRegistry.MaximumResources, + identities.Count + ); + Assert.Equal( + TerminalPersistentRasterRegistry.MaximumResources, + identities.Distinct().Count() + ); + Assert.DoesNotContain( 0u, identities ); + Assert.Equal( + TerminalPersistentRasterRegistry.MaximumResources, + registry.LiveResourceCount + ); + } + + [Fact] + public void OwnershipStateDoesNotRetainRasterPayloadTypes() { + Type[] stateTypes = [ + typeof( TerminalPersistentRasterResourceState ), + typeof( TerminalPersistentRasterPlacementState ) + ]; + + foreach ( Type stateType in stateTypes ) { + FieldInfo[] fields = stateType.GetFields( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ); + Assert.DoesNotContain( + fields, + static field => typeof( TerminalRasterImage ) == field.FieldType + || typeof( byte[] ) == field.FieldType + ); + } + } +} diff --git a/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterResourceTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterResourceTests.cs new file mode 100644 index 000000000..022c4b8c8 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Graphics/TerminalPersistentRasterResourceTests.cs @@ -0,0 +1,659 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Graphics; + +using System.Reflection; +using System.Text; +using System.Threading.Channels; +using Icod.Terminal; +using Icod.TermInfo; +using Icod.Timing; +using Xunit; + +/// +/// Defines the C114 public persistent-resource and acknowledged-upload contract. +/// +public sealed class TerminalPersistentRasterResourceTests { + [Fact] + public void PublicResourceContractIsOpaqueAndSessionOwned() { + Type resourceType = typeof( TerminalRasterResource ); + + Assert.True( resourceType.IsPublic ); + Assert.True( resourceType.IsSealed ); + Assert.Contains( typeof( IAsyncDisposable ), resourceType.GetInterfaces() ); + Assert.DoesNotContain( + resourceType.GetMembers( BindingFlags.Instance | BindingFlags.Public ), + static member => member.Name.Contains( "ImageId", StringComparison.Ordinal ) + || member.Name.Contains( "ImageNumber", StringComparison.Ordinal ) + || member.Name.Contains( "PlacementId", StringComparison.Ordinal ) + || member.Name.Contains( "Backend", StringComparison.Ordinal ) + ); + + MethodInfo? create = typeof( TerminalSession ).GetMethod( + "CreateRasterResourceAsync", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: [ + typeof( TerminalRasterImage ), + typeof( CancellationToken ) + ], + modifiers: null + ); + Assert.NotNull( create ); + Assert.Equal( + typeof( ValueTask> ), + create.ReturnType + ); + } + + [Fact] + public async Task NullImageIsRejectedBeforeOutput() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + SeedKittyVerified( session ); + + await Assert.ThrowsAsync( + async () => await session.CreateRasterResourceAsync( null! ) + ); + Assert.Empty( transport.Writes ); + } + + [Fact] + public async Task PreCancellationIsRejectedBeforeOutput() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + SeedKittyVerified( session ); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await session.CreateRasterResourceAsync( + CreateSmallImage(), + cancellation.Token + ) + ); + Assert.Empty( transport.Writes ); + } + + [Fact] + public async Task RedirectedOutputReturnsUnavailableWithoutProtocolTraffic() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RedirectedOutputControlProvider(), + transport, + new ManualMonotonicClock() + ); + + TerminalControlResult result = + await session.CreateRasterResourceAsync( CreateSmallImage() ); + + Assert.Equal( TerminalControlStatus.Unavailable, result.Status ); + Assert.Null( result.Value ); + Assert.Empty( transport.Writes ); + } + + [Fact] + public async Task KnownKittyUnsupportedReturnsUnsupportedWithoutSixelFallback() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.DcsSixel, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.ApcKittyGraphics, + TerminalCapabilitySupportState.Unsupported, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + + TerminalControlResult result = + await session.CreateRasterResourceAsync( CreateSmallImage() ); + + Assert.Equal( TerminalControlStatus.Unsupported, result.Status ); + Assert.Null( result.Value ); + Assert.Empty( transport.Writes ); + } + + [Fact] + public async Task MatchingAcknowledgementPublishesOpaqueResource() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + SeedKittyVerified( session ); + + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( 1 ); + Assert.Equal( + Encoding.ASCII.GetBytes( + "\u001b_Ga=t,f=24,s=1,v=1,t=d,I=1,m=0;AQID\u001b\\" + ), + transport.Writes[ 0 ] + ); + + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=77,I=1;OK\u001b\\" ) + ); + TerminalControlResult result = await creation; + + Assert.Equal( TerminalControlStatus.Available, result.Status ); + Assert.NotNull( result.Value ); + } + + [Fact] + public async Task WrongImageNumberRemainsUnrelatedUntilMatchingAcknowledgementArrives() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + SeedKittyVerified( session ); + + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( 1 ); + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=88,I=2;OK\u001b\\" ) + ); + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=77,I=1;OK\u001b\\" ) + ); + + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Available, result.Status ); + Assert.NotNull( result.Value ); + } + + [Fact] + public async Task MalformedCorrelatedAcknowledgementThrowsFormatException() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + SeedKittyVerified( session ); + + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( 1 ); + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=x,I=1;OK\u001b\\" ) + ); + + await Assert.ThrowsAsync( () => creation ); + } + + [Fact] + public async Task WellFormedNegativeAcknowledgementReturnsFailedWithoutResource() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + SeedKittyVerified( session ); + + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( 1 ); + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=77,I=1;EINVAL:synthetic failure\u001b\\" ) + ); + + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Failed, result.Status ); + Assert.Null( result.Value ); + } + + [Fact] + public async Task EnoentAcknowledgementReturnsUnavailableWithoutResource() { + ScriptedTransport transport = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + SeedKittyVerified( session ); + + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( 1 ); + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_GI=1;ENOENT:synthetic missing resource\u001b\\" ) + ); + + TerminalControlResult result = await creation; + Assert.Equal( TerminalControlStatus.Unavailable, result.Status ); + Assert.Null( result.Value ); + } + + [Fact] + public async Task CorrelatedTimeoutThrowsAndDoesNotPublishResource() { + ScriptedTransport transport = new(); + ManualMonotonicClock clock = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + clock + ); + SeedKittyVerified( session ); + + Task> creation = + session.CreateRasterResourceAsync( CreateSmallImage() ).AsTask(); + await transport.WaitForWriteCountAsync( 1 ); + clock.Advance( TimeSpan.FromSeconds( 2 ) ); + + await Assert.ThrowsAsync( () => creation ); + } + + [Fact] + public async Task CancellationAfterFirstCommittedFrameDoesNotTruncateUpload() { + using CancellationTokenSource cancellation = new(); + ScriptedTransport transport = new( + onFirstWrite: cancellation.Cancel + ); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + transport, + new ManualMonotonicClock() + ); + SeedKittyVerified( session ); + byte[] pixels = new byte[ 1025 * 3 ]; + TerminalRasterImage image = TerminalRasterImage.CreateRgb24( + 1025, + 1, + pixels + ); + + Task> creation = + session.CreateRasterResourceAsync( + image, + cancellation.Token + ).AsTask(); + await transport.WaitForWriteCountAsync( 2 ); + + await Assert.ThrowsAnyAsync( () => creation ); + Assert.Equal( 2, transport.Writes.Count ); + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=77,I=1;OK\u001b\\" ) + ); + } + + private static TerminalRasterImage CreateSmallImage() { + return TerminalRasterImage.CreateRgb24( + 1, + 1, + [ 1, 2, 3 ] + ); + } + + private static void SeedKittyVerified( + TerminalSession session + ) { + ArgumentNullException.ThrowIfNull( session ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.ApcKittyGraphics, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + } + + private static ValueTask OpenSessionAsync( + ITerminalControlProvider controlProvider, + ScriptedTransport transport, + IMonotonicClock clock + ) { + ArgumentNullException.ThrowIfNull( controlProvider ); + ArgumentNullException.ThrowIfNull( transport ); + ArgumentNullException.ThrowIfNull( clock ); + + return TerminalSession.OpenAsync( + controlProvider, + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + transport, + transport, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + ObserveLifecycleEvents = false, + RequireInteractiveOutput = false, + MonotonicClock = clock + } + ); + } + + private sealed class ScriptedTransport : ITerminalInput, ITerminalOutput { + private readonly Channel input = Channel.CreateUnbounded( + new UnboundedChannelOptions { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + } + ); + private readonly object synchronization = new(); + private readonly SemaphoreSlim writeSignal = new( 0 ); + private readonly Action? onFirstWrite; + private readonly List writes = []; + + internal ScriptedTransport( + Action? onFirstWrite = null + ) { + this.onFirstWrite = onFirstWrite; + } + + internal IReadOnlyList Writes { + get { + lock ( this.synchronization ) { + return this.writes.Select( + static item => item.ToArray() + ).ToArray(); + } + } + } + + public async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + byte[] value = await this.input.Reader.ReadAsync( + cancellationToken + ).ConfigureAwait( false ); + if ( value.Length > buffer.Length ) { + throw new InvalidOperationException( + "The scripted response exceeds the terminal input buffer." + ); + } + + value.AsSpan().CopyTo( buffer.Span ); + return value.Length; + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + bool first; + lock ( this.synchronization ) { + first = 0 == this.writes.Count; + this.writes.Add( buffer.ToArray() ); + } + this.writeSignal.Release(); + if ( first ) { + this.onFirstWrite?.Invoke(); + } + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + + internal void Publish( + byte[] bytes + ) { + ArgumentNullException.ThrowIfNull( bytes ); + if ( !this.input.Writer.TryWrite( bytes.ToArray() ) ) { + throw new InvalidOperationException( + "The scripted terminal input channel is closed." + ); + } + } + + internal async Task WaitForWriteCountAsync( + int expected + ) { + if ( 0 > expected ) { + throw new ArgumentOutOfRangeException( nameof( expected ) ); + } + + using CancellationTokenSource timeout = new(); + timeout.CancelAfter( TimeSpan.FromSeconds( 5 ) ); + while ( true ) { + lock ( this.synchronization ) { + if ( expected <= this.writes.Count ) { + return; + } + } + await this.writeSignal.WaitAsync( + timeout.Token + ).ConfigureAwait( false ); + } + } + } + + private sealed class ManualMonotonicClock : IMonotonicClock { + private readonly object synchronization = new(); + private readonly List waiters = []; + private long timestamp; + + public long GetTimestamp() { + lock ( this.synchronization ) { + return this.timestamp; + } + } + + public TimeSpan GetElapsedTime( + long startingTimestamp, + long endingTimestamp + ) { + return TimeSpan.FromTicks( + endingTimestamp - startingTimestamp + ); + } + + public ValueTask DelayAsync( + TimeSpan delay, + CancellationToken cancellationToken = default + ) { + if ( TimeSpan.Zero > delay ) { + throw new ArgumentOutOfRangeException( nameof( delay ) ); + } + cancellationToken.ThrowIfCancellationRequested(); + if ( TimeSpan.Zero == delay ) { + return ValueTask.CompletedTask; + } + + return new ValueTask( + this.DelayCoreAsync( + delay, + cancellationToken + ) + ); + } + + internal void Advance( + TimeSpan elapsed + ) { + if ( TimeSpan.Zero > elapsed ) { + throw new ArgumentOutOfRangeException( nameof( elapsed ) ); + } + + List due; + lock ( this.synchronization ) { + this.timestamp = checked( + this.timestamp + elapsed.Ticks + ); + due = this.waiters + .Where( + waiter => waiter.DueTimestamp <= this.timestamp + ).ToList(); + } + + foreach ( DelayWaiter waiter in due ) { + waiter.Completion.TrySetResult(); + } + } + + private async Task DelayCoreAsync( + TimeSpan delay, + CancellationToken cancellationToken + ) { + DelayWaiter waiter; + lock ( this.synchronization ) { + waiter = new DelayWaiter( + checked( this.timestamp + delay.Ticks ) + ); + this.waiters.Add( waiter ); + } + + using CancellationTokenRegistration registration = cancellationToken.Register( + static state => { + var tuple = (Tuple)state!; + tuple.Item1.TrySetCanceled( tuple.Item2 ); + }, + Tuple.Create( + waiter.Completion, + cancellationToken + ) + ); + + try { + await waiter.Completion.Task.ConfigureAwait( false ); + } finally { + lock ( this.synchronization ) { + this.waiters.Remove( waiter ); + } + } + } + + private sealed class DelayWaiter { + internal DelayWaiter( + long dueTimestamp + ) { + this.DueTimestamp = dueTimestamp; + } + + internal long DueTimestamp { + get; + } + + internal TaskCompletionSource Completion { + get; + } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + } + } + + private class RecordingTerminalControlProvider : ITerminalControlProvider { + private readonly TerminalModeSnapshot baseline = TerminalModeSnapshot.CreatePosix( + 0, + 0, + 0, + 0x0002UL, + new byte[ 32 ], + 0, + 32, + 0, + new TerminalSpeed( 13, 9600 ), + new TerminalSpeed( 13, 9600 ) + ); + + public virtual TerminalControlResult Observe( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + new TerminalEndpointObservation( + true, + null, + TerminalPlatformKind.PosixTermios, + TerminalControlCapabilities.Attachment + | TerminalControlCapabilities.ModeRead + | TerminalControlCapabilities.ModeWrite + ) + ); + } + + public TerminalControlResult GetSize( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Unsupported( + "Size is not required by this test." + ); + } + + public TerminalControlResult GetMode( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( this.baseline ); + } + + public TerminalControlMutationResult SetMode( + TerminalEndpoint endpoint, + TerminalModeSnapshot mode, + TerminalModeApplyTiming timing + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + ArgumentNullException.ThrowIfNull( mode ); + if ( !Enum.IsDefined( timing ) ) { + throw new ArgumentOutOfRangeException( nameof( timing ) ); + } + return TerminalControlMutationResult.Success(); + } + } + + private sealed class RedirectedOutputControlProvider : RecordingTerminalControlProvider { + public override TerminalControlResult Observe( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + bool isInput = ReferenceEquals( endpoint, TerminalEndpoint.StandardInput ); + return TerminalControlResult.Available( + new TerminalEndpointObservation( + isInput, + null, + isInput ? TerminalPlatformKind.PosixTermios : null, + isInput + ? TerminalControlCapabilities.Attachment + | TerminalControlCapabilities.ModeRead + | TerminalControlCapabilities.ModeWrite + : TerminalControlCapabilities.None + ) + ); + } + } +} diff --git a/tests/Icod.Terminal.Tests/src/Input/TerminalDecrqssQueryTests.cs b/tests/Icod.Terminal.Tests/src/Input/TerminalDecrqssQueryTests.cs index 79fcb19c0..80afe416d 100644 --- a/tests/Icod.Terminal.Tests/src/Input/TerminalDecrqssQueryTests.cs +++ b/tests/Icod.Terminal.Tests/src/Input/TerminalDecrqssQueryTests.cs @@ -284,8 +284,12 @@ await Assert.ThrowsAsync( [Fact] public async Task CancellationRetainsLateDcsOwnership() { + ManualMonotonicClock clock = new(); DcsTransport transport = new(); - await using TerminalSession session = await OpenSessionAsync( transport ); + await using TerminalSession session = await OpenSessionAsync( + transport, + clock + ); using CancellationTokenSource cancellation = new(); Task first = session.QueryStatusStringAsync( diff --git a/tests/Icod.Terminal.Tests/src/Session/TerminalCapabilityStatusTests.cs b/tests/Icod.Terminal.Tests/src/Session/TerminalCapabilityStatusTests.cs index 952972412..d96679d2e 100644 --- a/tests/Icod.Terminal.Tests/src/Session/TerminalCapabilityStatusTests.cs +++ b/tests/Icod.Terminal.Tests/src/Session/TerminalCapabilityStatusTests.cs @@ -38,7 +38,8 @@ public void CapabilityVocabularyIsCurated() { TerminalCapability.MouseReporting, TerminalCapability.FocusReporting, TerminalCapability.BracketedPaste, - TerminalCapability.RasterGraphics + TerminalCapability.RasterGraphics, + TerminalCapability.PersistentRasterGraphics ], Enum.GetValues() ); diff --git a/tests/Icod.Terminal.Tests/src/Session/TerminalPersistentRasterCapabilityTests.cs b/tests/Icod.Terminal.Tests/src/Session/TerminalPersistentRasterCapabilityTests.cs new file mode 100644 index 000000000..7a7b84593 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Session/TerminalPersistentRasterCapabilityTests.cs @@ -0,0 +1,301 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Session; + +using System.Text; +using Icod.Terminal; +using Icod.TermInfo; +using Xunit; + +/// +/// Defines the C112 semantic capability contract for persistent raster ownership. +/// +public sealed class TerminalPersistentRasterCapabilityTests { + [Fact] + public void PersistentRasterCapabilityAppendsAtNine() { + Assert.Equal( 0, (int)TerminalCapability.ClipboardRead ); + Assert.Equal( 1, (int)TerminalCapability.ClipboardWrite ); + Assert.Equal( 2, (int)TerminalCapability.CursorStyle ); + Assert.Equal( 3, (int)TerminalCapability.SynchronizedOutput ); + Assert.Equal( 4, (int)TerminalCapability.KeyboardReporting ); + Assert.Equal( 5, (int)TerminalCapability.MouseReporting ); + Assert.Equal( 6, (int)TerminalCapability.FocusReporting ); + Assert.Equal( 7, (int)TerminalCapability.BracketedPaste ); + Assert.Equal( 8, (int)TerminalCapability.RasterGraphics ); + Assert.Equal( 9, (int)TerminalCapability.PersistentRasterGraphics ); + } + + [Fact] + public void PersistentRasterSemanticOperationUsesOnlyKittyGraphics() { + IReadOnlyList candidates = + TerminalSemanticBackendRegistry.GetCandidates( + TerminalSemanticOperation.PersistentRasterGraphics + ); + + TerminalSemanticBackendCandidate candidate = Assert.Single( candidates ); + Assert.Equal( + TerminalProtocolBackend.ApcKittyGraphics, + candidate.Backend + ); + } + + [Fact] + public async Task VerifiedSixelDoesNotVerifyPersistentRaster() { + RecordingOutput output = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + output + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.DcsSixel, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + + TerminalCapabilityStatus raster = session.InspectCapability( + TerminalCapability.RasterGraphics + ); + TerminalCapabilityStatus persistent = session.InspectCapability( + TerminalCapability.PersistentRasterGraphics + ); + + Assert.Equal( TerminalCapabilitySupport.Verified, raster.Support ); + Assert.True( raster.IsUsable ); + Assert.Equal( TerminalCapabilitySupport.Unknown, persistent.Support ); + Assert.Equal( TerminalCapabilityEvidenceKind.None, persistent.EvidenceKind ); + Assert.False( persistent.IsUsable ); + Assert.Empty( output.Bytes ); + } + + [Fact] + public async Task VerifiedKittyVerifiesBothRasterCapabilitiesWithoutInspectionIo() { + RecordingOutput output = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + output + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.ApcKittyGraphics, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + + TerminalCapabilityStatus raster = session.InspectCapability( + TerminalCapability.RasterGraphics + ); + TerminalCapabilityStatus persistent = session.InspectCapability( + TerminalCapability.PersistentRasterGraphics + ); + + Assert.Equal( TerminalCapabilitySupport.Verified, raster.Support ); + Assert.Equal( TerminalCapabilitySupport.Verified, persistent.Support ); + Assert.Equal( + TerminalCapabilityEvidenceKind.LiveObservation, + persistent.EvidenceKind + ); + Assert.True( persistent.IsUsable ); + Assert.Empty( output.Bytes ); + } + + [Fact] + public async Task RedirectedOutputMakesPersistentRasterUnavailableWithoutProbe() { + RecordingOutput output = new(); + await using TerminalSession session = await OpenSessionAsync( + new RedirectedOutputControlProvider(), + output + ); + + TerminalCapabilityStatus status = session.InspectCapability( + TerminalCapability.PersistentRasterGraphics + ); + + Assert.Equal( TerminalCapabilitySupport.Unknown, status.Support ); + Assert.Equal( + TerminalCapabilityEndpointAvailability.Unavailable, + status.EndpointAvailability + ); + Assert.False( status.IsUsable ); + Assert.Empty( output.Bytes ); + } + + [Fact] + public async Task PersistentVerificationUsesKittyProbeRatherThanSixelProbe() { + RecordingOutput output = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), + output + ); + session.RecordSemanticBackendEvidence( + TerminalProtocolBackend.DcsSixel, + TerminalCapabilitySupportState.Verified, + TerminalCapabilityEvidenceSource.ProtocolResponse + ); + + TerminalCapabilityStatus status = await session.VerifyCapabilityAsync( + TerminalCapability.PersistentRasterGraphics + ); + + Assert.Equal( TerminalCapabilitySupport.Unknown, status.Support ); + byte[] bytes = output.Bytes.ToArray(); + Assert.NotEmpty( bytes ); + string ascii = Encoding.ASCII.GetString( bytes ); + Assert.Contains( "a=q,t=d,f=24;AAAA", ascii, StringComparison.Ordinal ); + Assert.DoesNotContain( "?1;1;0q", ascii, StringComparison.Ordinal ); + } + + private static ValueTask OpenSessionAsync( + ITerminalControlProvider controlProvider, + ITerminalOutput output + ) { + ArgumentNullException.ThrowIfNull( controlProvider ); + ArgumentNullException.ThrowIfNull( output ); + return TerminalSession.OpenAsync( + controlProvider, + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + new EmptyTerminalInput(), + output, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + ObserveLifecycleEvents = false, + RequireInteractiveOutput = false + } + ); + } + + private sealed class EmptyTerminalInput : ITerminalInput { + public ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult( 0 ); + } + } + + private sealed class RecordingOutput : ITerminalOutput { + private readonly List bytes = []; + + internal IReadOnlyList Bytes { + get { + return this.bytes; + } + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + this.bytes.AddRange( buffer.ToArray() ); + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + } + + private class RecordingTerminalControlProvider : ITerminalControlProvider { + private readonly TerminalModeSnapshot baseline = TerminalModeSnapshot.CreatePosix( + 0, + 0, + 0, + 0x0002UL, + new byte[ 32 ], + 0, + 32, + 0, + new TerminalSpeed( 13, 9600 ), + new TerminalSpeed( 13, 9600 ) + ); + + public virtual TerminalControlResult Observe( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + new TerminalEndpointObservation( + true, + null, + TerminalPlatformKind.PosixTermios, + TerminalControlCapabilities.Attachment + | TerminalControlCapabilities.ModeRead + | TerminalControlCapabilities.ModeWrite + ) + ); + } + + public TerminalControlResult GetSize( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Unsupported( + "Size is not required by this test." + ); + } + + public TerminalControlResult GetMode( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( this.baseline ); + } + + public TerminalControlMutationResult SetMode( + TerminalEndpoint endpoint, + TerminalModeSnapshot mode, + TerminalModeApplyTiming timing + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + ArgumentNullException.ThrowIfNull( mode ); + if ( !Enum.IsDefined( timing ) ) { + throw new ArgumentOutOfRangeException( nameof( timing ) ); + } + return TerminalControlMutationResult.Success(); + } + } + + private sealed class RedirectedOutputControlProvider : RecordingTerminalControlProvider { + public override TerminalControlResult Observe( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + bool isInput = ReferenceEquals( endpoint, TerminalEndpoint.StandardInput ); + return TerminalControlResult.Available( + new TerminalEndpointObservation( + isInput, + null, + isInput ? TerminalPlatformKind.PosixTermios : null, + isInput + ? TerminalControlCapabilities.Attachment + | TerminalControlCapabilities.ModeRead + | TerminalControlCapabilities.ModeWrite + : TerminalControlCapabilities.None + ) + ); + } + } +} diff --git a/tools/package-persistent-raster-smoke/Icod.Terminal.PackagePersistentRasterSmoke.csproj b/tools/package-persistent-raster-smoke/Icod.Terminal.PackagePersistentRasterSmoke.csproj new file mode 100644 index 000000000..58c0eb7c4 --- /dev/null +++ b/tools/package-persistent-raster-smoke/Icod.Terminal.PackagePersistentRasterSmoke.csproj @@ -0,0 +1,36 @@ + + + + + + + Exe + net8.0;net9.0;net10.0 + 13.0 + enable + enable + false + true + + + + + + diff --git a/tools/package-persistent-raster-smoke/Program.cs b/tools/package-persistent-raster-smoke/Program.cs new file mode 100644 index 000000000..a6b20606f --- /dev/null +++ b/tools/package-persistent-raster-smoke/Program.cs @@ -0,0 +1,127 @@ +/* + Icod.Terminal.PackagePersistentRasterSmoke + Package smoke-test utility for Icod.Terminal release and compatibility contracts. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +using System.Reflection; +using Icod.Terminal; + +Func< + TerminalSession, + TerminalRasterImage, + CancellationToken, + ValueTask> +> createResource = static ( + TerminalSession session, + TerminalRasterImage image, + CancellationToken cancellationToken +) => session.CreateRasterResourceAsync( + image, + cancellationToken +); +Func< + TerminalRasterResource, + TerminalRasterPlacementOptions?, + CancellationToken, + ValueTask> +> createPlacement = static ( + TerminalRasterResource resource, + TerminalRasterPlacementOptions? options, + CancellationToken cancellationToken +) => resource.CreatePlacementAsync( + options, + cancellationToken +); +Func< + TerminalRasterPlacement, + TerminalRasterPlacementOptions?, + CancellationToken, + ValueTask +> updatePlacement = static ( + TerminalRasterPlacement placement, + TerminalRasterPlacementOptions? options, + CancellationToken cancellationToken +) => placement.UpdateAsync( + options, + cancellationToken +); +_ = createResource; +_ = createPlacement; +_ = updatePlacement; + +Require( + 9 == (int)TerminalCapability.PersistentRasterGraphics, + "PersistentRasterGraphics must retain the reviewed additive enum value 9." +); + +TerminalRasterPlacementOptions options = new() { + Columns = 12, + Rows = 6 +}; +Require( + 12 == options.Columns && 6 == options.Rows, + "TerminalRasterPlacementOptions did not preserve caller-supplied cell extents." +); +Require( + typeof( IAsyncDisposable ).IsAssignableFrom( typeof( TerminalRasterResource ) ), + "TerminalRasterResource must remain asynchronously disposable." +); +Require( + typeof( IAsyncDisposable ).IsAssignableFrom( typeof( TerminalRasterPlacement ) ), + "TerminalRasterPlacement must remain asynchronously disposable." +); + +AssertOpaquePublicSurface( typeof( TerminalRasterResource ) ); +AssertOpaquePublicSurface( typeof( TerminalRasterPlacement ) ); + +static void AssertOpaquePublicSurface( + Type type +) { + ArgumentNullException.ThrowIfNull( type ); + string[] forbiddenFragments = [ + "ImageId", + "ImageNumber", + "PlacementId", + "Backend", + "Kitty" + ]; + MemberInfo[] members = type.GetMembers( + BindingFlags.Instance | BindingFlags.Public + ); + foreach ( string fragment in forbiddenFragments ) { + Require( + !members.Any( + member => member.Name.Contains( + fragment, + StringComparison.Ordinal + ) + ), + $"{type.Name} exposes forbidden protocol-specific public member text '{fragment}'." + ); + } +} + +static void Require( + bool condition, + string message +) { + ArgumentException.ThrowIfNullOrWhiteSpace( message ); + if ( !condition ) { + throw new InvalidOperationException( message ); + } +}