Skip to content

Make Surface implement IDisposable (API shape only, no pooling yet) - #16

Merged
Malcolmnixon merged 7 commits into
mainfrom
feature/surface-idisposable
Sep 25, 2026
Merged

Malcolmnixon merged 7 commits into
mainfrom
feature/surface-idisposable

Conversation

@Malcolmnixon

@Malcolmnixon Malcolmnixon commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

This is an intentional, deliberate breaking API change: Surface (src/DemaConsulting.CanvasNet/Canvas/Surface.cs) now implements IDisposable.

Scope is deliberately narrow — API shape only, no pooling yet. This PR adds the IDisposable contract (a _disposed flag, an idempotent no-finalizer Dispose(), and ObjectDisposedException.ThrowIf(_disposed, this) guards on every public buffer-touching member) so that Surface's internal pixel storage can be switched to ArrayPool<byte>-backed rented arrays in a future, non-breaking PR. Plain-array-backed storage (new byte[...]) is completely unchanged in this PR; disposing a Surface today does not release/return anything, it purely flips the guard flag. Real ArrayPool pooling is intentionally deferred to a dedicated follow-up PR (it has its own correctness subtleties — e.g. ArrayPool<T>.Rent returning arrays larger than requested, and a security-relevant question about zeroing pixel data on return-to-pool — that deserve focused review, not to be bundled with this breaking API-shape change).

Why now

Adding IDisposable to Surface is a binary/source breaking change (any code holding a Surface in a using block today would newly be required to, though nothing is force-required by the compiler — see below). Making this change before 1.0 preserves the option of ArrayPool-based pooling post-1.0 without a second breaking change later.

What changed

  • Surface is now sealed class Surface : IDisposable.
  • ObjectDisposedException.ThrowIf(_disposed, this) guards added to all 11 public members that touch the internal pixel buffer (13 call-sites, counting the indexer's get/set and CompositeOver(Surface)'s self/foreground checks separately): the indexer get/set, GetRowSpanBytes, GetRowSpan, Crop, PremultiplyAlpha, UnpremultiplyAlpha, Clear, both CompositeOver overloads, and both public CompositeOverSpan overloads. The two internal workspace-reusing CompositeOverSpan overloads used by ScanlineRasterizer carry the same guard (15 guards total), so a disposed surface cannot be mutated through the internal fill path either.
  • GetRowSpanBytes/GetRowSpan gained an explicit span-lifetime contract in the design doc: the disposal guard only runs when those methods are called, so a caller retaining a previously-obtained span across a Dispose() call gets undefined behavior — callers must not do that.
  • Dispose() is idempotent (safe to call twice) and has no finalizer — matching the exact precedent already established by Surface.cs's own internal RowChannelBuffers/CompositeWorkBuffers helper types, since these are managed arrays, not unmanaged resources.
  • Canvas (src/DemaConsulting.CanvasNet/Rendering/Canvas.cs) does not become IDisposable and does not dispose the Surface it wraps — doc-only change adding explicit <remarks> to its constructor and Surface property stating this non-owning-reference contract (the caller that constructed the Surface remains responsible for disposing it).
  • Zero churn across the ~178 pre-existing new Surface(...) call sites in src//test/ — verified CA2000 is not a build-breaking diagnostic under this repo's analyzer settings, so none of them need to change to keep compiling/passing.
  • README.md and docs/user_guide/introduction.md code samples that construct a Surface now use the using var surface = new Surface(...) idiom, for consistency/teaching a good pattern.
  • Companion artifacts updated per software-items.md: reqstream requirements (docs/reqstream/canvas-net/canvas.yaml, docs/reqstream/canvas-net/canvas/surface.yaml), design docs (docs/design/canvas-net/canvas/surface.md, docs/design/canvas-net/rendering/canvas.md), verification docs (docs/verification/canvas-net/canvas/surface.md, docs/verification/canvas-net/rendering/canvas.md), and the SysML2 model note (docs/sysml2/model/canvas-net/canvas/surface.sysml).

New tests

  • Surface_Dispose_CalledOnce_DoesNotThrow
  • Surface_Dispose_CalledTwice_DoesNotThrow (idempotency)
  • One *_AfterDispose_ThrowsObjectDisposedException test per guarded call-site, plus the foreground-disposed case for CompositeOver(Surface) (15 tests)
  • Canvas_Surface_DisposedExternally_CanvasDoesNotThrowFromAccessingSurfaceProperty — a design-intent regression test documenting that Canvas continues to allow access to a disposed Surface without Canvas itself throwing.
  • CanvasNet_SystemIntegration_DisposeSurfaceThenUseIt_ThrowsObjectDisposedException — a system-level integration test proving the disposal contract is reachable end to end through the public API, using a representative sample of guarded members (the full member-by-member matrix lives in SurfaceTests).

Validation

  • pwsh ./fix.ps1 run.
  • Full build: 0 warnings/errors across net8.0/net9.0/net10.0 (TreatWarningsAsErrors=true).
  • Full test suite: 3810/3810 passing across net8.0/net9.0/net10.0 (1270 per TFM).
  • dotnet reqstream --lint, dotnet reviewmark --lint, SysML2 lint — all pass with 0 errors.

Follow-up (tracked separately, not in this PR)

  • ArrayPool-based pooling of Surface's internal buffer (the internal implementation of the constructor + Dispose()), including the rented-array-size and clear-on-return design questions noted above.

Copilot AI lite review requested due to automatic review settings September 25, 2026 13:38
@Malcolmnixon
Malcolmnixon marked this pull request as draft September 25, 2026 13:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Moderate lifecycle-contract issues remain in Surface and its regression test coverage.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity · 2 Low severity

Open (3)
What changed in this PR

Adds IDisposable to Surface with disposal guards, tests, and updated ownership documentation.

Changes:

  • Added idempotent disposal and post-disposal guards.
  • Documented non-owning Canvas/Surface behavior.
  • Updated samples, requirements, design, verification, and SysML artifacts.
File Summary / review note
test/​DemaConsulting.CanvasNet.Tests/​Rendering/​CanvasTests.cs Adds Canvas ownership coverage; test should verify the surface remains usable before disposal.
test/​DemaConsulting.CanvasNet.Tests/​Canvas/​SurfaceTests.cs Adds disposal and post-disposal behavior tests.
src/​DemaConsulting.CanvasNet/​Rendering/​Canvas.cs Documents non-owning surface behavior.
src/​DemaConsulting.CanvasNet/​Canvas/​Surface.cs Adds disposal implementation and guards; disposed foreground surfaces and retained spans require further handling.
README.md Updates samples; the cropped surface should also be disposed.
docs/​verification/​canvas-net/​rendering/​canvas.md Updates Canvas ownership verification documentation.
docs/​verification/​canvas-net/​canvas/​surface.md Updates Surface disposal verification documentation.
docs/​user_guide/​introduction.md Updates disposal guidance; the cropped surface should also be disposed.
docs/​sysml2/​model/​canvas-net/​canvas/​surface.sysml Updates the Surface model description.
docs/​reqstream/​canvas-net/​canvas/​surface.yaml Disposal requirement should describe buffer unavailability rather than resource release.
docs/​reqstream/​canvas-net/​canvas.yaml Parent disposal requirement should align with the current implementation.
docs/​design/​canvas-net/​rendering/​canvas.md Documents Canvas ownership semantics.
docs/​design/​canvas-net/​canvas/​surface.md Corrects the inaccurate helper-type precedent for idempotent disposal.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/DemaConsulting.CanvasNet/Canvas/Surface.cs
Comment thread docs/reqstream/canvas-net/canvas.yaml Outdated
Comment thread docs/reqstream/canvas-net/canvas/surface.yaml Outdated
- Surface is now a sealed IDisposable class with an idempotent, non-finalizer
  Dispose() (managed arrays only, no pooling yet).
- ObjectDisposedException.ThrowIf(_disposed, this) guards every public member
  that touches the pixel buffer: indexer get/set, GetRowSpanBytes, GetRowSpan,
  Crop, PremultiplyAlpha, UnpremultiplyAlpha, CompositeOver(Surface),
  CompositeOver(Rgba32), and both public CompositeOverSpan overloads.
- Canvas gains doc-only remarks clarifying it holds a non-owning reference to
  its Surface and does not dispose it; Canvas itself remains non-IDisposable.
- Adds 13 new SurfaceTests.cs tests (Dispose-once/twice plus one
  *_AfterDispose_ThrowsObjectDisposedException test per guarded member) and one
  CanvasTests.cs regression test documenting Canvas's non-owning behavior.
- Updates companion artifacts: reqstream requirements (canvas.yaml,
  canvas/surface.yaml), design docs (canvas/surface.md,
  rendering/canvas.md), verification docs (canvas/surface.md,
  rendering/canvas.md), sysml2 model note, README.md, and
  docs/user_guide/introduction.md (new Disposal subsection plus using var
  conversions at all literal new Surface(...) call sites).
@Malcolmnixon
Malcolmnixon force-pushed the feature/surface-idisposable branch from eb4e271 to 1018d64 Compare September 25, 2026 21:04
@Malcolmnixon
Malcolmnixon marked this pull request as ready for review September 25, 2026 21:04
Copilot AI review requested due to automatic review settings September 25, 2026 21:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Disposal coverage and documentation remain inconsistent, and disposed foreground surfaces are not guarded.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity · 6 Low severity

Open (8)

Comment thread src/DemaConsulting.CanvasNet/Canvas/Surface.cs
Comment thread README.md Outdated
Comment thread docs/design/canvas-net/canvas/surface.md Outdated
Comment thread docs/user_guide/introduction.md Outdated
Comment thread docs/user_guide/introduction.md Outdated
…al, and fix disposal wording/examples

- CompositeOver(Surface) now also throws ObjectDisposedException when the
  foreground argument has been disposed, not just when this surface has.
- Add Surface_Clear_AfterDispose_ThrowsObjectDisposedException and
  Surface_CompositeOverSurface_ForegroundDisposed_ThrowsObjectDisposedException,
  and list both in the reqstream/verification test matrices.
- Reword the parent/child disposal requirements (canvas.yaml,
  canvas/surface.yaml) to say Dispose logically releases the pixel buffer,
  matching the plain-managed-array implementation instead of promising an
  unmanaged/pooled resource release.
- Add Clear to the design doc's and user guide's enumerated
  disposal-guarded-member lists.
- Bind the Crop result with using var in the README and user guide examples
  so the samples model the lifetime contract for derived surfaces.
- Add finalizer to the cspell dictionary and fix a design-doc table
  line-length/alignment lint failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 21:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A test does not compile, and disposal coverage and lifetime behavior have unresolved correctness issues.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity

Open (2)
Resolved since last review (7)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Stale spans make pooled buffer reuse unsafe

src/​DemaConsulting.CanvasNet/​Canvas/​Surface.cs:27

The row accessors return mutable spans that alias _buffer, so a caller can retain a span, call Dispose(), and continue reading or writing the array; the guard only affects later member calls. If a follow-up returns this storage to ArrayPool here, that stale span could mutate another surface's reused array or expose prior pixels, so the stated future pooling change is not safely non-breaking. Please define a lifetime/lease strategy or explicitly prohibit returning this storage while aliases may exist.

This issue also appears on line 595 of the same file.

Low severity Contradictory never-throws documentation

src/​DemaConsulting.CanvasNet/​Canvas/​Surface.cs:439

The newly added exception makes the preceding "this method never throws" statement inaccurate: every byte pattern is valid, but a disposed surface now throws ObjectDisposedException. Please qualify the sentence so the XML documentation does not contradict the exception contract.

This issue also appears on line 484 of the same file.

Comment thread test/DemaConsulting.CanvasNet.Tests/Rendering/CanvasTests.cs Outdated
CanvasNet-Canvas-Disposal and its child CanvasNet-Canvas-Surface-Disposal
were unreachable from any system/quality-tagged requirement, so ReqStream's
enforce step reported them as orphans. Add CanvasNet-Lib-CanvasDisposal as
the missing system-level parent, following the same pattern as the sibling
CanvasNet-Lib-CanvasClear requirement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 21:28
Malcolm Nixon and others added 2 commits September 25, 2026 17:31
Assign the discarded value instead of a bare property-access expression
body, per review feedback, even though it already compiled (bare property
access isn't a valid Action statement, so overload resolution picked
Func<object>).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

An ownership test does not compile, and disposal guard and span-lifetime concerns remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 2 Medium severity · 1 Low severity

Open (4)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Low severity Correct inaccurate claims about idempotent disposal

docs/​design/​canvas-net/​canvas/​surface.md:339

The cited helpers do not use an idempotent flag check: their Dispose methods unconditionally return their rented arrays, so a second call can return the same arrays twice. This comparison makes the new disposal documentation inaccurate and could establish a false precedent for the future pooled Surface implementation; describe only Surface's flag-based behavior or correct the helper description.

Comment thread test/DemaConsulting.CanvasNet.Tests/Rendering/CanvasTests.cs Outdated
Comment thread src/DemaConsulting.CanvasNet/Canvas/Surface.cs
Comment thread src/DemaConsulting.CanvasNet/Canvas/Surface.cs
Comment thread docs/reqstream/canvas-net.yaml Outdated
Copilot AI review requested due to automatic review settings September 25, 2026 21:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Moderate issues remain around disposed-surface mutation paths, span lifetime safety, and system-level requirement traceability.

Review effort: Lite
Findings: 2 Medium severity · 1 Low severity

Open (3)
Resolved since last review (1)
Previously missed (3)

In code that hasn't changed since last review

Low severity Dispose all codec-loaded surfaces in the canonical sample

README.md:77

This canonical sample now teaches using for the directly constructed Surface, but the Surface instances returned by BmpCodec.Load, PngCodec.Load, TiffCodec.Load, JpegCodec.Load, SvgCodec.Load, and the later PngCodec.Load call are still left undisposed. Those results also own the pixel buffer and would retain pooled storage in the follow-up implementation; make every loaded surface using var (or dispose it before the sample ends) so the sample demonstrates the lifetime contract consistently.

Low severity Prevent helper disposal from returning buffers twice

docs/​design/​canvas-net/​canvas/​surface.md:339

The cited RowChannelBuffers and CompositeWorkBuffers do not follow the claimed idempotent flag-check/set/return shape: their Dispose methods unconditionally return each rented array, so a second call would return the same arrays twice. Remove this comparison or update the helper types before using them as the precedent for Surface disposal.

Low severity Dispose surfaces returned by codec load examples

docs/​user_guide/​introduction.md:93

The new disposal guidance says to dispose a Surface once it is no longer needed, but the load/save examples below leave every Surface returned by a codec undisposed (loaded, loaded24, loadedRgb, loadedLzw, the JPEG result, and the untrusted PNG result). Since codec loads also create the buffer whose future storage is intended to be pooled, update these examples to use using var for the returned surfaces as well.

…tion disposal test

- Add ObjectDisposedException guards to the internal workspace-reusing
  CompositeOverSpan overloads used by ScanlineRasterizer, so a disposed
  surface's buffer cannot be mutated through the internal fill path.
- Document span lifetime for GetRowSpanBytes/GetRowSpan: the disposal
  guard only runs when those methods are called, so a caller holding a
  previously-obtained span across Dispose() gets undefined behavior.
- Correct the design doc's inaccurate claim that RowChannelBuffers and
  CompositeWorkBuffers use the same idempotent flag-checked disposal
  shape as Surface; they do not.
- Add CanvasNet_SystemIntegration_DisposeSurfaceThenUseIt_ThrowsObjectDisposedException
  and reference it from the root CanvasNet-Lib-CanvasDisposal requirement
  instead of a unit-level test, matching sibling root requirements.
- Add unit tests for the two internal CompositeOverSpan workspace
  overloads' disposed guards, and wire them into the reqstream/
  verification test lists.
- Use 'using var' for all codec Load(...) results in README.md and
  docs/user_guide/introduction.md so canonical samples don't leak
  Surface instances.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 21:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

Remaining findings are minor documentation and summary-count nits, not approval-blocking issues.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Low severity Update stale disposal inventory and test counts

src/​DemaConsulting.CanvasNet/​Canvas/​Surface.cs:693

The PR summary's inventory is now stale: Clear is also a public buffer-touching member and this guard plus Surface_Clear_AfterDispose_ThrowsObjectDisposedException were added. The stated “9 members/11 call-sites/11 tests” therefore omits Clear (and the foreground-disposed case is an additional test); please update the description's counts and member list to match the implementation.

Comment thread test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs Outdated
…overage

- Note that the 'future pooling is non-breaking' claim in the design
  doc is limited to the API surface: a future pooled implementation
  must still honor the span-lifetime rule, since Dispose() cannot
  retroactively invalidate a span obtained before it was called.
- Reword the CanvasNet_SystemIntegration_DisposeSurfaceThenUseIt_...
  test summary to describe it as exercising a representative sample
  of guarded members rather than overclaiming full member coverage;
  the complete matrix lives in SurfaceTests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 22:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved issues were identified, and the requested changes are fully covered.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants