Skip to content

Commit 3dbdaef

Browse files
vwilsonpaulirwinclaude
authored
Fix FSDirectory fsync race on output close (apache#1292)
Co-authored-by: Paul Irwin <paulirwin@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e6dd61a commit 3dbdaef

3 files changed

Lines changed: 249 additions & 58 deletions

File tree

src/Lucene.Net.Tests/Index/TestIndexWriterOnJRECrash.cs

Lines changed: 184 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
using NUnit.Framework;
55
using RandomizedTesting.Generators;
66
using System;
7+
using System.Collections.Generic;
78
using System.Diagnostics;
89
using System.Globalization;
910
using System.IO;
1011
using System.Linq;
1112
using System.Net;
1213
using System.Net.Sockets;
1314
using System.Reflection;
15+
using System.Runtime.InteropServices;
16+
using System.Text;
1417
using System.Threading;
1518
using BaseDirectoryWrapper = Lucene.Net.Store.BaseDirectoryWrapper;
1619
using Assert = Lucene.Net.TestFramework.Assert;
@@ -47,12 +50,15 @@ public class TestIndexWriterOnJRECrash : TestNRTThreads
4750

4851
[Test]
4952
[Slow]
50-
[AwaitsFix]
5153
public override void TestNRTThreads_Mem()
5254
{
5355
//if we are not the fork
5456
if (!SystemProperties.GetPropertyAsBoolean("tests:crashmode", false))
5557
{
58+
// LUCENENET: Bail out early (Inconclusive) if the current platform can't launch the fork,
59+
// rather than hanging in WaitForProcessId waiting for a process that will never start.
60+
EnsureForkPlatformSupported();
61+
5662
// try up to 10 times to create an index
5763
for (int i = 0; i < 10; i++)
5864
{
@@ -74,12 +80,17 @@ public override void TestNRTThreads_Mem()
7480
// Note this is the vstest.console process we are tracking here.
7581
p = ForkTest(tempDir.FullName, port);
7682

77-
TextWriter childOut = BeginOutput(p, out ThreadJob stdOutPumper, out ThreadJob stdErrPumper);
83+
// LUCENENET: Capture STDERR so we can report it if the fork fails to start or
84+
// exits with a non-zero exit code.
85+
StringBuilder stdErrCapture = new StringBuilder();
86+
TextWriter childOut = BeginOutput(p, stdErrCapture, out ThreadJob stdOutPumper, out ThreadJob stdErrPumper);
7887

7988
// LUCENENET: Note that ForkTest() creates the vstest.console.exe process.
8089
// This spawns testhost.exe, which runs our test. We wait until
8190
// the process starts and transmits its own PID so we know who to kill later.
82-
int processIdToKill = WaitForProcessId(listener);
91+
// If the fork exits before connecting (e.g. a build/launch error), this throws
92+
// with the fork's exit code and STDERR rather than hanging indefinitely.
93+
int processIdToKill = WaitForProcessId(listener, p, stdErrCapture);
8394

8495
// Setup a time to crash the forked thread
8596
int crashTime = TestUtil.NextInt32(Random, 4000, 5000); // LUCENENET: Adjusted these up by 1 second to give our tests some more time to spin up
@@ -162,31 +173,46 @@ public Process ForkTest(string tempDir, int port)
162173

163174
//get the folder that's in
164175
string theDirectory = Path.GetDirectoryName(testAssemblyPath);
176+
// LUCENENET: Only constrain the target platform when running as x86. Since .NET 8, an x86
177+
// `dotnet test` fork will not run unless the 32-bit SDK is installed separately (the 64-bit
178+
// SDK is no longer sufficient), so we verify it up front and skip the test if it is missing
179+
// rather than hanging forever in WaitForProcessId. For x64/ARM64 we leave the platform off and
180+
// let vstest auto-detect a compatible host, which avoids a similar "Could not find 'dotnet'
181+
// host for the 'X64' architecture" hang on ARM64 hosts.
182+
var arguments = new List<string>
183+
{
184+
// LUCENENET NOTE: dotnet test doesn't need the --no-build flag since we are passing the DLL path in
185+
"test", testAssemblyPath,
186+
"--framework", GetTargetFramework(),
187+
"--filter", nameof(TestIndexWriterOnJRECrash),
188+
"--logger:\"console;verbosity=normal\"",
189+
"--",
190+
};
191+
192+
string targetPlatform = GetTargetPlatform();
193+
if (targetPlatform != null)
194+
{
195+
arguments.Add($"RunConfiguration.TargetPlatform={targetPlatform}");
196+
}
197+
198+
// LUCENENET NOTE: Since in our CI environment we create a lucene.testsettings.json file
199+
// for all tests, we need to pass some of these settings as test run parameters to override
200+
// for this process. These are read as system properties on the inside of the application.
201+
arguments.Add(TestRunParameter("assert", "true"));
202+
arguments.Add(TestRunParameter("tests:seed", SeedUtils.FormatSeed(Random.NextInt64())));
203+
arguments.Add(TestRunParameter("tests:culture", Thread.CurrentThread.CurrentCulture.Name));
204+
arguments.Add(TestRunParameter("tests:crashmode", "true"));
205+
// passing NIGHTLY to this test makes it run for much longer, easier to catch it in the act...
206+
arguments.Add(TestRunParameter("tests:nightly", "true"));
207+
arguments.Add(TestRunParameter("tempDir", tempDir));
208+
// This port is for passing the process ID of the fork back to the original test so it can kill it.
209+
arguments.Add(TestRunParameter("tests:crashtestport", port.ToString(CultureInfo.InvariantCulture)));
210+
165211
// Set up the process to run the console app
166212
ProcessStartInfo startInfo = new ProcessStartInfo
167213
{
168214
FileName = "dotnet",
169-
Arguments = string.Join(" ", new[] {
170-
// LUCENENET NOTE: dotnet test doesn't need the --no-build flag since we are passing the DLL path in
171-
"test", testAssemblyPath,
172-
"--framework", GetTargetFramework(),
173-
"--filter", nameof(TestIndexWriterOnJRECrash),
174-
"--logger:\"console;verbosity=normal\"",
175-
"--",
176-
$"RunConfiguration.TargetPlatform={GetTargetPlatform()}",
177-
// LUCENENET NOTE: Since in our CI environment we create a lucene.testsettings.json file
178-
// for all tests, we need to pass some of these settings as test run parameters to override
179-
// for this process. These are read as system properties on the inside of the application.
180-
TestRunParameter("assert", "true"),
181-
TestRunParameter("tests:seed", SeedUtils.FormatSeed(Random.NextInt64())),
182-
TestRunParameter("tests:culture", Thread.CurrentThread.CurrentCulture.Name),
183-
TestRunParameter("tests:crashmode", "true"),
184-
// passing NIGHTLY to this test makes it run for much longer, easier to catch it in the act...
185-
TestRunParameter("tests:nightly", "true"),
186-
TestRunParameter("tempDir", tempDir),
187-
// This port is for passing the process ID of the fork back to the original test so it can kill it.
188-
TestRunParameter("tests:crashtestport", port.ToString(CultureInfo.InvariantCulture)),
189-
}),
215+
Arguments = string.Join(" ", arguments),
190216
WorkingDirectory = theDirectory,
191217
RedirectStandardOutput = true,
192218
RedirectStandardError = true,
@@ -215,12 +241,13 @@ private static string Escape(string value)
215241
private const string BackSlash = "\\";
216242
private const string Space = " ";
217243

218-
private static TextWriter BeginOutput(Process p, out ThreadJob stdOutPumper, out ThreadJob stdErrPumper)
244+
private static TextWriter BeginOutput(Process p, StringBuilder stdErrCapture, out ThreadJob stdOutPumper, out ThreadJob stdErrPumper)
219245
{
220246
// We pump everything to stderr.
221247
TextWriter childOut = Console.Error;
222-
stdOutPumper = ThreadPumper.Start(p.StandardOutput, childOut);
223-
stdErrPumper = ThreadPumper.Start(p.StandardError, childOut);
248+
stdOutPumper = ThreadPumper.Start(p.StandardOutput, childOut, capture: null);
249+
// LUCENENET: Capture the fork's STDERR so it can be surfaced if the fork fails.
250+
stdErrPumper = ThreadPumper.Start(p.StandardError, childOut, capture: stdErrCapture);
224251
if (Verbose) childOut.WriteLine(">>> Begin subprocess output");
225252
return childOut;
226253
}
@@ -243,17 +270,60 @@ private string GetTargetFramework()
243270

244271
private static string GetTargetPlatform()
245272
{
246-
return Environment.Is64BitProcess ? "x64" : "x86";
273+
// LUCENENET: Only x86 needs an explicit target platform. The forked vstest can otherwise
274+
// auto-detect a compatible dotnet host for the current architecture; forcing a platform on
275+
// x64/ARM64 risks the fork failing with "Could not find 'dotnet' host for the '<arch>'
276+
// architecture" (which left the parent blocked forever in WaitForProcessId, e.g. on ARM64
277+
// hosts), so we return null to leave RunConfiguration.TargetPlatform unset for those.
278+
//
279+
// For x86 we must verify the 32-bit SDK is actually installed: since .NET 8, an x86
280+
// `dotnet test` fork will not run unless the 32-bit SDK is installed separately. If it is
281+
// missing, EnsureForkPlatformSupported() makes the test inconclusive rather than letting it
282+
// hang waiting for a fork that can never start.
283+
if (RuntimeInformation.ProcessArchitecture == Architecture.X86)
284+
{
285+
return "x86";
286+
}
287+
288+
return null;
289+
}
290+
291+
// LUCENENET: Verify the runtime/SDK needed to launch the fork on the current platform is present,
292+
// marking the test Inconclusive (rather than hanging) when it is not. Currently this only applies
293+
// to x86, where the 32-bit .NET SDK must be installed separately since .NET 8.
294+
private static void EnsureForkPlatformSupported()
295+
{
296+
if (RuntimeInformation.ProcessArchitecture != Architecture.X86)
297+
{
298+
return;
299+
}
300+
301+
// The x86 SDK installs under "Program Files (x86)\dotnet". On 64-bit Windows this path exists
302+
// only when the 32-bit SDK has been installed in addition to the 64-bit one.
303+
string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
304+
string x86DotnetExe = string.IsNullOrEmpty(programFilesX86)
305+
? null
306+
: Path.Combine(programFilesX86, "dotnet", "dotnet.exe");
307+
308+
if (x86DotnetExe is null || !File.Exists(x86DotnetExe))
309+
{
310+
NUnit.Framework.Assert.Inconclusive(
311+
"The 32-bit .NET SDK is required to run this test on x86 but was not found at '" +
312+
(x86DotnetExe ?? "<unknown>") + "'. Since .NET 8, the 32-bit SDK must be installed " +
313+
"separately from the 64-bit SDK to fork an x86 `dotnet test` process.");
314+
}
247315
}
248316
#endregion
249317

250318
/// <summary>
251319
/// A pipe thread. It'd be nice to reuse guava's implementation for this... </summary>
252320
internal static class ThreadPumper
253321
{
254-
public static ThreadJob Start(TextReader from, TextWriter to)
322+
// LUCENENET: capture is an optional buffer that accumulates the piped text (e.g. STDERR) so it
323+
// can be reported when the fork fails, independent of Verbose.
324+
public static ThreadJob Start(TextReader from, TextWriter to, StringBuilder capture)
255325
{
256-
ThreadJob t = new ThreadPumperAnonymousClass(from, to);
326+
ThreadJob t = new ThreadPumperAnonymousClass(from, to, capture);
257327
t.Start();
258328
return t;
259329
}
@@ -262,11 +332,13 @@ private sealed class ThreadPumperAnonymousClass : ThreadJob
262332
{
263333
private readonly TextReader from;
264334
private readonly TextWriter to;
335+
private readonly StringBuilder capture;
265336

266-
public ThreadPumperAnonymousClass(TextReader from, TextWriter to)
337+
public ThreadPumperAnonymousClass(TextReader from, TextWriter to, StringBuilder capture)
267338
{
268339
this.from = from;
269340
this.to = to;
341+
this.capture = capture;
270342
}
271343

272344
public override void Run()
@@ -277,6 +349,13 @@ public override void Run()
277349
int len;
278350
while ((len = from.Read(buffer, 0, buffer.Length)) > 0)
279351
{
352+
if (capture != null)
353+
{
354+
lock (capture)
355+
{
356+
capture.Append(buffer, 0, len);
357+
}
358+
}
280359
if (Verbose)
281360
{
282361
to.Write(buffer, 0, len);
@@ -315,25 +394,39 @@ public virtual bool CheckIndexes(FileSystemInfo file)
315394
{
316395
if (file is DirectoryInfo directoryInfo)
317396
{
318-
BaseDirectoryWrapper dir = NewFSDirectory(directoryInfo);
319-
dir.CheckIndexOnDispose = false; // don't double-checkindex
320-
if (DirectoryReader.IndexExists(dir))
397+
BaseDirectoryWrapper dir = null;
398+
Exception priorE = null;
399+
try
321400
{
322-
if (Verbose)
401+
dir = NewFSDirectory(directoryInfo);
402+
dir.CheckIndexOnDispose = false; // don't double-checkindex
403+
if (DirectoryReader.IndexExists(dir))
323404
{
324-
Console.Error.WriteLine("Checking index: " + file);
325-
}
326-
// LUCENE-4738: if we crashed while writing first
327-
// commit it's possible index will be corrupt (by
328-
// design we don't try to be smart about this case
329-
// since that too risky):
330-
if (SegmentInfos.GetLastCommitGeneration(dir) > 1)
331-
{
332-
TestUtil.CheckIndex(dir);
405+
if (Verbose)
406+
{
407+
Console.Error.WriteLine("Checking index: " + file);
408+
}
409+
// LUCENE-4738: if we crashed while writing first
410+
// commit it's possible index will be corrupt (by
411+
// design we don't try to be smart about this case
412+
// since that too risky):
413+
if (SegmentInfos.GetLastCommitGeneration(dir) > 1)
414+
{
415+
TestUtil.CheckIndex(dir);
416+
}
417+
return true;
333418
}
334-
return true;
335419
}
336-
dir.Dispose();
420+
catch (Exception e)
421+
{
422+
priorE = e;
423+
throw;
424+
}
425+
finally
426+
{
427+
IOUtils.DisposeWhileHandlingException(priorE, dir);
428+
}
429+
337430
foreach (DirectoryInfo f in directoryInfo.EnumerateDirectories())
338431
{
339432
if (CheckIndexes(f))
@@ -354,15 +447,58 @@ private TcpListener SetupSocketListener()
354447
}
355448

356449
// LUCENENET: Wait for our test to spin up and send its process ID so we can kill it.
357-
private int WaitForProcessId(TcpListener listener)
450+
// Rather than blocking forever in AcceptTcpClient(), poll for either an incoming connection or the
451+
// fork exiting. If the fork exits before connecting (e.g. it failed to build or launch), hard-fail
452+
// with its exit code and captured STDERR so the test reports the cause instead of hanging.
453+
private int WaitForProcessId(TcpListener listener, Process fork, StringBuilder stdErrCapture)
358454
{
359-
using var client = listener.AcceptTcpClient();
455+
IAsyncResult acceptResult = listener.BeginAcceptTcpClient(null, null);
456+
457+
// The fork has to build the test project before it can run, so allow a generous window.
458+
const int TimeoutMs = 120_000;
459+
int waited = 0;
460+
const int PollMs = 100;
461+
while (!acceptResult.AsyncWaitHandle.WaitOne(PollMs))
462+
{
463+
waited += PollMs;
464+
if (fork.HasExited)
465+
{
466+
FailForkStartup(fork, stdErrCapture, "The forked process exited before connecting back.");
467+
}
468+
if (waited >= TimeoutMs)
469+
{
470+
FailForkStartup(fork, stdErrCapture,
471+
$"Timed out after {TimeoutMs / 1000} seconds waiting for the forked process to connect back.");
472+
}
473+
}
474+
475+
using var client = listener.EndAcceptTcpClient(acceptResult);
360476
using var stream = client.GetStream();
361477
// Directly read the process ID as a 32-bit integer
362478
using var reader = new BinaryReader(stream);
363479
return reader.ReadInt32();
364480
}
365481

482+
// LUCENENET: Hard-fail with the fork's exit code and captured STDERR so failures are diagnosable
483+
// instead of presenting as a hang.
484+
private static void FailForkStartup(Process fork, StringBuilder stdErrCapture, string message)
485+
{
486+
string exitCode = fork.HasExited
487+
? fork.ExitCode.ToString(CultureInfo.InvariantCulture)
488+
: "(still running)";
489+
string stdErr;
490+
lock (stdErrCapture)
491+
{
492+
stdErr = stdErrCapture.ToString();
493+
}
494+
if (stdErr.Length == 0)
495+
{
496+
stdErr = "(no STDERR captured)";
497+
}
498+
499+
Assert.Fail($"{message} Fork exit code: {exitCode}.{Environment.NewLine}Fork STDERR:{Environment.NewLine}{stdErr}");
500+
}
501+
366502
private void SendProcessId(int processId, int port)
367503
{
368504
using var client = new TcpClient("127.0.0.1", port);

src/Lucene.Net.Tests/Store/TestDirectory.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,41 @@ public virtual void TestFsyncDoesntCreateNewFiles()
438438
Assert.AreEqual(0, fsdir.ListAll().Length);
439439
}
440440

441+
[Test]
442+
[LuceneNetSpecific]
443+
public virtual void TestFSIndexOutputFlushesBeforeMarkedStale()
444+
{
445+
DirectoryInfo path = CreateTempDir("flushBeforeMarkedStale");
446+
using var dir = new FlushObservingSimpleFSDirectory(path);
447+
448+
byte[] bytes = new byte[1];
449+
Random.NextBytes(bytes);
450+
451+
using (IndexOutput output = dir.CreateOutput("afile", NewIOContext(Random)))
452+
{
453+
output.WriteBytes(bytes, bytes.Length);
454+
}
455+
456+
Assert.AreEqual(bytes.Length, dir.LengthObservedOnIndexOutputClosed,
457+
"FSDirectory must flush FileStream's managed buffer before marking a file stale for fsync.");
458+
}
459+
460+
private sealed class FlushObservingSimpleFSDirectory : SimpleFSDirectory
461+
{
462+
public FlushObservingSimpleFSDirectory(DirectoryInfo path)
463+
: base(path)
464+
{
465+
}
466+
467+
public long LengthObservedOnIndexOutputClosed { get; private set; } = -1;
468+
469+
protected override void OnIndexOutputClosed(FSIndexOutput io)
470+
{
471+
LengthObservedOnIndexOutputClosed = new FileInfo(Path.Combine(m_directory.FullName, io.name)).Length;
472+
base.OnIndexOutputClosed(io);
473+
}
474+
}
475+
441476
[Test]
442477
[Slow]
443478
[LuceneNetSpecific]

0 commit comments

Comments
 (0)