Skip to content

Commit 0acaaa6

Browse files
paulirwinclaude
andcommitted
Add DrainReclaimer unit tests; alphabetize feature constants
Unit tests for the lock-free DrainReclaimer primitive, independent of MMapDirectory: registration returns distinct slots, Enter/Exit balances and is re-entrant, the ReadScope using form ends the bracket, Enter after Close (and on a slot registered after Close) throws AlreadyClosed without leaving depth elevated, Close cleans up immediately when idle, defers while a user is inside the bracket (via the OnEnterForTest seam) then runs on drain, runs the cleanup exactly once with many users racing, and a nightly concurrent stress test asserting the cleanup never runs while a user is inside the bracket (the use-after-free analog) across 2000 iterations. Also sort the netstandard2.1+ FEATURE_* DefineConstants alphabetically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a41864c commit 0acaaa6

2 files changed

Lines changed: 332 additions & 3 deletions

File tree

Directory.Build.targets

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,15 @@
105105
<PropertyGroup Condition=" '$(TargetFramework)' == 'netstandard2.1' Or $(TargetFramework.StartsWith('netcoreapp3.')) Or $(TargetFramework.StartsWith('net5.')) Or $(TargetFramework.StartsWith('net6.')) Or $(TargetFramework.StartsWith('net7.')) Or $(TargetFramework.StartsWith('net8.')) Or $(TargetFramework.StartsWith('net9.')) Or $(TargetFramework.StartsWith('net10.')) ">
106106

107107
<DefineConstants>$(DefineConstants);FEATURE_ARRAY_FILL</DefineConstants>
108-
<DefineConstants>$(DefineConstants);FEATURE_CONDITIONALWEAKTABLE_ENUMERATOR</DefineConstants>
109-
<DefineConstants>$(DefineConstants);FEATURE_MEMORYBARRIER_PROCESSWIDE</DefineConstants>
110108
<DefineConstants>$(DefineConstants);FEATURE_CONDITIONALWEAKTABLE_ADDORUPDATE</DefineConstants>
109+
<DefineConstants>$(DefineConstants);FEATURE_CONDITIONALWEAKTABLE_ENUMERATOR</DefineConstants>
111110
<DefineConstants>$(DefineConstants);FEATURE_ENCODING_GETSTRING_READONLYSPAN</DefineConstants>
111+
<DefineConstants>$(DefineConstants);FEATURE_MEMORYBARRIER_PROCESSWIDE</DefineConstants>
112112
<DefineConstants>$(DefineConstants);FEATURE_MEMORYMARSHAL_CREATEREADONLYSPAN</DefineConstants>
113113
<DefineConstants>$(DefineConstants);FEATURE_NUMBER_PARSE_READONLYSPAN</DefineConstants>
114+
<DefineConstants>$(DefineConstants);FEATURE_QUEUE_TRYDEQUEUE_TRYPEEK</DefineConstants>
114115
<DefineConstants>$(DefineConstants);FEATURE_STREAM_READ_SPAN</DefineConstants>
115116
<DefineConstants>$(DefineConstants);FEATURE_STRINGBUILDER_APPEND_READONLYSPAN</DefineConstants>
116-
<DefineConstants>$(DefineConstants);FEATURE_QUEUE_TRYDEQUEUE_TRYPEEK</DefineConstants>
117117

118118
</PropertyGroup>
119119

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
using Lucene.Net.Attributes;
2+
using Lucene.Net.Util;
3+
using NUnit.Framework;
4+
using System;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Assert = Lucene.Net.TestFramework.Assert;
8+
9+
namespace Lucene.Net.Support
10+
{
11+
/*
12+
* Licensed to the Apache Software Foundation (ASF) under one or more
13+
* contributor license agreements. See the NOTICE file distributed with
14+
* this work for additional information regarding copyright ownership.
15+
* The ASF licenses this file to You under the Apache License, Version 2.0
16+
* (the "License"); you may not use this file except in compliance with
17+
* the License. You may obtain a copy of the License at
18+
*
19+
* http://www.apache.org/licenses/LICENSE-2.0
20+
*
21+
* Unless required by applicable law or agreed to in writing, software
22+
* distributed under the License is distributed on an "AS IS" BASIS,
23+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
24+
* See the License for the specific language governing permissions and
25+
* limitations under the License.
26+
*/
27+
28+
/// <summary>
29+
/// LUCENENET specific: unit tests for <see cref="DrainReclaimer"/>, the lock-free
30+
/// per-user drain barrier that defers a cleanup action until all in-flight users
31+
/// have drained. These exercise the handshake at the primitive level, independent
32+
/// of <c>MMapDirectory</c> (its real consumer): registration, the
33+
/// <c>Enter</c>/<c>Exit</c> bracket and re-entrancy, fail-fast after close, and
34+
/// the core invariant that the cleanup runs exactly once and only after every
35+
/// active user has drained.
36+
/// </summary>
37+
[TestFixture]
38+
[LuceneNetSpecific]
39+
public class TestDrainReclaimer : LuceneTestCase
40+
{
41+
// ------------------------------------------------------------------
42+
// Registration + basic bracket
43+
// ------------------------------------------------------------------
44+
45+
[Test]
46+
public void TestRegisterReturnsDistinctSlots()
47+
{
48+
var r = new DrainReclaimer();
49+
var a = r.Register();
50+
var b = r.Register();
51+
Assert.IsNotNull(a);
52+
Assert.IsNotNull(b);
53+
Assert.AreNotSame(a, b, "each Register must return its own slot");
54+
}
55+
56+
[Test]
57+
public void TestEnterExitBalancesDepth()
58+
{
59+
var r = new DrainReclaimer();
60+
var slot = r.Register();
61+
Assert.AreEqual(0, slot.Depth);
62+
slot.EnterCore();
63+
Assert.AreEqual(1, slot.Depth, "EnterCore bumps depth");
64+
slot.Exit();
65+
Assert.AreEqual(0, slot.Depth, "Exit restores depth");
66+
}
67+
68+
[Test]
69+
public void TestEnterIsReentrant()
70+
{
71+
var r = new DrainReclaimer();
72+
var slot = r.Register();
73+
slot.EnterCore();
74+
slot.EnterCore();
75+
Assert.AreEqual(2, slot.Depth, "nested Enter increments depth");
76+
slot.Exit();
77+
Assert.AreEqual(1, slot.Depth, "inner Exit leaves the outer bracket open");
78+
slot.Exit();
79+
Assert.AreEqual(0, slot.Depth);
80+
}
81+
82+
[Test]
83+
public void TestReadScopeUsingEndsBracket()
84+
{
85+
var r = new DrainReclaimer();
86+
var slot = r.Register();
87+
using (slot.Enter())
88+
{
89+
Assert.AreEqual(1, slot.Depth, "the using scope holds the bracket open");
90+
}
91+
Assert.AreEqual(0, slot.Depth, "disposing the scope ends the bracket");
92+
}
93+
94+
// ------------------------------------------------------------------
95+
// Fail-fast after Close
96+
// ------------------------------------------------------------------
97+
98+
[Test]
99+
public void TestEnterAfterCloseThrowsAlreadyClosed()
100+
{
101+
var r = new DrainReclaimer();
102+
var slot = r.Register();
103+
r.Close(() => { });
104+
Assert.IsTrue(r.IsClosed);
105+
try
106+
{
107+
slot.EnterCore();
108+
Assert.Fail("Enter after Close must throw AlreadyClosed");
109+
}
110+
catch (Exception e) when (e.IsAlreadyClosedException())
111+
{
112+
// expected
113+
}
114+
Assert.AreEqual(0, slot.Depth, "a rejected Enter must not leave depth elevated");
115+
}
116+
117+
[Test]
118+
public void TestSlotRegisteredAfterCloseStillFailsFast()
119+
{
120+
// Registering a brand-new user after Close is allowed (it just gets a
121+
// slot), but its first Enter must observe the closed flag and throw.
122+
var r = new DrainReclaimer();
123+
r.Close(() => { });
124+
var late = r.Register();
125+
try
126+
{
127+
late.EnterCore();
128+
Assert.Fail("Enter on a slot registered after Close must throw");
129+
}
130+
catch (Exception e) when (e.IsAlreadyClosedException())
131+
{
132+
// expected
133+
}
134+
}
135+
136+
// ------------------------------------------------------------------
137+
// Cleanup timing: idle vs active
138+
// ------------------------------------------------------------------
139+
140+
[Test]
141+
public void TestCloseRunsCleanupImmediatelyWhenIdle()
142+
{
143+
var r = new DrainReclaimer();
144+
r.Register(); // a registered-but-idle user must not block cleanup
145+
int cleaned = 0;
146+
r.Close(() => cleaned++);
147+
Assert.AreEqual(1, cleaned, "with no active user, Close runs the cleanup inline");
148+
}
149+
150+
[Test]
151+
public void TestCloseDefersCleanupWhileUserActive()
152+
{
153+
// A user parked inside the bracket on another thread must hold off the
154+
// cleanup; once it exits, the cleanup runs.
155+
var r = new DrainReclaimer();
156+
var slot = r.Register();
157+
158+
var entered = new ManualResetEventSlim(false);
159+
var resume = new ManualResetEventSlim(false);
160+
slot.OnEnterForTest = () => { entered.Set(); resume.Wait(); };
161+
162+
int cleaned = 0;
163+
var user = new Thread(() =>
164+
{
165+
slot.EnterCore(); // parks inside the bracket via OnEnterForTest
166+
slot.Exit();
167+
}) { IsBackground = true };
168+
user.Start();
169+
170+
Assert.IsTrue(entered.Wait(TimeSpan.FromSeconds(5)), "user should park inside the bracket");
171+
172+
// Close from this thread while the user is active: must NOT clean up yet.
173+
var closer = new Thread(() => r.Close(() => Interlocked.Increment(ref cleaned)))
174+
{ IsBackground = true };
175+
closer.Start();
176+
177+
Thread.Sleep(150); // let Close's bounded spin run and give up
178+
Assert.AreEqual(0, Volatile.Read(ref cleaned),
179+
"cleanup must be deferred while a user is inside the bracket");
180+
181+
// Release the user: its Exit drains the last reference and runs cleanup.
182+
resume.Set();
183+
Assert.IsTrue(user.Join(TimeSpan.FromSeconds(5)), "user thread should finish");
184+
Assert.IsTrue(closer.Join(TimeSpan.FromSeconds(5)), "closer thread should finish");
185+
186+
for (int i = 0; i < 2000 && Volatile.Read(ref cleaned) == 0; i++) Thread.Sleep(1);
187+
Assert.AreEqual(1, Volatile.Read(ref cleaned),
188+
"once the user exits the bracket, the deferred cleanup must run exactly once");
189+
}
190+
191+
[Test]
192+
public void TestCleanupRunsExactlyOnce()
193+
{
194+
// Many users active at Close; cleanup must fire exactly once no matter
195+
// which thread (a draining Exit, or Close's own scan) wins the race.
196+
const int users = 8;
197+
var r = new DrainReclaimer();
198+
var slots = new DrainReclaimer.Slot[users];
199+
for (int i = 0; i < users; i++) slots[i] = r.Register();
200+
201+
int cleaned = 0;
202+
var resume = new ManualResetEventSlim(false);
203+
var entered = new CountdownEvent(users);
204+
var threads = new Thread[users];
205+
for (int i = 0; i < users; i++)
206+
{
207+
var slot = slots[i];
208+
slot.OnEnterForTest = () => { entered.Signal(); resume.Wait(); };
209+
threads[i] = new Thread(() => { slot.EnterCore(); slot.Exit(); }) { IsBackground = true };
210+
threads[i].Start();
211+
}
212+
Assert.IsTrue(entered.Wait(TimeSpan.FromSeconds(5)), "all users should park");
213+
214+
var closer = new Thread(() => r.Close(() => Interlocked.Increment(ref cleaned)))
215+
{ IsBackground = true };
216+
closer.Start();
217+
218+
resume.Set();
219+
foreach (var t in threads) Assert.IsTrue(t.Join(TimeSpan.FromSeconds(5)));
220+
Assert.IsTrue(closer.Join(TimeSpan.FromSeconds(5)));
221+
222+
for (int i = 0; i < 2000 && Volatile.Read(ref cleaned) == 0; i++) Thread.Sleep(1);
223+
Assert.AreEqual(1, Volatile.Read(ref cleaned), "cleanup must run exactly once");
224+
}
225+
226+
[Test]
227+
public void TestSlotsAreIndependent()
228+
{
229+
// One user being active must not be confused with another being active:
230+
// closing while only slot A is active defers; draining A then cleans up,
231+
// even though idle slot B was also registered.
232+
var r = new DrainReclaimer();
233+
var a = r.Register();
234+
var b = r.Register();
235+
b.EnterCore();
236+
b.Exit(); // B is now idle
237+
238+
a.EnterCore(); // only A active
239+
int cleaned = 0;
240+
var closer = new Thread(() => r.Close(() => Interlocked.Increment(ref cleaned)))
241+
{ IsBackground = true };
242+
closer.Start();
243+
Thread.Sleep(150);
244+
Assert.AreEqual(0, Volatile.Read(ref cleaned), "A active -> deferred");
245+
246+
a.Exit();
247+
Assert.IsTrue(closer.Join(TimeSpan.FromSeconds(5)));
248+
for (int i = 0; i < 2000 && Volatile.Read(ref cleaned) == 0; i++) Thread.Sleep(1);
249+
Assert.AreEqual(1, Volatile.Read(ref cleaned));
250+
}
251+
252+
// ------------------------------------------------------------------
253+
// Concurrency stress: never clean up under an active user
254+
// ------------------------------------------------------------------
255+
256+
[Test, LuceneNetSpecific, Slow, Nightly]
257+
public void TestConcurrentEnterExitVsCloseNeverCleansUnderActiveUser()
258+
{
259+
// Hammer the handshake: N users repeatedly Enter/Exit while one thread
260+
// Closes at a random moment. The cleanup callback asserts no user is
261+
// inside the bracket when it runs (would be the use-after-free analog).
262+
// Repeated across many iterations to shake out races.
263+
const int iterations = 2000;
264+
const int userThreads = 6;
265+
266+
for (int iter = 0; iter < iterations; iter++)
267+
{
268+
var r = new DrainReclaimer();
269+
var slots = new DrainReclaimer.Slot[userThreads];
270+
for (int i = 0; i < userThreads; i++) slots[i] = r.Register();
271+
272+
int active = 0; // live count of users inside the bracket
273+
int violation = 0; // set if cleanup saw active > 0
274+
int cleaned = 0;
275+
var stop = new ManualResetEventSlim(false);
276+
277+
var workers = new Task[userThreads];
278+
for (int i = 0; i < userThreads; i++)
279+
{
280+
var slot = slots[i];
281+
workers[i] = Task.Run(() =>
282+
{
283+
try
284+
{
285+
while (!stop.IsSet)
286+
{
287+
slot.EnterCore();
288+
Interlocked.Increment(ref active);
289+
// tiny critical section
290+
Interlocked.Decrement(ref active);
291+
slot.Exit();
292+
}
293+
}
294+
catch (Exception e) when (e.IsAlreadyClosedException())
295+
{
296+
// expected once Close wins; EnterCore threw before we
297+
// incremented active, so nothing to undo.
298+
}
299+
});
300+
}
301+
302+
// Let the workers run a moment, then close.
303+
Thread.Yield();
304+
r.Close(() =>
305+
{
306+
Interlocked.Increment(ref cleaned);
307+
if (Volatile.Read(ref active) != 0)
308+
{
309+
Interlocked.Exchange(ref violation, 1);
310+
}
311+
});
312+
313+
stop.Set();
314+
Task.WaitAll(workers);
315+
316+
Assert.AreEqual(0, Volatile.Read(ref violation),
317+
$"iteration {iter}: cleanup ran while a user was inside the bracket");
318+
Assert.AreEqual(1, Volatile.Read(ref cleaned),
319+
$"iteration {iter}: cleanup must run exactly once");
320+
// After everything drains, a final check: no slot left elevated.
321+
foreach (var s in slots)
322+
{
323+
Assert.AreEqual(0, Volatile.Read(ref s.Depth),
324+
$"iteration {iter}: a slot was left with depth != 0");
325+
}
326+
}
327+
}
328+
}
329+
}

0 commit comments

Comments
 (0)