Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,8 @@ jobs:
# detector cannot catch (use-after-dispose, double-dispose, leak-on-path).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/FlowLocalsSample.cs \
frontend/roslyn/samples/MemoryOwnerEscapeSample.cs --flow-locals -o "$RUNNER_TEMP/flow.json"
frontend/roslyn/samples/MemoryOwnerEscapeSample.cs \
frontend/roslyn/samples/FactoryLeakSample.cs --flow-locals -o "$RUNNER_TEMP/flow.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/flow.json" || true)
echo "$out"
echo "$out" | grep -q "OWN002" || { echo "FAIL: expected OWN002 (use-after-dispose)"; exit 1; }
Expand Down Expand Up @@ -821,6 +822,16 @@ jobs:
for ok in clean looped esc exemptTask whileClean asyncDisposed asyncDisposedCfg tfClean tfCatch tfRet tfNull cda daci cif ctorLater lamPrior other doClean swAll ncf captured shaClean stopped defer ctorMoved handedOwner tdClean vtc tif; do
if echo "$out" | grep -q "'$ok'"; then echo "FAIL: silent/exempt case '$ok' was reported"; exit 1; fi
done
# P-005 D5.2 INTERPROCEDURAL fresh-returning factory (FactoryLeakSample.cs): the core
# infers `StreamFactory.Make` returns `fresh` (its `acquire; return <var>` body), so a
# caller that binds the result and drops it leaks at the CALL SITE — a finding the flat,
# intra-procedural detectors cannot see. The disposed caller and the factory itself stay
# silent (the factory transfers ownership out via its return).
echo "$out" | grep -qE "FactoryLeakSample\.cs:[0-9]+:.*\[OWN001\].*'factoryLeak'" \
|| { echo "FAIL: expected the interprocedural OWN001 on the dropped factory result at its call site"; exit 1; }
for ok in factoryOk made; do
if echo "$out" | grep -q "'$ok'"; then echo "FAIL: D5.2 silent case '$ok' was reported"; exit 1; fi
done
echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach/for, try/finally sequential, never-vs-every-path wording, dispose-optional exempt, beyond flat)"
- name: Opt-in body-throw-edges tier (--body-throw-edges, P-016 throw firehose)
run: |
Expand Down
16 changes: 13 additions & 3 deletions docs/notes/d5-ownership-transfer.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,19 @@ escape-without-transfer and all `unknown`/`may` lower to **silence** in the defa
never `fresh` (that is wrap/alias, T4/D5.4), and a non-fresh / unknown return makes no claim —
the result is never falsely owned. Proven by synthetic OwnIR tests: factory-result leak
(OWN001 @ the call), disposed-clean, use-after-dispose (OWN002), forward-return propagation,
and the param-return precision guard. **Remaining T1 door:** `out`/`ref`-owned parameters
(another `fresh` source) — extractor-side recognition of an out-assignment as a fresh acquire
— rides into a later slice before async.
and the param-return precision guard. **Extractor emission (shipped).** The Roslyn extractor
now produces the facts that drive this on real C# (`--flow-locals`): a `new`'d local returned
bare outside a `try` stays tracked and emits `acquire …; return <var>` (so a first-party
factory is classified `fresh`), and `var r = FirstPartyFactory()` — a call to a source-visible
method returning an owned `IDisposable` — emits a `call callee=… result=r` op (the core mints
the acquire only when it proves the callee `fresh`, so a non-fresh call is never falsely owned).
`IsFirstPartyDisposableFactory` gates on a source-declared, non-void, disposable, non-dispose-
optional return; overloads (non-unique names) resolve to `unknown` and stay silent. Validated
end-to-end by `FactoryLeakSample.cs` in CI: a dropped factory result leaks **interprocedurally**
(OWN001 at the call site — beyond the flat detectors), while the disposed caller and the factory
itself stay silent. **Remaining T1 door:** `out`/`ref`-owned parameters (another `fresh` source)
— extractor-side recognition of an out-assignment as a fresh acquire — rides into a later slice
before async.
- **Bridge branch-scope fix (shipped — separate from the D5 transfer ladder).** The OwnIR→core
bridge uses a *flat* `localmap` but emitted each synthetic `Let` *inside* the branch block it
occurred in, so a local `acquire`d in **both** branches of an `if` and released **after** the
Expand Down
96 changes: 92 additions & 4 deletions frontend/roslyn/OwnSharp.Extractor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,42 @@
_ => "?",
};

// P-005 D5.2: a FIRST-PARTY method call whose result is an owned IDisposable — the
// caller side of a fresh-returning factory. Because the callee is defined in source (not
// the BCL), the core can see its body and infer whether it returns `fresh`; if so, a
// `var r = Factory()` binding is an acquire and a caller that drops `r` leaks. The emitted
// `callee` matches the `functions[]` key `{TypeName}.{MethodName}`. A null/extern symbol,
// a void/non-disposable return, or a dispose-optional return is rejected (no claim); an
// overload (non-unique name) resolves to `unknown` in the core and is silently safe.
static bool IsFirstPartyDisposableFactory(ExpressionSyntax? expr, SemanticModel model, out string callee)
{
callee = "";
if (expr is not InvocationExpressionSyntax inv)
return false;
if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol m)
return false;
if (m.ReturnsVoid || m.DeclaringSyntaxReferences.Length == 0)
return false; // void, or not first-party (no visible body to infer `fresh` from)
if (!ImplementsIDisposable(m.ReturnType) || IsDisposeOptional(m.ReturnType))
return false;
// Fully-qualified key (namespace + containing-type chain) so the call resolves to the
// RIGHT summary: two `StreamFactory.Make` in different namespaces must not alias, or a
// call to a non-fresh one could pick up a fresh one's summary and fabricate OWN001
// (Codex). Must match the `functions[]` name built by `FlowFunctionName`.
callee = $"{m.ContainingType.ToDisplayString()}.{m.Name}";
return true;
}

// The fully-qualified `functions[]` key for a method — `{Namespace.Containing.Type}.{Name}` —
// used both as the flow-function name and as a D5.2 call callee, so the two always agree (a
// simple `{Type}.{Name}` would alias same-named types across namespaces). Falls back to the
// syntactic class name only if the symbol cannot be resolved.
static string FlowFunctionName(BaseMethodDeclarationSyntax method, string fallbackType,
SemanticModel model) =>
model.GetDeclaredSymbol(method) is IMethodSymbol ms
? $"{ms.ContainingType.ToDisplayString()}.{ms.Name}"
: $"{fallbackType}.{MethodName(method)}";

// A `Dispose()`/`Close()`/`DisposeAsync()` call — through member access (`x.Dispose()`)
// or member binding (`x?.Dispose()`), and seen through a trailing `.ConfigureAwait(false)`
// (the idiomatic `await x.DisposeAsync().ConfigureAwait(false)` is the release, not a
Expand Down Expand Up @@ -758,6 +794,32 @@
// — the flow path previously mislabelled a pool buffer leaked on a throw edge.
nodes.Add(new { op = "acquire", var = v.Identifier.Text, line = LineOf(v),
kind = IsPoolRent(v.Initializer?.Value, model) ? "pool" : "disposable" });
// P-005 D5.2: `var r = FirstPartyFactory()` — emit a `call` op (NOT an
// acquire); the core mints the acquire only if it proves the callee returns
// `fresh`, so a non-fresh first-party call is never falsely owned.
else if (tracked.Contains(v.Identifier.Text)
&& IsFirstPartyDisposableFactory(v.Initializer?.Value, model, out var fpCallee))
{
// Preserve the call's TRACKED identifier args (CodeRabbit) so the core
// can apply the callee's per-argument ownership effects (consume/borrow)
// to a `var r = Wrap(stream)` — not just the fresh return. Untracked /
// non-identifier args are dropped (no local to attribute an effect to).
// Positional args only: the bridge applies the callee's effects by
// POSITION, so a NAMED argument (`Wrap(second: s2, first: s1)`) would
// mis-attribute if kept in syntactic order. Dropping named args
// under-claims (no effect on them) but never mis-aligns (CodeRabbit).
var fpArgs = v.Initializer?.Value is InvocationExpressionSyntax fpInv
? fpInv.ArgumentList.Arguments
.Where(a => a.NameColon is null)
.Select(a => a.Expression)
.OfType<IdentifierNameSyntax>()
.Select(id => id.Identifier.Text)
.Where(tracked.Contains)
.ToArray()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: Array.Empty<string>();
nodes.Add(new { op = "call", callee = fpCallee, args = fpArgs,
result = v.Identifier.Text, line = LineOf(v) });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
// POOL005: a full-length view in the initializer — `var copy = buf.AsSpan().ToArray();`
// — over-reads the pooled tail just as `Emit(buf.AsSpan());` does. EmitFlowExpr is not
// called on a non-acquire initializer, so scan it here for the overspan (Codex review).
Expand Down Expand Up @@ -849,7 +911,16 @@
nodes.AddRange(chain);
}
else
nodes.Add(new { op = "return", var = (string?)null, line = LineOf(rs) });
{
// P-005 D5.2: a tracked local returned BARE (outside any `finally`) is a
// fresh-factory transfer — emit it as the return's `var` so the core models the
// escape (a discharge: ownership moves to the caller) and classifies the method
// `returnsOwned: fresh`. A non-identifier / non-tracked return is a bare CFG exit.
var rvar = rs.Expression is IdentifierNameSyntax rid
&& tracked.Contains(rid.Identifier.Text)
? rid.Identifier.Text : (string?)null;
nodes.Add(new { op = "return", var = rvar, line = LineOf(rs) });
}
return true;
}
case WhileStatementSyntax ws:
Expand Down Expand Up @@ -2383,7 +2454,7 @@
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
.ToList();
var refNames = new HashSet<string>(tpa.Select(Path.GetFileName), StringComparer.OrdinalIgnoreCase);

Check warning on line 2457 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check SARIF -> GitHub code scanning (dog-food)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2457 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / P-014 Tier B — external reference resolution (--ref-dir)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2457 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check SARIF -> GitHub code scanning (dog-food)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2457 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / P-014 Tier B — external reference resolution (--ref-dir)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2457 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / C# leak extractor (Roslyn) -> OwnIR -> core

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2457 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2457 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / C# leak extractor (Roslyn) -> OwnIR -> core

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2457 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.
var references = tpa.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList();
// P-004 WPF profile: widen the reference set with assemblies named by the
// OWN_EXTRA_REF_DIRS env var (colon-separated dirs) — e.g. the WindowsDesktop ref
Expand Down Expand Up @@ -3181,6 +3252,12 @@
candidates.Add(v.Identifier.Text);
else if (IsMemoryPoolRent(v.Initializer?.Value, model)) // MemoryPool<T> IMemoryOwner (Dispose-released, NOT a poolBuffer)
candidates.Add(v.Identifier.Text);
else if (IsFirstPartyDisposableFactory(v.Initializer?.Value, model, out _))
// P-005 D5.2: `var r = FirstPartyFactory()` — a candidate acquire
// IFF the core proves the callee returns `fresh` (it emits a `call`
// op, not an `acquire`; the core decides). Checked last so `new` /
// pool / BCL-factory initializers keep their existing classification.
candidates.Add(v.Identifier.Text);
}
// `using (IMemoryOwner owner = MemoryPool.Rent(...)) { … }` STATEMENT form: track the owner
// too, so its returned view dangles after the scope-exit dispose (the desugar mirrors the
Expand Down Expand Up @@ -3254,9 +3331,20 @@
// use of the returned owner trips OWN002 — the bare-owner twin of the returned-view
// dangle. A NON-using returned owner stays a genuine transfer (escaped → untracked →
// silent), so this never fires on `var o = Rent(); return o;`.
if (idn.Parent is ReturnStatementSyntax)
if (idn.Parent is ReturnStatementSyntax rsp)
{
if (!usingMemoryOwners.Contains(nm))
// P-005 D5.2: a `new`'d IDisposable returned BARE outside any `try` is a
// fresh-returning FACTORY — keep it tracked so the flow body emits
// `acquire …; return <var>`. The core then classifies the method
// `returnsOwned: fresh` (and the `return <var>` discharges it, so the
// factory itself stays silent), letting a caller that drops the result
// leak. A return INSIDE a try threads `finally` edges the fresh path does
// not model yet, so keep the old transfer (escape) there; a `using` owner
// also stays tracked (its scope-exit dispose dangles the returned value).
var freshFactory = newedDisposables.Contains(nm)
&& !rsp.Ancestors().TakeWhile(a => a != mbody)
.OfType<TryStatementSyntax>().Any();
if (!usingMemoryOwners.Contains(nm) && !freshFactory)
escapedLocals.Add(nm);
}
// A pooled buffer handed as an argument is normally a BORROW (the renter Returns it),
Expand Down Expand Up @@ -3300,7 +3388,7 @@
statMethodsAnalysed++;
flowFunctions.Add(new
{
name = $"{cls.Identifier.Text}.{MethodName(method)}",
name = FlowFunctionName(method, cls.Identifier.Text, model),
file,
body = fbody,
});
Expand Down
35 changes: 35 additions & 0 deletions frontend/roslyn/samples/FactoryLeakSample.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.IO;

namespace Factories;

// P-005 D5.2: a fresh-returning factory + its callers. The factory `Make` creates and
// hands back a NEW owned stream; the core infers `returnsOwned: fresh` from its
// `acquire; return <var>` body. A caller that binds the result and drops it (`Leaks`)
// is then charged the leak at the call site — an INTERPROCEDURAL finding the flat,
// intra-procedural detectors cannot see. A caller that disposes the result (`Clean`)
// stays silent, and the factory itself stays silent (it transfers ownership out).
public static class StreamFactory
{
public static Stream Make()
{
var made = new MemoryStream(); // freshly owned, handed to the caller
return made;
}
}

public static class FactoryConsumers
{
// Drops the fresh factory result without disposing -> OWN001 at the call site.
public static void Leaks()
{
var factoryLeak = StreamFactory.Make();

Check warning

Code scanning / Own.NET

owned resource not released on all paths (possible leak) Warning

IDisposable local 'factoryLeak' is never disposed (leak) [resource: disposable]
factoryLeak.WriteByte(1);
}

// Disposes the fresh factory result -> clean (silent).
public static void Clean()
{
var factoryOk = StreamFactory.Make();
factoryOk.Dispose();
}
}
29 changes: 26 additions & 3 deletions ownlang/ownir.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,11 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str]) -> ReturnSkeleton:
ExternDecl("$borrow_mut",
[EffectParam(Effect.BORROW_MUT, "Disposable", 0)], None, 0),
)
# The callee names a `call` op may resolve against in `lower_call` WITHOUT being a
# first-party function summary — the fixed sink externs. A call to any other callee
# that is not in the solved MOS is unresolvable (no signature) and must NOT be lowered
# to a `Call` (it would raise OWN040); see the `call` handler in `_lower_flow`.
_SINK_EXTERN_NAMES = frozenset(e.name for e in _OWNERSHIP_SINK_EXTERNS)

# A forward to a sink extern is a *known* transfer, so a skeleton can record the
# resolved path action directly — `$consume` is ownership leaving (a must-transfer),
Expand Down Expand Up @@ -1530,7 +1535,14 @@ def _lower_flow(nodes: list[Any], ffile: str, fname: str,
# extractor (it is an escape, surfaced separately).
callee = str(n.get("callee", ""))
raw_args = n.get("args", [])
if callee and isinstance(raw_args, list):
summ = mos.get(callee) if (mos is not None and callee) else None
# Only emit the `Call` when the callee is RESOLVABLE — a first-party function
# with a summary, or a fixed ownership-sink extern. A real extraction surfaces
# calls to callees we did not lower as functions (BCL / extension methods like
# `GetRequiredService`); those have no signature, so `lower_call` would raise
# OWN040. Drop them (no effect, no claim) — precision-safe, never a crash.
if (summ is not None or callee in _SINK_EXTERN_NAMES) \
and callee and isinstance(raw_args, list):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
arg_refs: list[Expr] = [VarRef(localmap.get(str(a), str(a)), line)
for a in raw_args]
body.append(Call(callee, arg_refs, line))
Expand All @@ -1540,7 +1552,14 @@ def _lower_flow(nodes: list[Any], ffile: str, fname: str,
# above; this models the return). A non-fresh / unknown return makes no
# claim, so the result is never falsely owned (precision-first).
result = n.get("result")
summ = mos.get(callee) if (mos is not None and callee) else None
# Overwriting a tracked local KILLS its previous ownership binding: if the
# old handle was not released before this call, it leaks (the reference is
# lost). Drop the stale mapping before any optional fresh acquire, so
# `acquire x; x = Unknown(); release x` leaks the original x rather than
# reading as clean (CodeRabbit). A hoisted local keeps its single outer-scope
# handle (it is declared once and never re-bound), so leave it alone.
if isinstance(result, str) and result and result not in hoisted:
localmap.pop(result, None)
if (isinstance(result, str) and result and result not in hoisted
and summ is not None and getattr(summ, "returns", None) == "fresh"):
handle = f"loc_{loc[0]}"
Expand Down Expand Up @@ -1600,7 +1619,11 @@ def check_facts(facts: dict[str, Any]) -> list[Finding]:
# parameter's contract could not be inferred (an ambiguous pass-through
# stays plain) -- a "needs annotation / transitive inference" gap, not a
# leak. Surfacing these properly (with a subject) is a later step.
if d.code in ("OWN033", "OWN034", "OWN035", "OWN041"):
# - OWN040: a `call` to a callee the bridge did not lower as a function
# (an extension/BCL method surfaced by the extractor). The `call` handler
# already drops unresolvable callees, so this is belt-and-suspenders — a
# synthetic-call artifact, never a real C# bug (C# already binds the call).
if d.code in ("OWN033", "OWN034", "OWN035", "OWN040", "OWN041"):
continue
sub = handles.get(_handle_of(d) or "")
if sub is None:
Expand Down
Loading
Loading