@@ -19,7 +19,7 @@ what the existing test suite does and does not actually verify.
1919This distinction is the crux of the entire bug.
2020
2121When a connection participates in a ` TransactionScope ` , it ends up in one of ** two
22- mutually exclusive ** states.
22+ distinct ** states. They overlap, but neither implies the other .
2323
2424### Delegated root — "I * own* this transaction"
2525
@@ -28,10 +28,26 @@ down to SQL Server rather than paying for a distributed coordinator. The transac
2828lives ** on** the connection.
2929
3030- ` IsTransactionRoot ` → ` true `
31- - ` EnlistedTransaction ` → ** ` null ` **
31+ - ` EnlistedTransaction ` → set at first, then ** cleared ** later (see below)
3232
33- The ` null ` is not an oversight. There is no external transaction object to point at,
34- because the transaction * is here* .
33+ ` IsTransactionRoot ` is not a stored flag. It is derived:
34+
35+ ``` csharp
36+ internal bool IsTransactionRoot => DelegatedTransaction ? .IsActive == true ;
37+ ```
38+
39+ Enlistment sets ` EnlistedTransaction ` unconditionally, so a * freshly* delegated root
40+ has both. But once the transaction is no longer ` Active ` ,
41+ ` DbConnectionInternal.DetachCurrentTransactionIfEnded ` clears ` EnlistedTransaction ` :
42+
43+ ``` csharp
44+ transactionIsDead = enlistedTransaction .TransactionInformation .Status != TransactionStatus .Active ;
45+ if (transactionIsDead ) { DetachTransaction (enlistedTransaction , true ); }
46+ ```
47+
48+ The delegated transaction, meanwhile, can still report ` IsActive == true ` . That
49+ transient ** half-state** — root, but no ` EnlistedTransaction ` — is precisely the state
50+ issue #4001 reproduces in.
3551
3652### Enlisted participant — "I * joined* someone else's transaction"
3753
@@ -43,9 +59,10 @@ each connection enlists in it.
4359
4460### The trap
4561
46- These two states never look alike. ** A delegated root always has a ` null `
47- ` EnlistedTransaction ` .** Any check that tests only one of these fields silently
48- misses the other case — and does so with no exception at the point of the mistake.
62+ Neither field subsumes the other: a delegated root can have a ` null `
63+ ` EnlistedTransaction ` , and an enlisted participant is never a root. Any check that
64+ tests only one of these fields silently misses the other case — and does so with no
65+ exception at the point of the mistake.
4966
5067---
5168
@@ -96,13 +113,17 @@ internal protected override bool IsNonPoolableTransactionRoot
96113 => IsTransactionRoot && (! Is2008OrNewer || Pool == null );
97114```
98115
99- Since ` Is2008OrNewer ` is true for every supported server, the entire pre- #3019
100- condition reduces to:
116+ Substituting, the pre- #3019 condition was:
101117
102118``` csharp
103- IsTransactionRoot && Pool != null
119+ IsTransactionRoot && Is2008OrNewer && Pool != null
104120```
105121
122+ The ` Is2008OrNewer ` term is not vestigial. ` AdapterUtil.ValidateTdsVersion ` still accepts
123+ ` TdsEnums.SQL2005_VERSION ` , and ` ConnectionCapabilities.Is2008R2OrNewer ` returns ` false `
124+ for it. A pre-2008 server cannot carry a delegated transaction across a reset, so such a
125+ connection must not be recycled with one attached.
126+
106127So the two conditions were:
107128
108129| | Question it asked | Covered | Missed |
@@ -120,13 +141,26 @@ This reframes the fix: the goal is not to undo #3019, it is to finish it.
120141
121142## 4. The fix
122143
144+ The predicate is extracted into a helper so it can be tested directly:
145+
123146``` csharp
124- _parser .PrepareResetConnection (
125- Pool is not null &&
126- (IsTransactionRoot || EnlistedTransaction is not null ));
147+ internal static bool ShouldPreserveTransactionOnReset (
148+ bool isPooled ,
149+ bool isTransactionRoot ,
150+ bool is2008OrNewer ,
151+ bool hasEnlistedTransaction )
152+ {
153+ if (! isPooled )
154+ {
155+ return false ;
156+ }
157+
158+ return (isTransactionRoot && is2008OrNewer ) || hasEnlistedTransaction ;
159+ }
127160```
128161
129- This is exactly ` OLD || NEW ` .
162+ This is exactly ` OLD || NEW ` , with both prior conditions preserved verbatim — including
163+ the ` Is2008OrNewer ` guard that only ever applied to the delegated-root arm.
130164
131165---
132166
@@ -135,21 +169,23 @@ This is exactly `OLD || NEW`.
135169This is the question that matters most, and it is answerable by inspection rather than
136170by testing. Every reachable state, for a pooled connection:
137171
138- | ` IsTransactionRoot ` | ` EnlistedTransaction ` | Pre- #3019 | #3019 | ** This fix** |
139- | :---:| :---:| :---:| :---:| :---:|
140- | ` false ` | ` null ` | ` false ` | ` false ` | ` false ` |
141- | ** ` true ` ** | ** ` null ` ** | ✅ ` true ` | ❌ ` false ` ← ** #4001 ** | ✅ ** ` true ` ** |
142- | ** ` false ` ** | ** set** | ❌ ` false ` ← ** #2970 ** | ✅ ` true ` | ✅ ** ` true ` ** |
143- | ` true ` | set | ` true ` | ` true ` | ` true ` |
172+ | ` IsTransactionRoot ` | ` Is2008OrNewer ` | ` EnlistedTransaction ` | Pre- #3019 | #3019 | ** This fix** |
173+ | :---:| :---:| :---:| :---:| :---:| :---:|
174+ | ` false ` | ` true ` | ` null ` | ` false ` | ` false ` | ` false ` |
175+ | ** ` true ` ** | ** ` true ` ** | ** ` null ` ** | ✅ ` true ` | ❌ ` false ` ← ** #4001 ** | ✅ ** ` true ` ** |
176+ | ` true ` | ` false ` | ` null ` | ` false ` | ` false ` | ` false ` |
177+ | ** ` false ` ** | any | ** set** | ❌ ` false ` ← ** #2970 ** | ✅ ` true ` | ✅ ** ` true ` ** |
178+ | ` true ` | any | set | see above | ` true ` | ` true ` |
144179
145180Read the ** #2970 row** . That is the row PR #3019 was created to fix, and this fix still
146181evaluates ` true ` there. It is untouched.
147182
148183Reintroducing #2970 would require that cell to flip to ` false ` , and ` A || B ` cannot
149184evaluate ` false ` while ` B ` is ` true ` . The guarantee is structural, not empirical.
150185
151- The condition is a strict ** superset** of both prior behaviors. There is no input on
152- which it returns ` false ` where either predecessor returned ` true ` .
186+ Because each arm reproduces its original predecessor exactly, the condition returns
187+ ` true ` wherever either predecessor did, and never returns ` false ` where one of them
188+ returned ` true ` .
153189
154190---
155191
@@ -230,8 +266,43 @@ to cover #2970 — is tagged `[Trait("Category", "flaky")]` and passes against c
230266carries the #2970 bug.
231267
232268The practical conclusion: ** the 9/9 result is evidence of no collateral damage, not
233- evidence that the fix works.** The evidence that the fix works is the reproduction
234- matrix above and the structural argument in section 5. Those should carry the weight.
269+ evidence that the fix works.**
270+
271+ ### The regression test that was added
272+
273+ An end-to-end reproduction was attempted extensively and abandoned. The ` #4001 ` state
274+ requires a narrow simultaneity — the transaction's status already non-` Active ` (so
275+ ` DetachCurrentTransactionIfEnded ` has cleared ` EnlistedTransaction ` ) while
276+ ` DelegatedTransaction.IsActive ` is still ` true ` — and which of two teardown paths in
277+ ` SqlDelegatedTransaction ` wins is a race:
278+
279+ - ` TransactionEnded ` sets ` _active = false ` and * immediately* calls
280+ ` DoomThisConnection() ` . If this path runs first, the delegate is already inactive
281+ before any reset, so the state is never observed.
282+ - ` Rollback ` , driven from ` TransactionScope.Dispose ` , sets ` _active = false ` * after* the
283+ reset. This is the ordering the reporter hit.
284+
285+ Roughly twenty harness variants — varying pool size, pool implementation, promotion
286+ success, explicit rollback, and parking the delegate in the transacted pool ahead of the
287+ enlistment — consistently drove the first path. A test built on that race would be
288+ flaky, which is the same defect ` Test_EnlistedTransactionPreservedWhilePooled ` already
289+ demonstrates.
290+
291+ Instead the predicate was extracted into
292+ ` SqlConnectionInternal.ShouldPreserveTransactionOnReset ` and pinned directly by
293+ ` SqlConnectionInternalResetTransactionTests ` , following the existing precedent of
294+ ` ResolveLoginTimeout ` / ` SqlConnectionInternalTimeoutTests ` . The tests were themselves
295+ mutation-tested:
296+
297+ | Condition compiled into the helper | Bug it contains | New tests |
298+ | ---| ---| ---|
299+ | ` hasEnlistedTransaction ` (#3019 ) | ** #4001 ** | ❌ 2 failed |
300+ | ` isTransactionRoot && is2008OrNewer ` (pre- #3019 ) | ** #2970 ** | ❌ 4 failed |
301+ | Union without the 2008 guard | pre-2008 regression | ❌ 2 failed |
302+ | The shipped condition | none | ✅ 15 passed |
303+
304+ This satisfies "fails before the change, passes after" for ** both** regressions, and
305+ does so deterministically and without a server.
235306
236307---
237308
0 commit comments