Skip to content

Commit 5d257ae

Browse files
paulirwinclaude
andcommitted
Address review feedback and harden JRE-crash fork harness, #1292
FSDirectory.FSIndexOutput.Dispose: - Collapse the nested try blocks to two levels (per review). - Document why upstream Java has no flush race (RandomAccessFile.write is unbuffered, unlike FileStream's managed buffer). - Make Dispose run-once via an atomic Interlocked guard so two racing threads can't both run the dispose logic (per review). TestIndexWriterOnJRECrash fork harness: - Only pass RunConfiguration.TargetPlatform when running as x86; let vstest auto-detect a compatible host on x64/ARM64. - Add EnsureForkPlatformSupported(): mark the test Inconclusive when the 32-bit .NET SDK is not installed on x86 (required separately since .NET 8) instead of hanging. - WaitForProcessId no longer blocks forever: poll for connect-back or fork exit, and hard-fail with the fork's exit code and captured STDERR on startup failure or timeout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1effea1 commit 5d257ae

2 files changed

Lines changed: 177 additions & 68 deletions

File tree

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

Lines changed: 153 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
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;
@@ -12,6 +13,7 @@
1213
using System.Net.Sockets;
1314
using System.Reflection;
1415
using System.Runtime.InteropServices;
16+
using System.Text;
1517
using System.Threading;
1618
using BaseDirectoryWrapper = Lucene.Net.Store.BaseDirectoryWrapper;
1719
using Assert = Lucene.Net.TestFramework.Assert;
@@ -53,6 +55,10 @@ public override void TestNRTThreads_Mem()
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,29 +270,60 @@ private string GetTargetFramework()
243270

244271
private static string GetTargetPlatform()
245272
{
246-
// LUCENENET: Match the host process architecture so the forked vstest can
247-
// locate a compatible dotnet host. On Apple Silicon and other ARM64 hosts,
248-
// returning "x64" here causes the fork to fail with "Could not find 'dotnet'
249-
// host for the 'X64' architecture", which leaves the parent blocked forever
250-
// in WaitForProcessId.
251-
return RuntimeInformation.ProcessArchitecture switch
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)
252284
{
253-
Architecture.X86 => "x86",
254-
Architecture.X64 => "x64",
255-
Architecture.Arm => "ARM",
256-
Architecture.Arm64 => "ARM64",
257-
_ => Environment.Is64BitProcess ? "x64" : "x86",
258-
};
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+
}
259315
}
260316
#endregion
261317

262318
/// <summary>
263319
/// A pipe thread. It'd be nice to reuse guava's implementation for this... </summary>
264320
internal static class ThreadPumper
265321
{
266-
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)
267325
{
268-
ThreadJob t = new ThreadPumperAnonymousClass(from, to);
326+
ThreadJob t = new ThreadPumperAnonymousClass(from, to, capture);
269327
t.Start();
270328
return t;
271329
}
@@ -274,11 +332,13 @@ private sealed class ThreadPumperAnonymousClass : ThreadJob
274332
{
275333
private readonly TextReader from;
276334
private readonly TextWriter to;
335+
private readonly StringBuilder capture;
277336

278-
public ThreadPumperAnonymousClass(TextReader from, TextWriter to)
337+
public ThreadPumperAnonymousClass(TextReader from, TextWriter to, StringBuilder capture)
279338
{
280339
this.from = from;
281340
this.to = to;
341+
this.capture = capture;
282342
}
283343

284344
public override void Run()
@@ -289,6 +349,13 @@ public override void Run()
289349
int len;
290350
while ((len = from.Read(buffer, 0, buffer.Length)) > 0)
291351
{
352+
if (capture != null)
353+
{
354+
lock (capture)
355+
{
356+
capture.Append(buffer, 0, len);
357+
}
358+
}
292359
if (Verbose)
293360
{
294361
to.Write(buffer, 0, len);
@@ -380,15 +447,58 @@ private TcpListener SetupSocketListener()
380447
}
381448

382449
// LUCENENET: Wait for our test to spin up and send its process ID so we can kill it.
383-
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)
384454
{
385-
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);
386476
using var stream = client.GetStream();
387477
// Directly read the process ID as a 32-bit integer
388478
using var reader = new BinaryReader(stream);
389479
return reader.ReadInt32();
390480
}
391481

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+
392502
private void SendProcessId(int processId, int port)
393503
{
394504
using var client = new TcpClient("127.0.0.1", port);

0 commit comments

Comments
 (0)