2828import java .util .concurrent .ExecutorService ;
2929import java .util .concurrent .Executors ;
3030import java .util .concurrent .Future ;
31+ import java .util .concurrent .TimeUnit ;
3132
3233import org .junit .jupiter .api .Test ;
3334import org .junit .jupiter .api .io .TempDir ;
@@ -77,6 +78,13 @@ public class SharedServerModeTest {
7778 "<fakeload millis=\" 60000\" cpu=\" 1\" mb=\" 10\" />" +
7879 "</mock>" ;
7980
81+ private static final String MOCK_INFLIGHT = "<?xml version=\" 1.0\" encoding=\" UTF-8\" ?>" +
82+ "<mock>" +
83+ "<metadata action=\" add\" name=\" dc:creator\" >In-flight Author</metadata>" +
84+ "<write element=\" p\" >In-flight content</write>" +
85+ "<fakeload millis=\" 3000\" cpu=\" 1\" mb=\" 10\" />" +
86+ "</mock>" ;
87+
8088 @ Test
8189 public void testBasicSharedMode (@ TempDir Path tmp ) throws Exception {
8290 Path inputDir = setupInputDir (tmp );
@@ -191,7 +199,7 @@ public void testMultipleSequentialRequests(@TempDir Path tmp) throws Exception {
191199 @ Test
192200 public void testGracefulShutdown (@ TempDir Path tmp ) throws Exception {
193201 Path inputDir = setupInputDir (tmp );
194- Files .writeString (inputDir .resolve ("test.xml" ), MOCK_SLOW , StandardCharsets .UTF_8 );
202+ Files .writeString (inputDir .resolve ("test.xml" ), MOCK_OK , StandardCharsets .UTF_8 );
195203
196204 Path tikaConfigPath = PluginsTestHelper .getFileSystemFetcherConfig (
197205 "tika-config-shared-server.json" , tmp , inputDir , tmp .resolve ("output" ), false );
@@ -216,6 +224,78 @@ public void testGracefulShutdown(@TempDir Path tmp) throws Exception {
216224 }
217225 }
218226
227+ /**
228+ * Verifies that close() unblocks an in-flight parse and returns promptly when
229+ * the parser is shut down at a randomized point in the parse lifecycle.
230+ * <p>
231+ * After a warmup parse to ensure the shared server is fully started, this picks
232+ * a random sleep in [0, 4000]ms before calling close(). Given the inflight
233+ * fakeload runs ~3000ms, the chosen delay can land in init, mid-parse, near the
234+ * tail of parse, or post-completion. The seed is logged and included in every
235+ * assertion message so a CI failure can be reproduced via
236+ * {@code -DcloseInFlightSeed=<seed>}.
237+ */
238+ @ Test
239+ public void testCloseAtRandomPhase (@ TempDir Path tmp ) throws Exception {
240+ long seed = Long .getLong ("closeInFlightSeed" , System .nanoTime ());
241+ java .util .Random rng = new java .util .Random (seed );
242+ long sleepBeforeCloseMs = rng .nextInt (4001 ); // [0, 4000]
243+ String repro = "[seed=" + seed + ", sleep=" + sleepBeforeCloseMs + "ms]" ;
244+ System .out .println ("testCloseAtRandomPhase " + repro );
245+
246+ Path inputDir = setupInputDir (tmp );
247+ Files .writeString (inputDir .resolve ("warmup.xml" ), MOCK_OK , StandardCharsets .UTF_8 );
248+ Files .writeString (inputDir .resolve ("inflight.xml" ), MOCK_INFLIGHT , StandardCharsets .UTF_8 );
249+
250+ Path tikaConfigPath = PluginsTestHelper .getFileSystemFetcherConfig (
251+ "tika-config-shared-server.json" , tmp , inputDir , tmp .resolve ("output" ), false );
252+ TikaJsonConfig tikaJsonConfig = TikaJsonConfig .load (tikaConfigPath );
253+ PipesConfig pipesConfig = PipesConfig .load (tikaJsonConfig );
254+
255+ PipesParser pipesParser = PipesParser .load (tikaJsonConfig , pipesConfig , tikaConfigPath );
256+ ExecutorService executor = Executors .newSingleThreadExecutor ();
257+ try {
258+ // Warmup so the shared server is fully started; without this, short
259+ // sleeps would always land in init rather than reaching mid-parse.
260+ PipesResult warmup = pipesParser .parse (new FetchEmitTuple (
261+ "warmup.xml" ,
262+ new FetchKey (FETCHER_NAME , "warmup.xml" ),
263+ new EmitKey (EMITTER_NAME , "" ),
264+ new Metadata (),
265+ new ParseContext (),
266+ FetchEmitTuple .ON_PARSE_EXCEPTION .SKIP ));
267+ assertTrue (warmup .isSuccess (), "Warmup parse must succeed " + repro );
268+
269+ Future <PipesResult > future = executor .submit (() -> pipesParser .parse (new FetchEmitTuple (
270+ "inflight.xml" ,
271+ new FetchKey (FETCHER_NAME , "inflight.xml" ),
272+ new EmitKey (EMITTER_NAME , "" ),
273+ new Metadata (),
274+ new ParseContext (),
275+ FetchEmitTuple .ON_PARSE_EXCEPTION .SKIP )));
276+
277+ if (sleepBeforeCloseMs > 0 ) {
278+ Thread .sleep (sleepBeforeCloseMs );
279+ }
280+
281+ long closeStart = System .currentTimeMillis ();
282+ pipesParser .close ();
283+ long closeDuration = System .currentTimeMillis () - closeStart ;
284+
285+ assertTrue (closeDuration < 15000 ,
286+ "close() should return promptly even with in-flight work; took " +
287+ closeDuration + "ms " + repro );
288+
289+ // The in-flight request must return - not deadlock waiting on a closed socket
290+ PipesResult result = future .get (15 , TimeUnit .SECONDS );
291+ assertNotNull (result , "In-flight parse should return a result, not hang " + repro );
292+ // Result may be SUCCESS (parse finished before close) or a crash/init-failure
293+ // status (close tore down the socket mid-flight) - both are acceptable.
294+ } finally {
295+ executor .shutdownNow ();
296+ }
297+ }
298+
219299 @ Test
220300 public void testPerClientModeStillWorks (@ TempDir Path tmp ) throws Exception {
221301 // Verify default per-client mode still works
0 commit comments