44using NUnit . Framework ;
55using RandomizedTesting . Generators ;
66using System ;
7+ using System . Collections . Generic ;
78using System . Diagnostics ;
89using System . Globalization ;
910using System . IO ;
1011using System . Linq ;
1112using System . Net ;
1213using System . Net . Sockets ;
1314using System . Reflection ;
15+ using System . Runtime . InteropServices ;
16+ using System . Text ;
1417using System . Threading ;
1518using BaseDirectoryWrapper = Lucene . Net . Store . BaseDirectoryWrapper ;
1619using 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 ) ;
0 commit comments