Amended after design review: the original version of this issue was too broad. Own.NET already has the static use-after-release rule (OWN002 / OWN009), and docs/proposals/P-034-runtime-lifetime-guard.md already defines the complementary runtime LifetimeGuard / disposal-quarantine direction. This issue is therefore narrowed to one non-duplicating gap: once a type explicitly adopts a runtime disposal-guard contract, verify that the contract is applied consistently across its API.
What already exists — do not re-derive here
OWN002 / OWN009 detect a concrete use of a resource after it has been released in analysed code.
OWN003 detects double release.
- P-034 proposes the runtime layer that actually throws when misuse reaches an execution path static analysis did not prove, such as an unmodelled ownership hand-off, callback/alias boundary, external consumer or cross-thread interaction.
- P-034 also owns the runtime helper/base type and disposal-quarantine design.
This issue does not add another use-after-dispose detector and does not define another runtime guard implementation.
The remaining gap
A runtime guard only helps on members that actually invoke it. A type may adopt P-034's guard and still accidentally leave part of its API unprotected:
[RequireDisposedGuard] // placeholder: use the final P-034 contract/marker
public sealed class Session : LifetimeGuard
{
private readonly Stream _stream;
public void Send(byte[] data)
{
ThrowIfDisposed();
_stream.Write(data, 0, data.Length);
}
public int ReadByte()
{
// Guard accidentally omitted. P-034 cannot fail loudly here because it is never invoked.
return _stream.ReadByte();
}
protected override void DisposeCore() => _stream.Dispose();
}
OWN002 may catch a direct local sequence such as x.Dispose(); x.ReadByte(). It cannot be expected to prove every call through fields, aliases, callbacks, external assemblies or concurrent code. The runtime layer covers that residue, but only if the guarded type's own public/protected surface consistently enters the guard.
The proposed diagnostic is therefore a contract-completeness check for explicitly guarded types, not a second lifetime-state analysis.
Applicability — explicit opt-in only
Do not infer this contract from IDisposable alone. Many disposable types intentionally allow selected operations after disposal, and some delegate the failure to an owned resource.
Apply the rule only when a type explicitly adopts the final P-034 guard contract, for example through one of:
- the P-034 base/interface/attribute;
- an exact configured guard contract;
- a project-level opt-in selecting named types/namespaces.
The exact marker belongs to P-034. Placeholder examples in this issue are not a competing runtime API.
A member-level escape hatch must exist for operations intentionally valid after disposal:
[AllowedAfterDispose] // placeholder: use the final P-034 annotation/config form
public bool IsDisposed => ...;
Contract to verify
For an opted-in type, every applicable public/protected instance member must satisfy one of:
- a recognised disposed-state guard dominates all lifecycle-sensitive work on every executable path;
- all lifecycle-sensitive state is reached exclusively through a recognised guarded accessor;
- the member is explicitly marked/configured as legal after disposal.
Initial member scope:
- public/protected instance methods;
- property and indexer accessors;
- optionally event accessors if the selected P-034 policy includes them.
Exclude constructors, static members, Dispose/DisposeAsync, finalizers and explicitly allowed members. Private helpers should be checked through guarded entry points rather than mechanically requiring a guard in every private method.
Guard recognition
Recognise source-visible guard families, semantically rather than by spelling alone:
- the exact P-034
LifetimeGuard.ThrowIfDisposed() symbol;
ObjectDisposedException.ThrowIf(disposedCondition, this/name);
- an explicit
if (disposedCondition) throw new ObjectDisposedException(...);
- a local/base helper whose readable body is proven to perform the disposed-state check;
- a guarded state/property accessor that throws when its backing state is unavailable/disposed.
A method merely named ThrowIfDisposed is not proof. For source helpers, resolve the symbol and inspect the body. For metadata-only helpers, accept only an exact known/configured contract; otherwise fail closed and do not claim the member is guarded.
Dominance requirement
A guard on only some paths is insufficient:
public void Send(bool fast)
{
if (fast)
ThrowIfDisposed();
_stream.WriteByte(1); // unguarded when fast == false
}
The recognised guard/accessor must dominate the protected operation according to the CFG. A guard after a resource access is also insufficient.
Overrides must be checked independently unless the base implementation is actually invoked before the override's lifecycle-sensitive work. Merely overriding a guarded base member does not inherit the guard contract into a replacement body.
Suggested diagnostic split
Diagnostic IDs should follow the repository's numbering plan. Semantic split:
- runtime disposal-guard contract incomplete: an applicable opted-in member can execute protected work without a dominating guard;
- guarded state bypassed: a type adopts a guarded-state-accessor pattern, but a member reads the backing state/resource directly.
The first diagnostic is the MVP. The bypass diagnostic can be a follow-up if alias/data-flow requirements make it materially larger.
These diagnostics are distinct from OWN002/OWN009:
OWN002/OWN009: a caller actually uses a released resource;
- this rule: an explicitly hardened type failed to implement its chosen runtime guard contract on part of its API.
Do not emit both merely because a direct Dispose(); Use(); sequence is present. The call-site misuse remains OWN002/OWN009; the contract diagnostic belongs on the unguarded member declaration.
Source-visible code fix
Where an already-recognised canonical helper exists, offer a fix that inserts its call at the first executable point:
For a guarded-accessor contract, offer replacement of a direct backing-field access only when the transformation is semantics-preserving and unambiguous.
The MVP fix must not invent a disposed-state field, disposal protocol, base class or helper body. That belongs to P-034 and cannot be guessed soundly from an arbitrary class. A separate explicit refactoring may adopt P-034 once that API exists.
All fixes must modify visible C# source. No IL weaving or post-compile method rewriting.
Architecture sketch
A staged implementation can fit the extractor/core split:
- Roslyn frontend emits facts for opted-in guard contracts: exact guard symbols, disposed-state predicates, guarded accessors, member visibility, CFG guard points and allowed-after-dispose annotations.
- The core checks contract coverage/dominance without reimplementing
OWN002 resource-state tracking.
- A Roslyn code-fix layer inserts calls only to an already-established guard contract.
Keep runtime-contract recognition, policy and call-site ownership analysis separate. The frontend reports what guard contract the type adopted; the core verifies conformance to that declared contract.
Non-goals
- Replacing or duplicating
OWN002, OWN009 or OWN003.
- Defining
LifetimeGuard, disposal quarantine or another runtime package; P-034 owns those.
- Requiring guards on every
IDisposable type.
- Inferring that every member is invalid after disposal without an explicit type-level contract.
- Proving thread safety of concurrent operation versus
Dispose().
- Treating
ThrowIfDisposed(); DoWork(); as synchronization; another thread may still dispose between the check and the operation.
- Generating a complete disposal implementation for an arbitrary class.
- Hidden IL mutation.
Acceptance
- A non-opted-in
IDisposable type is unchanged and produces no new contract diagnostic.
- An opted-in type with an unguarded public method is flagged at the member declaration.
- Unguarded property/indexer accessors are flagged when included by policy.
- The exact P-034 helper, direct
ObjectDisposedException.ThrowIf(...), an explicit throw branch and a semantically verified source helper are accepted.
- A same-named but semantically unrelated
ThrowIfDisposed() helper is not accepted.
- A metadata-only helper is accepted only through an exact known/configured contract.
- A guard on only one branch, or after the protected access, does not satisfy dominance.
- An override that replaces a guarded base body but omits the guard is flagged.
- A guarded-state-accessor member is accepted; direct backing-state bypass remains flagged or is reserved for the follow-up diagnostic.
[AllowedAfterDispose] (or the final P-034 equivalent) suppresses only the annotated member.
- Constructors, static members, disposal methods and finalizers are not flagged.
- A direct call-site
Dispose(); Use(); remains diagnosed by OWN002/OWN009 and does not gain a duplicate contract diagnostic at the call site.
- The code fix inserts only an existing recognised source-visible guard and does not synthesize a disposal protocol.
- Existing sample output remains byte-identical unless the new contract rule is explicitly enabled.
References
Amended after design review: the original version of this issue was too broad. Own.NET already has the static use-after-release rule (
OWN002/OWN009), anddocs/proposals/P-034-runtime-lifetime-guard.mdalready defines the complementary runtimeLifetimeGuard/ disposal-quarantine direction. This issue is therefore narrowed to one non-duplicating gap: once a type explicitly adopts a runtime disposal-guard contract, verify that the contract is applied consistently across its API.What already exists — do not re-derive here
OWN002/OWN009detect a concrete use of a resource after it has been released in analysed code.OWN003detects double release.This issue does not add another use-after-dispose detector and does not define another runtime guard implementation.
The remaining gap
A runtime guard only helps on members that actually invoke it. A type may adopt P-034's guard and still accidentally leave part of its API unprotected:
OWN002may catch a direct local sequence such asx.Dispose(); x.ReadByte(). It cannot be expected to prove every call through fields, aliases, callbacks, external assemblies or concurrent code. The runtime layer covers that residue, but only if the guarded type's own public/protected surface consistently enters the guard.The proposed diagnostic is therefore a contract-completeness check for explicitly guarded types, not a second lifetime-state analysis.
Applicability — explicit opt-in only
Do not infer this contract from
IDisposablealone. Many disposable types intentionally allow selected operations after disposal, and some delegate the failure to an owned resource.Apply the rule only when a type explicitly adopts the final P-034 guard contract, for example through one of:
The exact marker belongs to P-034. Placeholder examples in this issue are not a competing runtime API.
A member-level escape hatch must exist for operations intentionally valid after disposal:
Contract to verify
For an opted-in type, every applicable public/protected instance member must satisfy one of:
Initial member scope:
Exclude constructors, static members,
Dispose/DisposeAsync, finalizers and explicitly allowed members. Private helpers should be checked through guarded entry points rather than mechanically requiring a guard in every private method.Guard recognition
Recognise source-visible guard families, semantically rather than by spelling alone:
LifetimeGuard.ThrowIfDisposed()symbol;ObjectDisposedException.ThrowIf(disposedCondition, this/name);if (disposedCondition) throw new ObjectDisposedException(...);A method merely named
ThrowIfDisposedis not proof. For source helpers, resolve the symbol and inspect the body. For metadata-only helpers, accept only an exact known/configured contract; otherwise fail closed and do not claim the member is guarded.Dominance requirement
A guard on only some paths is insufficient:
The recognised guard/accessor must dominate the protected operation according to the CFG. A guard after a resource access is also insufficient.
Overrides must be checked independently unless the base implementation is actually invoked before the override's lifecycle-sensitive work. Merely overriding a guarded base member does not inherit the guard contract into a replacement body.
Suggested diagnostic split
Diagnostic IDs should follow the repository's numbering plan. Semantic split:
The first diagnostic is the MVP. The bypass diagnostic can be a follow-up if alias/data-flow requirements make it materially larger.
These diagnostics are distinct from
OWN002/OWN009:OWN002/OWN009: a caller actually uses a released resource;Do not emit both merely because a direct
Dispose(); Use();sequence is present. The call-site misuse remainsOWN002/OWN009; the contract diagnostic belongs on the unguarded member declaration.Source-visible code fix
Where an already-recognised canonical helper exists, offer a fix that inserts its call at the first executable point:
For a guarded-accessor contract, offer replacement of a direct backing-field access only when the transformation is semantics-preserving and unambiguous.
The MVP fix must not invent a disposed-state field, disposal protocol, base class or helper body. That belongs to P-034 and cannot be guessed soundly from an arbitrary class. A separate explicit refactoring may adopt P-034 once that API exists.
All fixes must modify visible C# source. No IL weaving or post-compile method rewriting.
Architecture sketch
A staged implementation can fit the extractor/core split:
OWN002resource-state tracking.Keep runtime-contract recognition, policy and call-site ownership analysis separate. The frontend reports what guard contract the type adopted; the core verifies conformance to that declared contract.
Non-goals
OWN002,OWN009orOWN003.LifetimeGuard, disposal quarantine or another runtime package; P-034 owns those.IDisposabletype.Dispose().ThrowIfDisposed(); DoWork();as synchronization; another thread may still dispose between the check and the operation.Acceptance
IDisposabletype is unchanged and produces no new contract diagnostic.ObjectDisposedException.ThrowIf(...), an explicit throw branch and a semantically verified source helper are accepted.ThrowIfDisposed()helper is not accepted.[AllowedAfterDispose](or the final P-034 equivalent) suppresses only the annotated member.Dispose(); Use();remains diagnosed byOWN002/OWN009and does not gain a duplicate contract diagnostic at the call site.References
docs/proposals/P-034-runtime-lifetime-guard.md— owns the runtime guard and quarantine layer; this issue is a follow-up completeness check.spec/OwnCore.mdR2 — existingOWN002/OWN009static use-after-release semantics.