This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 339693b6cf TIKA-5040: kill descendant processes with the forked server 
and in ProcessUtils.execute (#3532)
339693b6cf is described below

commit 339693b6cf22ddcc868294d0f597b946c967b67c
Author: Davide Polato <[email protected]>
AuthorDate: Thu Nov 5 15:26:06 2026 +0100

    TIKA-5040: kill descendant processes with the forked server and in 
ProcessUtils.execute (#3532)
    
    * TIKA-5040: kill descendant processes with the forked server and in 
ProcessUtils.execute
    
    Signed-off-by: Davide Polato <[email protected]>
---
 CHANGES.txt                                        |   5 +
 .../java/org/apache/tika/utils/ProcessUtils.java   |  43 +++++-
 .../org/apache/tika/utils/ProcessTestHelper.java   |  70 +++++++++
 .../org/apache/tika/utils/ProcessUtilsTest.java    |  59 ++++++++
 .../tika/pipes/core/PerClientServerManager.java    |   3 +-
 .../tika/pipes/core/SharedServerManager.java       |   3 +-
 .../pipes/core/ExternalProcessCleanupTest.java     | 157 +++++++++++++++++++++
 .../apache/tika/pipes/core/PluginsTestHelper.java  |  11 +-
 .../configs/tika-config-external-sleep.json        |  54 +++++++
 9 files changed, 396 insertions(+), 9 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 53a1b25680..dc08815664 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -15,6 +15,11 @@ Release 4.3.0 - ???
 
   SERVER, PIPES AND OPERATIONS
 
+   * When pipes kills a forked server, it now kills the server's descendant 
processes too,
+     such as ExternalParser commands and their children; they kept running. On 
a timeout,
+     an interrupt or JVM shutdown, ProcessUtils.execute also kills the 
descendants of the
+     process it started (TIKA-5040).
+
    * Fetchers and plugins built on HttpClientFactory (HTTP, Atlassian JWT, ES, 
OpenSearch,
      Solr) no longer send Authorization or Cookie headers, or answer a 401 
with the
      configured password, after a redirect to another host, port or scheme. 
That includes
diff --git a/tika-core/src/main/java/org/apache/tika/utils/ProcessUtils.java 
b/tika-core/src/main/java/org/apache/tika/utils/ProcessUtils.java
index 244742cb74..36da26225e 100644
--- a/tika-core/src/main/java/org/apache/tika/utils/ProcessUtils.java
+++ b/tika-core/src/main/java/org/apache/tika/utils/ProcessUtils.java
@@ -52,10 +52,41 @@ public class ProcessUtils {
 
     static {
         Runtime.getRuntime().addShutdownHook(new Thread(() -> {
-            PROCESS_MAP.forEachValue(1, Process::destroyForcibly);
+            PROCESS_MAP.forEachValue(1, 
ProcessUtils::destroyForciblyWithDescendants);
         }));
     }
 
+    /**
+     * Forcibly destroys {@code process} and the descendants it has at the 
time of the call.
+     *
+     * @param process the process to destroy
+     */
+    public static void destroyForciblyWithDescendants(Process process) {
+        List<ProcessHandle> descendants = listDescendants(process);
+        process.destroyForcibly();
+        descendants.forEach(ProcessHandle::destroyForcibly);
+    }
+
+    private static List<ProcessHandle> listDescendants(Process process) {
+        ProcessHandle handle;
+        try {
+            handle = process.toHandle();
+        } catch (UnsupportedOperationException e) {
+            LOG.debug("cannot list the descendants of {}; destroying only the 
process", process, e);
+            return List.of();
+        }
+        if (!handle.isAlive()) {
+            return List.of();
+        }
+        // the JDK signals a handle only if its pid still has the handle's 
start time, and it
+        // skips that check when the start time is unknown
+        List<ProcessHandle> descendants = handle.descendants()
+                .filter(h -> h.info().startInstant().isPresent())
+                .toList();
+        // descendants() finds the parent by pid; isAlive() checks its start 
time
+        return handle.isAlive() ? descendants : List.of();
+    }
+
     private static String register(Process p) {
         String id = UUID.randomUUID().toString();
         PROCESS_MAP.put(id, p);
@@ -214,7 +245,7 @@ public class ProcessUtils {
                     outThread.join(1000);
                     errThread.join(1000);
                 } else {
-                    p.destroyForcibly();
+                    destroyForciblyWithDescendants(p);
                     outThread.join(1000);
                     errThread.join(1000);
                     boolean completed = p.waitFor(500, TimeUnit.MILLISECONDS);
@@ -249,7 +280,7 @@ public class ProcessUtils {
             return result;
         } finally {
             if (p != null) {
-                p.destroyForcibly();
+                destroyForciblyWithDescendants(p);
             }
             if (id != null) {
                 release(id);
@@ -332,7 +363,7 @@ public class ProcessUtils {
                     exitValue = p.exitValue();
                     errThread.join(1000);
                 } else {
-                    p.destroyForcibly();
+                    destroyForciblyWithDescendants(p);
                     errThread.join(1000);
                 }
             } catch (InterruptedException e) {
@@ -354,7 +385,7 @@ public class ProcessUtils {
             return result;
         } finally {
             if (p != null) {
-                p.destroyForcibly();
+                destroyForciblyWithDescendants(p);
             }
             if (id != null) {
                 release(id);
@@ -455,7 +486,7 @@ public class ProcessUtils {
             throw err;
         } finally {
             if (process != null) {
-                process.destroyForcibly();
+                destroyForciblyWithDescendants(process);
             }
         }
     }
diff --git 
a/tika-core/src/test/java/org/apache/tika/utils/ProcessTestHelper.java 
b/tika-core/src/test/java/org/apache/tika/utils/ProcessTestHelper.java
new file mode 100644
index 0000000000..491d37458d
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/utils/ProcessTestHelper.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.utils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Finds the processes a test started by a marker string that appears in their 
command lines.
+ */
+public final class ProcessTestHelper {
+
+    private static final long POLL_MILLIS = 20;
+
+    private ProcessTestHelper() {
+    }
+
+    /** Live processes whose command line contains {@code marker}; a zombie 
has no command line. */
+    public static List<ProcessHandle> processesWith(String marker) {
+        List<ProcessHandle> found = new ArrayList<>();
+        ProcessHandle.allProcesses().forEach(ph -> {
+            if (ph.info().commandLine().orElse("").contains(marker) && 
ph.isAlive()) {
+                found.add(ph);
+            }
+        });
+        return found;
+    }
+
+    /**
+     * Polls {@link #processesWith(String)} until it returns {@code count} 
processes or
+     * {@code timeoutMillis} passes.
+     *
+     * @return the processes found by the last poll
+     */
+    public static List<ProcessHandle> awaitProcessesWith(String marker, int 
count, long timeoutMillis)
+            throws InterruptedException {
+        long deadline = System.nanoTime() + 
TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+        List<ProcessHandle> found = processesWith(marker);
+        while (found.size() != count && System.nanoTime() < deadline) {
+            Thread.sleep(POLL_MILLIS);
+            found = processesWith(marker);
+        }
+        return found;
+    }
+
+    /** One {@code pid:ppid=N:commandLine} entry per process, for assertion 
messages. */
+    public static List<String> describe(List<ProcessHandle> processes) {
+        List<String> described = new ArrayList<>();
+        for (ProcessHandle ph : processes) {
+            described.add(ph.pid() + ":ppid=" + 
ph.parent().map(ProcessHandle::pid).orElse(-1L)
+                    + ":" + ph.info().commandLine().orElse("?"));
+        }
+        return described;
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/utils/ProcessUtilsTest.java 
b/tika-core/src/test/java/org/apache/tika/utils/ProcessUtilsTest.java
index 9b920a4aa7..6da933256a 100644
--- a/tika-core/src/test/java/org/apache/tika/utils/ProcessUtilsTest.java
+++ b/tika-core/src/test/java/org/apache/tika/utils/ProcessUtilsTest.java
@@ -16,6 +16,9 @@
  */
 package org.apache.tika.utils;
 
+import static org.apache.tika.utils.ProcessTestHelper.awaitProcessesWith;
+import static org.apache.tika.utils.ProcessTestHelper.describe;
+import static org.apache.tika.utils.ProcessTestHelper.processesWith;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -24,9 +27,16 @@ import static org.junit.jupiter.api.Assumptions.assumeFalse;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.ThreadLocalRandom;
 
 import org.jspecify.annotations.NonNull;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import org.apache.tika.config.ParseTimeout;
 import org.apache.tika.config.TimeoutLimits;
@@ -169,6 +179,55 @@ public class ProcessUtilsTest {
         assertSubprocessDidNotStart(result, elapsed);
     }
 
+    /**
+     * When execute() stops waiting (timeout or interrupt), {@code sh} and its 
child {@code sleep}
+     * end, and a process started outside execute() is left alone.
+     */
+    @ParameterizedTest(name = "redirect={0} interrupt={1}")
+    @CsvSource({"false, false", "true, false", "false, true", "true, true"})
+    @EnabledOnOs(OS.LINUX)
+    public void testStoppedWaitDestroysTheProcessesItStarted(boolean redirect, 
boolean interrupt)
+            throws Exception {
+        // doubles as the sleep duration, so both command lines contain it; 
random per run
+        int suffix = ThreadLocalRandom.current().nextInt(1_000_000);
+        String marker = String.format(Locale.ROOT, "301.%06d", suffix);
+        ProcessBuilder pb = new ProcessBuilder("sh", "-c", "sleep " + marker + 
"; true");
+        long timeoutMillis = interrupt ? 60_000 : 2000;
+        Process bystander = new ProcessBuilder("sleep", 
String.format(Locale.ROOT, "302.%06d", suffix))
+                .start();
+        Path stdoutRedirect = redirect ? createTmpRedirect() : null;
+        Thread runner = new Thread(() -> {
+            try {
+                if (redirect) {
+                    ProcessUtils.execute(pb, null, timeoutMillis, 
stdoutRedirect, 1000);
+                } else {
+                    ProcessUtils.execute(pb, null, timeoutMillis, 1000, 1000);
+                }
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        });
+        try {
+            runner.start();
+            List<ProcessHandle> running = awaitProcessesWith(marker, 2, 
10_000);
+            assertEquals(2, running.size(), "sh and its sleep should be 
running: " + describe(running));
+            if (interrupt) {
+                runner.interrupt();
+            }
+            runner.join(10_000);
+            assertFalse(runner.isAlive(), "execute() should have returned");
+
+            List<ProcessHandle> survivors = awaitProcessesWith(marker, 0, 
2_000);
+            assertTrue(survivors.isEmpty(), "redirect=" + redirect + " 
interrupt=" + interrupt
+                    + ": processes outlived execute(): " + 
describe(survivors));
+            assertTrue(bystander.isAlive(), "a process execute() did not start 
was destroyed");
+        } finally {
+            runner.interrupt();
+            processesWith(marker).forEach(ProcessHandle::destroyForcibly);
+            bystander.destroyForcibly();
+        }
+    }
+
     private static @NonNull Path createTmpRedirect() throws IOException {
         Path tmpDir = Path.of(System.getProperty("java.io.tmpdir"));
         Files.createDirectories(tmpDir);
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
index f6a7b17a45..1bec6bfd70 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
@@ -606,7 +606,8 @@ public class PerClientServerManager implements 
ServerManager {
 
     private void destroyProcess() throws InterruptedException {
         if (process != null) {
-            process.destroyForcibly();
+            // a forced kill skips the server's shutdown hooks, which end its 
external processes
+            ProcessUtils.destroyForciblyWithDescendants(process);
             try {
                 process.waitFor(WAIT_ON_DESTROY_MS, TimeUnit.MILLISECONDS);
                 if (process.isAlive()) {
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
index f39383cb69..76f8ee7a97 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
@@ -462,7 +462,8 @@ public class SharedServerManager implements ServerManager {
 
     private void destroyProcessUnsafe() throws InterruptedException {
         if (process != null) {
-            process.destroyForcibly();
+            // a forced kill skips the server's shutdown hooks, which end its 
external processes
+            ProcessUtils.destroyForciblyWithDescendants(process);
             try {
                 process.waitFor(WAIT_ON_DESTROY_MS, TimeUnit.MILLISECONDS);
                 if (process.isAlive()) {
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/ExternalProcessCleanupTest.java
 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/ExternalProcessCleanupTest.java
new file mode 100644
index 0000000000..98e593df39
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/ExternalProcessCleanupTest.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.core;
+
+import static org.apache.tika.utils.ProcessTestHelper.awaitProcessesWith;
+import static org.apache.tika.utils.ProcessTestHelper.describe;
+import static org.apache.tika.utils.ProcessTestHelper.processesWith;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.loader.TikaJsonConfig;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.pipes.api.emitter.EmitKey;
+import org.apache.tika.pipes.api.fetcher.FetchKey;
+
+/**
+ * A forked server's external processes and their children end with the 
server. The parser runs
+ * {@code sh -c "sleep N; true"}, so {@code sleep} is a child of the process 
Tika started.
+ */
+@EnabledOnOs(OS.LINUX)
+public class ExternalProcessCleanupTest {
+
+    private static final String DOC = "doc.txt";
+
+    enum ServerMode {
+        PER_CLIENT,
+        SHARED
+    }
+
+    enum ServerEnd {
+        /** PipesParser.close() mid-parse: the parent destroys the server. */
+        PARENT_KILLS_SERVER,
+        /** The calling thread is interrupted; the per-client server loses its 
connection and exits. */
+        SERVER_EXITS_ITSELF
+    }
+
+    @Test
+    public void perClientServerKilledByParent(@TempDir Path tmp) throws 
Exception {
+        run(tmp, ServerMode.PER_CLIENT, ServerEnd.PARENT_KILLS_SERVER);
+    }
+
+    @Test
+    public void perClientServerExitsItself(@TempDir Path tmp) throws Exception 
{
+        run(tmp, ServerMode.PER_CLIENT, ServerEnd.SERVER_EXITS_ITSELF);
+    }
+
+    @Test
+    public void sharedServerKilledByParent(@TempDir Path tmp) throws Exception 
{
+        run(tmp, ServerMode.SHARED, ServerEnd.PARENT_KILLS_SERVER);
+    }
+
+    private void run(Path tmp, ServerMode mode, ServerEnd end) throws 
Exception {
+        // doubles as the sleep duration, so both command lines contain it; 
random per run
+        String marker = String.format(Locale.ROOT, "301.%06d",
+                ThreadLocalRandom.current().nextInt(1_000_000));
+        Path inputDir = tmp.resolve("input");
+        Files.createDirectories(inputDir);
+        Files.writeString(inputDir.resolve(DOC), "hello", 
StandardCharsets.UTF_8);
+        Path tikaConfigPath = PluginsTestHelper.getFileSystemFetcherConfig(
+                "tika-config-external-sleep.json", tmp, inputDir, 
tmp.resolve("output"), false,
+                Map.of("USE_SHARED_SERVER", mode == ServerMode.SHARED,
+                        "COMMAND_LINE", List.of("sh", "-c", "sleep " + marker 
+ "; true")));
+        TikaJsonConfig tikaJsonConfig = TikaJsonConfig.load(tikaConfigPath);
+        PipesParser pipesParser = PipesParser.load(tikaJsonConfig, 
PipesConfig.load(tikaJsonConfig));
+
+        AtomicReference<String> parseOutcome = new AtomicReference<>();
+        Thread parseThread = new Thread(() -> {
+            try {
+                PipesResult r = pipesParser.parse(new FetchEmitTuple(DOC, new 
FetchKey("fsf", DOC),
+                        new EmitKey(), new Metadata(), new ParseContext(),
+                        FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+                parseOutcome.set(r.status() + " " + r.message());
+            } catch (Throwable t) {
+                parseOutcome.set(t.getClass().getSimpleName());
+            }
+        }, "external-process-cleanup-parse");
+        try {
+            parseThread.start();
+            List<ProcessHandle> running = awaitProcessesWith(marker, 2, 
30_000);
+            assertEquals(2, running.size(), "sh and its sleep should be 
running: " + describe(running)
+                    + " parseOutcome=" + parseOutcome.get());
+            ProcessHandle server = serverOf(running);
+
+            if (end == ServerEnd.PARENT_KILLS_SERVER) {
+                pipesParser.close();
+            } else {
+                parseThread.interrupt();
+            }
+            boolean serverExited = awaitExit(server, 30_000);
+            List<ProcessHandle> survivors = awaitProcessesWith(marker, 0, 
3_000);
+            String report = String.format(Locale.ROOT,
+                    "mode=%s end=%s server=%d serverExited=%s parseOutcome=%s 
before=%s survivors=%s",
+                    mode, end, server.pid(), serverExited, parseOutcome.get(), 
describe(running),
+                    describe(survivors));
+            assertTrue(serverExited, "the server did not exit: " + report);
+            assertTrue(survivors.isEmpty(), "processes outlived the server: " 
+ report);
+        } finally {
+            parseThread.interrupt();
+            pipesParser.close();
+            processesWith(marker).forEach(ProcessHandle::destroyForcibly);
+        }
+    }
+
+    /** The forked server: the parent of the process it started, and a child 
of this JVM. */
+    private static ProcessHandle serverOf(List<ProcessHandle> running) {
+        long thisJvm = ProcessHandle.current().pid();
+        for (ProcessHandle ph : running) {
+            ProcessHandle parent = ph.parent().orElse(null);
+            if (parent != null && 
parent.parent().map(ProcessHandle::pid).orElse(-1L) == thisJvm) {
+                return parent;
+            }
+        }
+        throw new IllegalStateException("no process under a server of this 
JVM: " + describe(running));
+    }
+
+    private static boolean awaitExit(ProcessHandle ph, long millis) throws 
Exception {
+        try {
+            ph.onExit().get(millis, TimeUnit.MILLISECONDS);
+            return true;
+        } catch (TimeoutException e) {
+            return false;
+        }
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PluginsTestHelper.java
 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PluginsTestHelper.java
index 1add238190..cc1928e7ff 100644
--- 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PluginsTestHelper.java
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PluginsTestHelper.java
@@ -50,9 +50,18 @@ public class PluginsTestHelper {
     }
 
     public static Path getFileSystemFetcherConfig(String templateName, Path 
configBase, Path fetcherBase, Path emitterBase, boolean 
emitIntermediateResults) throws Exception {
+        return getFileSystemFetcherConfig(templateName, configBase, 
fetcherBase, emitterBase, emitIntermediateResults, Map.of());
+    }
+
+    /**
+     * @param extraReplacements template placeholders beyond the standard 
ones, mapped to their
+     *                          values (see {@link JsonConfigHelper} for the 
supported value types)
+     */
+    public static Path getFileSystemFetcherConfig(String templateName, Path 
configBase, Path fetcherBase, Path emitterBase,
+                                                  boolean 
emitIntermediateResults, Map<String, Object> extraReplacements) throws 
Exception {
         Path pipesConfig = configBase.resolve("pipes-config.json");
 
-        Map<String, Object> replacements = new HashMap<>();
+        Map<String, Object> replacements = new HashMap<>(extraReplacements);
         replacements.put("FETCHER_BASE_PATH", fetcherBase);
 
         if (emitterBase != null) {
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-external-sleep.json
 
b/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-external-sleep.json
new file mode 100644
index 0000000000..2b1e330ca9
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-external-sleep.json
@@ -0,0 +1,54 @@
+{
+  "content-handler-factory": {
+    "basic-content-handler-factory": {
+      "type": "TEXT",
+      "writeLimit": -1,
+      "throwOnWriteLimitReached": true
+    }
+  },
+  "fetchers": {
+    "fsf": {
+      "file-system-fetcher": {
+        "basePath": "FETCHER_BASE_PATH",
+        "extractFileSystemMetadata": false
+      }
+    }
+  },
+  "emitters": {
+    "fse": {
+      "file-system-emitter": {
+        "basePath": "EMITTER_BASE_PATH",
+        "fileExtension": "json",
+        "onExists": "EXCEPTION"
+      }
+    }
+  },
+  "pipes": {
+    "parseMode": "RMETA",
+    "onParseException": "EMIT",
+    "numClients": 1,
+    "useSharedServer": "USE_SHARED_SERVER",
+    "emitIntermediateResults": "EMIT_INTERMEDIATE_RESULTS",
+    "heartbeatIntervalMillis": 200,
+    "forkedJvmArgs": ["-Xmx256m", "-XX:TieredStopAtLevel=1"],
+    "emitStrategy": {
+      "type": "DYNAMIC",
+      "thresholdBytes": 1000000
+    }
+  },
+  "parsers": [
+    {
+      "external-parser": {
+        "_mime-include": ["text/plain"],
+        "commandLine": "COMMAND_LINE",
+        "timeoutMillis": 300000
+      }
+    }
+  ],
+  "parse-context": {
+    "timeout-limits": {
+      "progressTimeoutMillis": 30000
+    }
+  },
+  "plugin-roots": "PLUGINS_PATHS"
+}

Reply via email to