|
| 1 | +# Case study: the orphaned awaitable (OWN053) and Wolverine's `ScheduleRetryAsync` |
| 2 | + |
| 3 | +**What the analyzer saw.** Scanning JasperFx/wolverine (a frozen benchmark |
| 4 | +consumer, main at `7ee3df90` / `c20fb0cc`) the OwnIR extractor found exactly one |
| 5 | +production local in 772 project/TFM units that is initialised by an un-awaited, |
| 6 | +effectful awaitable and never referenced again: |
| 7 | + |
| 8 | +```csharp |
| 9 | +// src/Persistence/Wolverine.Postgresql/Transport/PostgresqlQueueSender.cs |
| 10 | +try |
| 11 | +{ |
| 12 | + var tx = conn.BeginTransactionAsync(cancellationToken); // never awaited, never read |
| 13 | + await scheduleMessageAsync(envelope, cancellationToken, conn); |
| 14 | +} |
| 15 | +finally |
| 16 | +{ |
| 17 | + await conn.CloseAsync(); |
| 18 | +} |
| 19 | +``` |
| 20 | + |
| 21 | +**Why it matters (measured, not assumed).** Against Npgsql 10.0.3 and PostgreSQL 16: |
| 22 | + |
| 23 | +- `BeginTransactionAsync` completes synchronously (`IsCompleted == true` right |
| 24 | + after the call) and changes the connection state inline, whether or not the |
| 25 | + returned `ValueTask<NpgsqlTransaction>` is ever awaited. |
| 26 | +- Every later command on the connection runs inside that transaction; a second |
| 27 | + `BeginTransactionAsync` throws "a transaction is already in progress". |
| 28 | +- Nobody holds the transaction, so nothing commits or disposes it; closing the |
| 29 | + connection rolls the work back. A probe that inserts after the orphaned call |
| 30 | + and closes the connection loses the insert. |
| 31 | + |
| 32 | +**Why the message is not lost today.** Driving the real `PostgresqlQueueSender` |
| 33 | +with an incoming row present gives `incoming = 0, scheduled = 1` after the call: |
| 34 | +the command right before the `try` is one autocommitted batch (`delete from |
| 35 | +incoming ...; insert into scheduled ... on conflict do update`) that already moves |
| 36 | +the message. The write inside the orphaned transaction is a redundant second upsert |
| 37 | +whose rollback is invisible. The defect is real — an unobserved acquisition, a |
| 38 | +redundant rolled-back write, and a latent trap for any write placed after the |
| 39 | +orphan — but its current impact is robustness, not data loss. The first |
| 40 | +hand-written "exact shape" probe omitted that batch and overstated the impact; |
| 41 | +the correction is recorded (erratum 5 in the research paperwork) and is the |
| 42 | +reason the upstream report describes the measured behaviour only. |
| 43 | + |
| 44 | +**Why nothing else reported it.** CA2012, VSTHRD110, MA0134 and CS4014 are silent |
| 45 | +by design: assigning the task to a local counts as "observing it later". CS0219 is |
| 46 | +not issued for a method-call result. IDE0059 reports the pattern in plain and |
| 47 | +async shapes but goes silent inside `try` / `finally` — exactly this site's shape. |
| 48 | + |
| 49 | +**What Own.NET does differently.** OWN053 looks at the *subsequent life of the |
| 50 | +acquired protocol object*, not at the statement form: an awaitable whose result or |
| 51 | +completion carries an obligation (an owned result, or a connection / transaction |
| 52 | +lifecycle call) that is obtained and then never awaited, returned, stored, passed |
| 53 | +or otherwise observed. It is deliberately narrow — discards and bare statements |
| 54 | +belong to other rules, non-effectful awaitables are out of scope, and new |
| 55 | +lifecycle names enter only with a runtime witness — and it offers no automatic |
| 56 | +fix: only the author knows whether the call should be awaited, kept, or made an |
| 57 | +explicit fire-and-forget. |
| 58 | + |
| 59 | +**Evidence trail.** Preregistration before any code, an exact Npgsql runtime |
| 60 | +falsifier, the analyzer-overlap measurement, an 8-consumer census (one orphan), |
| 61 | +and an OFF/ON scan of the prototype over 766 units with Python/Rust parity and |
| 62 | +byte-identical OFF facts; the records live in the Own.NET-paperwork repository |
| 63 | +(`paper-eval/h29/`), the probes and drivers in `corpus/ownership-lab/h29/`. |
0 commit comments