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
44 changes: 41 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ jobs:
# the registration site rides along as the SECONDARY anchor (named in EACH DI004
# message tail) — exactly 3, one per finding, so a partial-suffix regression (the tail
# on some findings but not all) fails CI too (CodeRabbit review; mirrors the counts above).
nreg=$(echo "$out" | grep -c "singleton registered at ")
nreg=$(echo "$out" | grep -cE "\[DI004\].*singleton registered at ")
[ "$nreg" = "3" ] \
|| { echo "FAIL: expected exactly 3 DI004 registration-site suffixes, got $nreg"; exit 1; }
# the three controls each pin one precision guard and must stay SILENT: ScopedResolver
Expand All @@ -597,7 +597,42 @@ jobs:
if echo "$out" | grep -qE "(ScopedResolver|PlainResolver|RequestResolver)"; then
echo "FAIL: a correct/non-leaking resolver (scope-resolved, non-disposable, or scoped) was wrongly flagged DI004"; exit 1
fi
echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) + OWN014 (static-event region escape) + DI001 (captive dependency) + DI002 (scoped captured weakly) + DI003 (transient IDisposable captured by a singleton) + DI004 (transient IDisposable service-located from the root provider) at the C# location"
# P-006 DI005 (scope-cached captive, WARNING): a singleton that resolves a SCOPED service
# from a scope it CREATES (the correct IServiceScopeFactory pattern) but CACHES it into a
# field — the scope is disposed when the operation ends, so the cached instance dangles
# and is promoted to application lifetime (the captive returns, hidden behind the fix).
echo "$out" | grep -qE "\[DI005\].*'ScopeCachingService' caches scoped service 'AppDbContext'" \
|| { echo "FAIL: expected DI005 (ScopeCachingService caches scope-resolved scoped AppDbContext)"; exit 1; }
# transitive DI005: a singleton caches the TRANSIENT UnitOfWork (which ctor-injects scoped
# AppDbContext) from a created scope — the DFS follows the cached transient's strong edges
# like DI001, so the dragged-in scoped service is found. A captive DI001/3/4 cannot see.
echo "$out" | grep -qE "\[DI005\].*'UnitOfWorkCachingService' caches scoped service 'AppDbContext'" \
|| { echo "FAIL: expected transitive DI005 (UnitOfWorkCachingService -> UnitOfWork -> AppDbContext)"; exit 1; }
echo "$out" | grep -q "UnitOfWorkCachingService -> UnitOfWork -> AppDbContext" \
|| { echo "FAIL: expected the transitive DI005 path text"; exit 1; }
n5=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI005\]")
[ "$n5" = "2" ] \
|| { echo "FAIL: expected exactly 2 DI005 findings (direct + transitive), got $n5"; exit 1; }
# DI005's consumer is the field-STORE site (not a ctor), the PRIMARY anchor — the direct
# case at line 154 (`_db = ...AppDbContext`), the transitive case at the cached ENTRY's
# store (line 201, `_uow = ...UnitOfWork`, NOT the dragged-in AppDbContext) — with the
# registration as the secondary suffix.
echo "$out" | grep -qE "DiCaptiveSample\.cs:154: warning: \[DI005\].*'ScopeCachingService'" \
|| { echo "FAIL: expected DI005 anchored at the field-store site (line 154)"; exit 1; }
echo "$out" | grep -qE "DiCaptiveSample\.cs:201: warning: \[DI005\].*'UnitOfWorkCachingService'" \
|| { echo "FAIL: expected transitive DI005 anchored at the cached-entry store site (line 201)"; exit 1; }
# the registration site rides along as the SECONDARY anchor in EACH DI005 message tail —
# exactly 2 (one per finding), so a partial-suffix regression fails CI (like DI004's nreg).
nreg5=$(echo "$out" | grep -cE "\[DI005\].*singleton registered at ")
[ "$nreg5" = "2" ] \
|| { echo "FAIL: expected exactly 2 DI005 registration-site suffixes, got $nreg5"; exit 1; }
# two controls stay SILENT: ScopeUsingService USES the scope-resolved service within the
# scope (a local, not a field store); ClockCachingService caches a SINGLETON (shareable,
# not a scoped service). Neither is a captive.
if echo "$out" | grep -qE "(ScopeUsingService|ClockCachingService)"; then
echo "FAIL: a correct scope use (used-in-scope, or a cached singleton) was wrongly flagged DI005"; exit 1
fi
echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) + OWN014 (static-event region escape) + DI001 (captive dependency) + DI002 (scoped captured weakly) + DI003 (transient IDisposable captured by a singleton) + DI004 (transient IDisposable service-located from the root provider) + DI005 (scoped service cached from a created scope) at the C# location"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2)
run: |
# Path-sensitive flow analysis of local IDisposables — bugs the flat D1
Expand Down Expand Up @@ -1108,7 +1143,10 @@ jobs:
# _owner.Memory`) read in such a handler is the view-in-a-field dangle -> OWN002 (`pooled-view-after-
# dispose`). The POOL005 field pass now also catches a full-length view of an ArrayPool `byte[]`
# buffer FIELD read past its logical length -> OWN025 (`arraypool-field-fullspan-overread`).
# use, and an injected-source region-escape. The DI captive family also has its first real-world
# case now — a singleton injecting a scoped EF `DbContext` -> DI001 (`corpus/di/`, a benchmark-only
# corpus: DI has no `.own` form, so it is not scanned by the Python `test_corpus` runner).
# Remaining backlog: a full-length view STORED into another field, a TWO-plus-hop indirect field
# use, and an injected-source region-escape. A drop below the floor is a regression.
run: python scripts/benchmark.py --min-recall 24
run: python scripts/benchmark.py --min-recall 25

36 changes: 36 additions & 0 deletions corpus/di/singleton-captures-scoped-dbcontext/after.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// AFTER (fixed). The standard remedy for a singleton that needs a scoped service:
// inject `IServiceScopeFactory` (a singleton itself) instead of the scoped
// `AppDbContext`, and open a fresh scope per operation — `using var scope =
// _scopes.CreateScope();` — resolving the DbContext inside it so it lives and is
// disposed within that operation. The singleton's constructor no longer depends on
// a scoped service, so there is no captive edge in the registration graph (DI001
// silent), and the resolve is off the scope's provider (not an injected root
// `IServiceProvider`), so the service-locator rule (DI004) stays silent too.
using System;

namespace Corpus
{
public sealed class AppDbContext { }

public sealed class NotificationService
{
private readonly IServiceScopeFactory _scopes;
public NotificationService(IServiceScopeFactory scopes) { _scopes = scopes; } // no scoped captured

public void Notify()
{
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); // per-operation scope
// ... use db within the scope ...
}
}

public static class Startup
{
public static void ConfigureServices(IServiceCollection services)
{
services.AddScoped<AppDbContext>();
services.AddSingleton<NotificationService>(); // SILENT — injects IServiceScopeFactory, not the scoped service
}
}
}
34 changes: 34 additions & 0 deletions corpus/di/singleton-captures-scoped-dbcontext/before.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// BEFORE (buggy). The canonical ASP.NET Core captive dependency (P-006 DI001):
// a SINGLETON service takes a SCOPED EF Core `DbContext` in its constructor, so
// the container builds one `AppDbContext` with the singleton and holds it for the
// whole application lifetime — an open DB connection pinned for the process, and
// request state shared across requests. Microsoft calls this "Cannot consume
// scoped service 'AppDbContext' from singleton 'NotificationService'." The
// extractor reads the conventional `IServiceCollection` registration graph
// (`Add{Singleton,Scoped}`) plus each implementation's constructor parameters, and
// ownlang/di.py flags the capture at the registration site, naming the consuming
// constructor. Representative of the pattern (a singleton background/notification
// service injecting a scoped DbContext), not verbatim from one project. The fix is
// a scope boundary — inject `IServiceScopeFactory` and resolve per operation (see
// after.cs).
using System;

namespace Corpus
{
public sealed class AppDbContext { } // scoped (an EF Core DbContext is scoped)

// registered as a SINGLETON below, but it captures the scoped DbContext:
public sealed class NotificationService
{
public NotificationService(AppDbContext db) { } // <-- captures scoped (DI001)
}

public static class Startup
{
public static void ConfigureServices(IServiceCollection services)
{
services.AddScoped<AppDbContext>(); // scoped
services.AddSingleton<NotificationService>(); // FLAGGED: singleton -> scoped (DI001)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DI001
40 changes: 40 additions & 0 deletions corpus/di/singleton-captures-scoped-dbcontext/notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Singleton captures a scoped DbContext (DI001)

**Pattern:** the canonical ASP.NET Core *captive dependency*. A service registered
`AddSingleton` takes a `AddScoped` service (here an EF Core `AppDbContext`) in its
constructor. The container builds **one** scoped instance with the singleton and
holds it for the whole application lifetime — a DB connection pinned for the
process, request-specific state shared across requests, and a `DbContext` used
concurrently from multiple threads (it is not thread-safe). Microsoft surfaces it
at startup as *"Cannot consume scoped service 'AppDbContext' from singleton
'NotificationService'."*

**Why it is exactly OwnLang's lifetime model.** A captive dependency *is* the
OWN014 region-escape rule in DI clothing: `Scoped < Singleton` (request < app), and
storing a shorter-lived value into a longer-lived owner is the violation. The core
runs the same lifetime ordering it uses for OWN014; `ownlang/di.py`
(`find_captive_dependencies`) walks the registration + constructor graph and flags
the capture **at the registration site**, naming the consuming constructor.

**The fix (after.cs).** Inject `IServiceScopeFactory` (a singleton) instead of the
scoped service, and open a fresh scope per operation
(`using var scope = _scopes.CreateScope();`), resolving the `DbContext` inside it.
The singleton's constructor no longer depends on a scoped service, so the captive
edge is gone (DI001 silent), and the resolve is off the scope's provider rather than
an injected root `IServiceProvider`, so the service-locator rule (DI004) is silent
too.

**Honesty / scope.** This is the DI family's first **real-world** corpus case; the
captive classifier was previously pinned only on the synthetic
`frontend/roslyn/samples/DiCaptiveSample.cs`. There is **no `case.own`**: the `.own`
DSL has no service-registration surface (DI lives in the `services` fact graph, not
the resource/flow language), so the captive cannot be hand-reduced to `.own` the way
an ownership bug can — `corpus/di/` is therefore scanned by the **dotnet
`corpus-benchmark` job only** (extractor → `services` graph → DI001), not the
Python `test_corpus` `.own` runner. `before.cs` / `after.cs` are representative of
the pattern, not a verbatim diff. The transitive, interface-registration, weak
(`DI002`), transient-`IDisposable` (`DI003`), and service-locator (`DI004`) variants
remain pinned on the synthetic sample.

Reference: [P-006](../../../docs/proposals/P-006-di-lifetimes.md); Microsoft "DI
guidelines — scoped service as singleton" (the captive-dependency anti-pattern).
20 changes: 16 additions & 4 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,21 @@ architectural strictness, and the borrow-checker showcase):
registration + constructor graph from `Add{Singleton,Scoped,Transient}` (generic
and `typeof(...)` forms) and the core flags the captive — direct, transitive
through a transient, or through an interface registration — CI-validated on
`DiCaptiveSample.cs`. **DI003** (a transient `IDisposable` captured by a singleton,
warning) now also fires on the same sample. Remaining: DI002 (weak-ref), the explicit
root-`GetService` form of DI003, and the consuming-constructor anchor.
`DiCaptiveSample.cs`. The whole captive family is now built end to end on the same
sample: **DI002** (a scoped service held weakly via `WeakReference<T>` — still
root-resolved/app-lived), **DI003** (a transient `IDisposable` captured by a singleton),
and **DI004** (the service-locator form — a transient `IDisposable` resolved by hand from
a singleton's injected **root** `IServiceProvider` via `GetService`/`GetRequiredService`),
all warnings; plus the **consuming-constructor anchor** (a captive names both its
registration site and the ctor that injects it, as message tail + SARIF relatedLocation).
**DI005** (the fix done wrong — a singleton that injects `IServiceScopeFactory`, opens a
scope, but **caches** the scope-resolved **scoped** service into a field, so it dangles after
the scope is disposed and is promoted to app lifetime) is built end to end too, a store-site
property anchored at the field assignment. The family now also has its first **real-world
corpus case** — a singleton injecting a scoped EF `DbContext` → DI001 (`corpus/di/`, a
benchmark-only corpus since DI has no `.own` form). Remaining (deliberate-deferral / future):
directly-injected `IServiceScopeFactory`-as-a-positive-signal recognition (P-006 OQ#3), and
the dynamic registrations that are explicit non-goals.
4. **Pool/Span** — `Rent`/`Return`, borrowed views, return-invalidates-views,
known-bug replay corpus (P-007). The borrow checker on stage at full height.
◑ *In progress* — POOL001 (leak), POOL002 (view-after-return → OWN002),
Expand Down Expand Up @@ -225,7 +237,7 @@ own scan. Label them as estimates wherever they appear.
| [P-003](proposals/P-003-lifetime-visualization.md) | Lifetime visualization (RustOwl-style) | horizon | draft |
| [P-004](proposals/P-004-wpf-lifetime-profile.md) | WPF / UI lifetime leak profile | P0 | in progress (WPF001–005 built) |
| [P-005](proposals/P-005-idisposable-ownership.md) | `IDisposable` ownership profile | P0 | draft |
| [P-006](proposals/P-006-di-lifetimes.md) | DI lifetime / captive dependency | P0 | in progress (DI001 end-to-end: core + extractor) |
| [P-006](proposals/P-006-di-lifetimes.md) | DI lifetime / captive dependency | P0 | in progress (DI001–DI005 end-to-end: core + extractor) |
| [P-007](proposals/P-007-arraypool-span.md) | ArrayPool / Span borrow-view | P1 | in progress (POOL001–003 built; 004/005 first slices) |
| [P-008](proposals/P-008-effects-and-resources.md) | Effects & resources (`Own.Effects`) | P1/P2 | draft |
| [P-009](proposals/P-009-nogc-regions.md) | No-GC / allocation-free regions | horizon | draft |
Expand Down
33 changes: 32 additions & 1 deletion docs/proposals/P-006-di-lifetimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@
`IDisposable` resolved by hand from a singleton's injected **root** `IServiceProvider` —
`GetService<T>()` / `GetRequiredService<T>()`, the service-locator anti-pattern) extends
the family to a **call site** the registration graph cannot see, also a CI-validated
warning on the same sample.
warning on the same sample. **DI005** (a singleton that resolves a **scoped** service from a
scope it *creates* — the correct `IServiceScopeFactory` pattern — but **caches it into a
field**, so the cached instance dangles after the scope is disposed and is promoted to
application lifetime: the captive returns, hidden behind the API meant to fix it) is the
newest member — a **store-site** property (the field assignment), anchored there like DI004
anchors at its call site; CI-validated on the same sample (`ScopeCachingService` flagged;
the used-in-scope `ScopeUsingService` and the cached-singleton `ClockCachingService` silent).
A first **real-world corpus case** now grounds DI001 outside
the synthetic sample — a singleton injecting a scoped EF `DbContext`
(`corpus/di/singleton-captures-scoped-dbcontext`), scored by the dotnet `corpus-benchmark`
job (a benchmark-only corpus: DI has no `.own` reduction, so it is not run by the Python
`test_corpus` `.own` suite).
- **Depends on:** `spec/Lifetimes.md` (the region-ordering model behind OWN014),
[P-001](P-001-csharp-extractor.md) (the C# seam). See
[`docs/ROADMAP.md`](../ROADMAP.md) (Milestone 3).
Expand Down Expand Up @@ -72,6 +83,26 @@ to a longer-lived region) already models it.
restricted to real fields (no local-alias false match) — each pinned by a control on
`DiCaptiveSample.cs` (`ConnectionResolver` / `ExprBodiedResolver` / transitive `WrapperResolver`
flagged; `ScopedResolver` / `PlainResolver` / `RequestResolver` silent).
- **DI005 (warning) — shipped:** the *fix done wrong*. A singleton that **does** inject
`IServiceScopeFactory` (or its provider) and opens a scope (`CreateScope()`) — the remedy
suggested below — but then **caches the scope-resolved scoped service into a field**. The
`using` scope is disposed when the operation ends, so the cached instance is used after the
scope (and the service) is disposed (use-after-dispose) *and* lives for the application
lifetime: the captive the scope was meant to avoid, now invisible to the static surface that
"sees the fix". The extractor records the scope-creator names (injected `IServiceScopeFactory`
/ provider, with the same this-field discipline as DI004), the scope locals their
`CreateScope()` produces, and each `scope.ServiceProvider.Get(Required)Service<T>()` whose
result is **assigned to a field** into a `scope_cached` list with its store site;
`find_scope_cached_captives` walks each cached entry's **strong transient graph** like DI001 —
a cached **scoped** service is the captive directly, and a cached **transient** that ctor-injects
a scoped service (directly or transitively) drags it into the singleton's lifetime too (the scope
disposed it; the singleton keeps the transient holding it). A cached singleton is shareable.
A **store-site** property (anchored at the field assignment, like DI004's call site), filed as a
distinct code (different detection, different fix: resolve inside the scope per use, do not
cache). Precision guards — singleton-only, scoped-cached-type-only (a cached singleton is
shareable, a cached transient is the DI003/DI004 family), real-field store only (a value used
in the scope and discarded is a local, not a field) — pinned on `DiCaptiveSample.cs`
(`ScopeCachingService` flagged; `ScopeUsingService` / `ClockCachingService` silent).

Suggested fix attached to DI001/DI002: inject `IServiceScopeFactory`, and per
operation `using var scope = factory.CreateScope();` then resolve the scoped
Expand Down
Loading
Loading