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 b5c52d4426 TIKA-4877: improve tmp file handling (#3125)
b5c52d4426 is described below

commit b5c52d4426705e275fc6b062544ec2575bd9e087
Author: Tim Allison <[email protected]>
AuthorDate: Thu Sep 3 17:20:14 2026 -0400

    TIKA-4877: improve tmp file handling (#3125)
---
 CHANGES.txt                                        |  11 +
 .../migration-to-4x/migrating-tika-server-4x.adoc  |   2 +-
 docs/modules/ROOT/pages/pipes/configuration.adoc   |  35 ++-
 .../microsoft/rtf/jflex/RTFEmbeddedHandler.java    |  12 +-
 .../microsoft/rtf/jflex/RTFHtmlDecapsulator.java   | 243 +++++++++++----------
 .../rtf/jflex/RTFHtmlDecapsulatorTest.java         |  30 +++
 .../tika/renderer/pdf/pdfbox/PDFBoxRenderer.java   |   8 +
 .../tika/renderer/pdf/poppler/PopplerRenderer.java |  14 +-
 .../renderer/pdf/pdfbox/PDFBoxRendererTest.java    |  36 +++
 .../tika/pipes/core/PerClientServerManager.java    |   7 +-
 .../org/apache/tika/pipes/core/PipesConfig.java    |  41 ++--
 .../tika/pipes/core/SharedServerManager.java       |   7 +-
 .../apache/tika/pipes/core/server/PipesServer.java |  35 ++-
 .../pipes/core/server/PipesServerTempDirTest.java  |  49 +++++
 .../org/apache/tika/config/loader/TikaLoader.java  |  30 +++
 .../apache/tika/config/loader/TikaLoaderTest.java  |  22 ++
 .../language/translate/impl/MarianTranslator.java  |  33 ++-
 17 files changed, 452 insertions(+), 163 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 2e739e0f21..fe2cc602cb 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,16 @@
 Release 4.1.0 - unreleased
 
+   * Temp files follow -Djava.io.tmpdir on the parent JVM (Tika, its
+     libraries, and forks all honor it); TikaLoader fails at config load
+     if it is unusable. pipes.tempDirectory is deprecated for removal in
+     5.0: it only covered the forks. Do not use tmpfs: spool size is
+     bounded by input, and an orphaned fork dir pins RAM (TIKA-4877).
+
+   * A fork whose parent dies deletes its own temp dir; the parent
+     surfaces a fork's hs_err log before every delete. Failure-path temp
+     file leaks fixed in PDFBoxRenderer, PopplerRenderer, truncated RTF,
+     and MarianTranslator (TIKA-4877).
+     
    * tika-eval Profile/Compare speedups: single-pass URL/mail stripping
      replaces the bounded regexes in langdetect preprocessing (same output,
      17-290x faster on web text), the default H2 db URL drops MVStore chunk
diff --git 
a/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc 
b/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
index e1859febae..ac4afd013f 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
@@ -576,7 +576,7 @@ xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap Sizing].
 === Performance Optimizations
 
 * TCP_NODELAY enabled for reduced latency on small requests
-* Configurable temp directory for RAM disk optimization (`pipes.tempDirectory`)
+* Temporary files go to `-Djava.io.tmpdir`; forks get private subdirectories 
under it (see xref:pipes/configuration.adoc#_where_temporary_files_go[Where 
temporary files go]; `pipes.tempDirectory` is deprecated, and do not use a RAM 
disk)
 
 == Advanced: Shared Server Mode
 
diff --git a/docs/modules/ROOT/pages/pipes/configuration.adoc 
b/docs/modules/ROOT/pages/pipes/configuration.adoc
index fb1c205117..d8691bf7a8 100644
--- a/docs/modules/ROOT/pages/pipes/configuration.adoc
+++ b/docs/modules/ROOT/pages/pipes/configuration.adoc
@@ -57,14 +57,37 @@ how many forked JVMs to run, timeouts, memory management, 
and parse behavior.
 
 |`tempDirectory`
 |_system default_
-|Directory for temporary files. Each fork gets a subdirectory here, and the 
fork's whole `java.io.tmpdir` points at it, so anything the fork spools while 
parsing, unpacked embedded files and JVM crash logs all land inside. It does 
*not* cover the host side: tika-server spools over-threshold request bodies 
into its own input temp directory, and `PipesForkParser` into the calling JVM's 
`java.io.tmpdir`. Consider a RAM-backed filesystem (e.g., `/dev/shm`) for 
better performance -- but see t [...]
+|*Deprecated since 4.1, removal planned for 5.0.* Set `-Djava.io.tmpdir` on 
the parent JVM instead; see below. Still honored: each fork gets a subdirectory 
here, and the fork's whole `java.io.tmpdir` points at it. It never covered the 
host side -- tika-server's request-body spool and `PipesForkParser` use the 
parent's `java.io.tmpdir` -- nor any library that calls `File.createTempFile` 
itself.
 |===
 
-The parent deletes a fork's subdirectory when that fork is torn down or fails 
to start, so a
-crashing fork does not accumulate them. A parent killed abruptly (`SIGKILL`, 
container stop)
-cannot, and its subdirectories survive. On a RAM-backed filesystem those leaks 
consume memory
-rather than disk, and `/dev/shm` is commonly sized at half of RAM -- so if you 
point
-`tempDirectory` at one, sweep it on service start.
+=== Where temporary files go
+
+Set `-Djava.io.tmpdir` on the parent JVM. It has to be a launch flag because 
of Tika's
+dependencies: POI, PDFBox and the rest create temp files through the JDK, 
which reads
+`java.io.tmpdir` once at JVM start, so nothing Tika sets at runtime reaches 
them.
+Tika, every library it calls, and its forked pipes servers all honor the flag: 
each fork gets a private subdirectory under it (its whole
+`java.io.tmpdir` points there, so spooled input, unpacked embedded files and 
JVM crash logs
+all land inside), and the parent deletes that subdirectory when the fork is 
torn down.
+Tika checks the directory exists and is writable at config load and refuses to 
start
+otherwise, rather than failing on the first document that spools.
+
+If the parent dies, a surviving fork deletes its own subdirectory as it exits. 
Only when the
+whole process family is killed at once (`SIGKILL` of the group, container 
stop) do
+subdirectories survive, with whatever the forks were spooling inside; Tika 
never deletes
+directories another process created. Point `java.io.tmpdir` at a disk-backed 
directory on a
+volume where filling it does not take out the OS, and apply your own retention 
to
+`pipes-server-*` there.
+
+[WARNING]
+====
+*DO NOT USE tmpfs* (`/dev/shm`, any RAM-backed filesystem) for 
`java.io.tmpdir`.
+
+Spool size is bounded by the input, not by any Tika setting: one large archive 
expanding
+into tmpfs can exhaust memory for every process on the host or get a container 
evicted,
+and a fork directory orphaned by a killed parent pins that RAM until someone 
deletes it.
+A slow run is recoverable; a lost host is not. If you must, give tmpfs a 
dedicated mount
+with `size=` so the failure is a refused write rather than a lost host.
+====
 
 === Embedded-object cache memory budget
 
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFEmbeddedHandler.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFEmbeddedHandler.java
index 9889a4d245..e5ab1241f4 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFEmbeddedHandler.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFEmbeddedHandler.java
@@ -16,6 +16,7 @@
  */
 package org.apache.tika.parser.microsoft.rtf.jflex;
 
+import java.io.Closeable;
 import java.io.IOException;
 import java.util.concurrent.atomic.AtomicInteger;
 
@@ -26,6 +27,7 @@ import org.xml.sax.SAXException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -41,7 +43,7 @@ import org.apache.tika.sax.EmbeddedContentHandler;
  * {@link RTFPictStreamParser}) so that large embedded objects are written
  * to temp files rather than buffered entirely in memory.</p>
  */
-public class RTFEmbeddedHandler {
+public class RTFEmbeddedHandler implements Closeable {
 
     private final ContentHandler handler;
     private final ParseContext context;
@@ -254,4 +256,12 @@ public class RTFEmbeddedHandler {
             return 10 + (ch - 'A');
         }
     }
+
+    /** Closes a stream parser left open by a document that ended inside its 
group. */
+    @Override
+    public void close() throws IOException {
+        TemporaryResources.closeAll(objParser, pictParser);
+        objParser = null;
+        pictParser = null;
+    }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFHtmlDecapsulator.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFHtmlDecapsulator.java
index 7ef06f1ebe..cf4c9f729e 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFHtmlDecapsulator.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFHtmlDecapsulator.java
@@ -75,150 +75,155 @@ public class RTFHtmlDecapsulator {
         boolean inHtmlTag = false;
 
         RTFToken tok;
-        while ((tok = tokenizer.yylex()) != null) {
-            RTFTokenType type = tok.getType();
-            if (type == RTFTokenType.EOF) {
-                break;
-            }
+        // EOF inside {\object or {\pict leaves a stream parser open
+        try {
+            while ((tok = tokenizer.yylex()) != null) {
+                RTFTokenType type = tok.getType();
+                if (type == RTFTokenType.EOF) {
+                    break;
+                }
 
-            // Flush pending bytes before charset-changing events
-            if (type == RTFTokenType.GROUP_CLOSE
-                    || (type == RTFTokenType.CONTROL_WORD && 
"f".equals(tok.getName())
-                        && tok.hasParameter())) {
-                flushPendingBytes(pendingBytes, html, state);
-            }
+                // Flush pending bytes before charset-changing events
+                if (type == RTFTokenType.GROUP_CLOSE
+                        || (type == RTFTokenType.CONTROL_WORD && 
"f".equals(tok.getName())
+                            && tok.hasParameter())) {
+                    flushPendingBytes(pendingBytes, html, state);
+                }
 
-            boolean consumed = state.processToken(tok);
+                boolean consumed = state.processToken(tok);
 
-            // Embedded handler processes objdata/pict/sp in the same pass
-            if (!consumed) {
-                RTFGroupState closingGroup =
-                        (type == RTFTokenType.GROUP_CLOSE) ? 
state.getLastClosedGroup() : null;
-                try {
-                    embHandler.processToken(tok, state, closingGroup);
-                } catch (TikaException | IOException e) {
-                    // don't let a bad embedded object kill decapsulation
+                // Embedded handler processes objdata/pict/sp in the same pass
+                if (!consumed) {
+                    RTFGroupState closingGroup =
+                            (type == RTFTokenType.GROUP_CLOSE) ? 
state.getLastClosedGroup() : null;
+                    try {
+                        embHandler.processToken(tok, state, closingGroup);
+                    } catch (TikaException | IOException e) {
+                        // don't let a bad embedded object kill decapsulation
+                    }
                 }
-            }
 
-            RTFGroupState group = state.getCurrentGroup();
+                RTFGroupState group = state.getCurrentGroup();
 
-            // Skip tokens that are part of objdata/pict hex streams
-            if (!consumed && (group.objdata || group.pictDepth > 0)) {
-                continue;
-            }
-
-            switch (type) {
-                case GROUP_OPEN:
-                    sawIgnorable = false;
-                    break;
-
-                case GROUP_CLOSE:
-                    if (inHtmlTag && state.getDepth() < htmlTagDepth) {
-                        flushPendingBytes(pendingBytes, html, state);
-                        inHtmlTag = false;
-                        htmlTagDepth = -1;
-                    }
-                    break;
+                // Skip tokens that are part of objdata/pict hex streams
+                if (!consumed && (group.objdata || group.pictDepth > 0)) {
+                    continue;
+                }
 
-                case CONTROL_SYMBOL:
-                    if (tok.getChar() == '*') {
-                        sawIgnorable = true;
-                    }
-                    if (!foundHtmlTag || inHtmlRtfSkip) {
+                switch (type) {
+                    case GROUP_OPEN:
+                        sawIgnorable = false;
                         break;
-                    }
-                    if (inHtmlTag || htmlTagDepth == -1) {
-                        char sym = tok.getChar();
-                        if (sym == '{' || sym == '}' || sym == '\\') {
+
+                    case GROUP_CLOSE:
+                        if (inHtmlTag && state.getDepth() < htmlTagDepth) {
                             flushPendingBytes(pendingBytes, html, state);
-                            html.append(sym);
+                            inHtmlTag = false;
+                            htmlTagDepth = -1;
                         }
-                    }
-                    break;
-
-                case CONTROL_WORD:
-                    if (consumed) {
                         break;
-                    }
-                    String name = tok.getName();
 
-                    if ("fromhtml".equals(name)) {
-                        foundFromHtml = true;
-                        break;
-                    }
-                    if ("htmltag".equals(name) && sawIgnorable) {
-                        if (!foundFromHtml) {
+                    case CONTROL_SYMBOL:
+                        if (tok.getChar() == '*') {
+                            sawIgnorable = true;
+                        }
+                        if (!foundHtmlTag || inHtmlRtfSkip) {
                             break;
                         }
-                        foundHtmlTag = true;
-                        flushPendingBytes(pendingBytes, html, state);
-                        inHtmlTag = true;
-                        htmlTagDepth = state.getDepth();
-                        break;
-                    }
-                    if ("htmlrtf".equals(name)) {
-                        flushPendingBytes(pendingBytes, html, state);
-                        inHtmlRtfSkip = !(tok.hasParameter() && 
tok.getParameter() == 0);
-                        break;
-                    }
-                    if (!foundHtmlTag || inHtmlRtfSkip) {
+                        if (inHtmlTag || htmlTagDepth == -1) {
+                            char sym = tok.getChar();
+                            if (sym == '{' || sym == '}' || sym == '\\') {
+                                flushPendingBytes(pendingBytes, html, state);
+                                html.append(sym);
+                            }
+                        }
                         break;
-                    }
-                    if (inHtmlTag || htmlTagDepth == -1) {
-                        flushPendingBytes(pendingBytes, html, state);
-                        switch (name) {
-                            case "par":
-                            case "pard":
-                                html.append('\n');
-                                break;
-                            case "tab":
-                                html.append('\t');
-                                break;
-                            case "line":
-                                html.append("<br>");
-                                break;
-                            default:
-                                break;
+
+                    case CONTROL_WORD:
+                        if (consumed) {
+                            break;
                         }
-                    }
-                    break;
+                        String name = tok.getName();
 
-                case HEX_ESCAPE:
-                    if (consumed || !foundHtmlTag || inHtmlRtfSkip) {
+                        if ("fromhtml".equals(name)) {
+                            foundFromHtml = true;
+                            break;
+                        }
+                        if ("htmltag".equals(name) && sawIgnorable) {
+                            if (!foundFromHtml) {
+                                break;
+                            }
+                            foundHtmlTag = true;
+                            flushPendingBytes(pendingBytes, html, state);
+                            inHtmlTag = true;
+                            htmlTagDepth = state.getDepth();
+                            break;
+                        }
+                        if ("htmlrtf".equals(name)) {
+                            flushPendingBytes(pendingBytes, html, state);
+                            inHtmlRtfSkip = !(tok.hasParameter() && 
tok.getParameter() == 0);
+                            break;
+                        }
+                        if (!foundHtmlTag || inHtmlRtfSkip) {
+                            break;
+                        }
+                        if (inHtmlTag || htmlTagDepth == -1) {
+                            flushPendingBytes(pendingBytes, html, state);
+                            switch (name) {
+                                case "par":
+                                case "pard":
+                                    html.append('\n');
+                                    break;
+                                case "tab":
+                                    html.append('\t');
+                                    break;
+                                case "line":
+                                    html.append("<br>");
+                                    break;
+                                default:
+                                    break;
+                            }
+                        }
                         break;
-                    }
-                    if (inHtmlTag || htmlTagDepth == -1) {
-                        pendingBytes.write(tok.getHexValue());
-                    }
-                    break;
 
-                case UNICODE_ESCAPE:
-                    if (!foundHtmlTag || inHtmlRtfSkip) {
+                    case HEX_ESCAPE:
+                        if (consumed || !foundHtmlTag || inHtmlRtfSkip) {
+                            break;
+                        }
+                        if (inHtmlTag || htmlTagDepth == -1) {
+                            pendingBytes.write(tok.getHexValue());
+                        }
                         break;
-                    }
-                    if (inHtmlTag || htmlTagDepth == -1) {
-                        flushPendingBytes(pendingBytes, html, state);
-                        int cp = tok.getParameter();
-                        if (Character.isValidCodePoint(cp)) {
-                            html.appendCodePoint(cp);
+
+                    case UNICODE_ESCAPE:
+                        if (!foundHtmlTag || inHtmlRtfSkip) {
+                            break;
                         }
-                    }
-                    break;
+                        if (inHtmlTag || htmlTagDepth == -1) {
+                            flushPendingBytes(pendingBytes, html, state);
+                            int cp = tok.getParameter();
+                            if (Character.isValidCodePoint(cp)) {
+                                html.appendCodePoint(cp);
+                            }
+                        }
+                        break;
 
-                case TEXT:
-                    if (consumed || !foundHtmlTag || inHtmlRtfSkip) {
+                    case TEXT:
+                        if (consumed || !foundHtmlTag || inHtmlRtfSkip) {
+                            break;
+                        }
+                        if (inHtmlTag || htmlTagDepth == -1) {
+                            flushPendingBytes(pendingBytes, html, state);
+                            html.append(tok.getChar());
+                        }
                         break;
-                    }
-                    if (inHtmlTag || htmlTagDepth == -1) {
-                        flushPendingBytes(pendingBytes, html, state);
-                        html.append(tok.getChar());
-                    }
-                    break;
 
-                default:
-                    break;
+                    default:
+                        break;
+                }
             }
+        } finally {
+            embHandler.close();
         }
 
         flushPendingBytes(pendingBytes, html, state);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFHtmlDecapsulatorTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFHtmlDecapsulatorTest.java
index 72235f36a3..9d22a40935 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFHtmlDecapsulatorTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/rtf/jflex/RTFHtmlDecapsulatorTest.java
@@ -23,8 +23,15 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashSet;
+import java.util.Set;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
 import org.xml.sax.SAXException;
 import org.xml.sax.helpers.DefaultHandler;
 
@@ -35,6 +42,7 @@ import org.apache.tika.parser.ParseContext;
  * Tests for {@link RTFHtmlDecapsulator}, mirroring the original
  * RTFEncapsulatedHTMLExtractorTest to verify parity.
  */
+@Isolated // testTruncatedPictLeavesNoTempFile snapshots java.io.tmpdir
 public class RTFHtmlDecapsulatorTest {
 
     private static String extract(byte[] rtfBytes)
@@ -257,4 +265,26 @@ public class RTFHtmlDecapsulatorTest {
         assertNotNull(html);
         assertEquals("A\u2014B", html);
     }
+
+    /** EOF inside {\pict: the temp file already exists and no group close 
will hand it off. */
+    @Test
+    public void testTruncatedPictLeavesNoTempFile() throws Exception {
+        String rtf = "{\\rtf1\\ansi\\ansicpg1252\\fromhtml1 
\\deff0{\\pict\\wmetafile8 0102";
+        Set<String> before = tikaTempEntries();
+        extract(rtf.getBytes(US_ASCII));
+        Set<String> leaked = tikaTempEntries();
+        leaked.removeAll(before);
+        assertTrue(leaked.isEmpty(), "temp files left behind: " + leaked);
+    }
+
+    private static Set<String> tikaTempEntries() throws IOException {
+        Set<String> names = new HashSet<>();
+        Path tmpDir = Paths.get(System.getProperty("java.io.tmpdir"));
+        try (DirectoryStream<Path> entries = Files.newDirectoryStream(tmpDir, 
"apache-tika-*.bin")) {
+            for (Path p : entries) {
+                names.add(p.getFileName().toString());
+            }
+        }
+        return names;
+    }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
index 3e26a13dda..17d194abea 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
@@ -130,6 +130,14 @@ public class PDFBoxRenderer implements PDDocumentRenderer {
             for (RenderRequest renderRequest : requests) {
                 processRequest(renderRequest, pdDocument, metadata, 
parseContext, results);
             }
+        } catch (Throwable t) {
+            // results never reach the caller; nothing else would delete the 
pages
+            try {
+                results.close();
+            } catch (IOException e) {
+                t.addSuppressed(e);
+            }
+            throw t;
         } finally {
             if (mustClose) {
                 pdDocument.close();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/poppler/PopplerRenderer.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/poppler/PopplerRenderer.java
index 9e6ac64ac7..714cb00a6c 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/poppler/PopplerRenderer.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/poppler/PopplerRenderer.java
@@ -114,8 +114,18 @@ public class PopplerRenderer implements Renderer {
         TemporaryResources tmp = new TemporaryResources();
         PageBasedRenderResults results = new PageBasedRenderResults(tmp);
         Path path = tis.getPath();
-        for (RenderRequest request : requests) {
-            renderRequest(path, metadata, parseContext, request, results, tmp);
+        try {
+            for (RenderRequest request : requests) {
+                renderRequest(path, metadata, parseContext, request, results, 
tmp);
+            }
+        } catch (Throwable t) {
+            // results never reach the caller; nothing else would delete the 
pages
+            try {
+                results.close();
+            } catch (IOException e) {
+                t.addSuppressed(e);
+            }
+            throw t;
         }
         return results;
     }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRendererTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRendererTest.java
index 0bcf1dc396..a2de6211f9 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRendererTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRendererTest.java
@@ -18,11 +18,20 @@ package org.apache.tika.renderer.pdf.pdfbox;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.IOException;
 import java.io.InputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashSet;
+import java.util.Set;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
 
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
@@ -32,6 +41,7 @@ import org.apache.tika.renderer.PageBasedRenderResults;
 import org.apache.tika.renderer.PageRangeRequest;
 import org.apache.tika.renderer.RenderResult;
 
+@Isolated // testFailedRenderLeavesNoTempFiles snapshots java.io.tmpdir
 public class PDFBoxRendererTest {
 
     private long renderedPngBytes(ParseContext context) throws Exception {
@@ -72,4 +82,30 @@ public class PDFBoxRendererTest {
         assertTrue(uncompressed > compressed * 4,
                 "quality 1.0 should be far larger: " + uncompressed + " vs " + 
compressed);
     }
+
+    /** An out-of-range page throws past the per-page IOException catch after 
RENDER_ALL wrote pages. */
+    @Test
+    public void testFailedRenderLeavesNoTempFiles() throws Exception {
+        PDFBoxRenderer renderer = new PDFBoxRenderer();
+        Set<String> before = tikaTempEntries();
+        try (InputStream is = 
getClass().getResourceAsStream("/test-documents/testPDF.pdf");
+             TikaInputStream tis = TikaInputStream.get(is)) {
+            assertThrows(RuntimeException.class, () -> renderer.render(tis, 
new Metadata(),
+                    new ParseContext(), PageRangeRequest.RENDER_ALL, new 
PageRangeRequest(9999, 9999)));
+        }
+        Set<String> leaked = tikaTempEntries();
+        leaked.removeAll(before);
+        assertTrue(leaked.isEmpty(), "temp files left behind: " + leaked);
+    }
+
+    private static Set<String> tikaTempEntries() throws IOException {
+        Set<String> names = new HashSet<>();
+        Path tmpDir = Paths.get(System.getProperty("java.io.tmpdir"));
+        try (DirectoryStream<Path> entries = Files.newDirectoryStream(tmpDir, 
"tika-pdfbox-rendering-*")) {
+            for (Path p : entries) {
+                names.add(p.getFileName().toString());
+            }
+        }
+        return names;
+    }
 }
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 103cb397a3..a30d3e24a0 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
@@ -415,7 +415,6 @@ public class PerClientServerManager implements 
ServerManager {
                     } else {
                         LOG.error("clientId={}: Process exited with code {} 
before connecting to socket",
                                 clientId, exitValue);
-                        ServerProcessIO.surfaceCrashDiagnostics(LOG, 
"clientId=" + clientId, tmpDir);
                     }
                     // Always treat pre-connect death as retryable.
                     // The only non-retryable paths are:
@@ -430,7 +429,6 @@ public class PerClientServerManager implements 
ServerManager {
                 long elapsed = System.currentTimeMillis() - startTime;
                 if (elapsed > SOCKET_CONNECT_TIMEOUT_MS) {
                     LOG.error("clientId={}: Timed out waiting for server to 
connect after {}ms", clientId, elapsed);
-                    ServerProcessIO.surfaceCrashDiagnostics(LOG, "clientId=" + 
clientId, tmpDir);
                     throw new ServerInitializationException(
                             "Server did not connect within " + 
SOCKET_CONNECT_TIMEOUT_MS + "ms");
                 }
@@ -439,6 +437,7 @@ public class PerClientServerManager implements 
ServerManager {
         }
     }
 
+    @SuppressWarnings("deprecation")
     private synchronized void startServer() throws IOException, 
InterruptedException, TimeoutException, ServerInitializationException {
         if (closed) {
             throw new IllegalStateException("PerClientServerManager is 
closed");
@@ -457,7 +456,7 @@ public class PerClientServerManager implements 
ServerManager {
 
         LOG.trace("clientId={}: starting server on port={}", clientId, port);
 
-        tmpDir = pipesConfig.createTempDirectory("pipes-server-" + clientId + 
"-");
+        tmpDir = pipesConfig.createTempDirectory(PipesServer.TEMP_DIR_PREFIX + 
clientId + "-");
         ProcessBuilder pb = new ProcessBuilder(getCommandline(tmpDir));
         // Tell the child our PID so it can watch ProcessHandle.onExit() and
         // self-terminate promptly if we die. Without this, an orphan child
@@ -536,6 +535,8 @@ public class PerClientServerManager implements 
ServerManager {
         destroyProcess();
 
         if (tmpDir != null) {
+            // never delete a crash log unread
+            ServerProcessIO.surfaceCrashDiagnostics(LOG, "clientId=" + 
clientId, tmpDir);
             deleteDir(tmpDir);
             tmpDir = null;
         }
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesConfig.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesConfig.java
index ce73c844f4..c253a167e7 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesConfig.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesConfig.java
@@ -28,6 +28,8 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.databind.util.StdConverter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import org.apache.tika.config.ExceptionReporting;
 import org.apache.tika.config.TimeoutLimits;
@@ -44,6 +46,8 @@ import org.apache.tika.utils.StringUtils;
 @JsonDeserialize(converter = PipesConfig.PostDeserializationCheck.class)
 public class PipesConfig {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(PipesConfig.class);
+
     /** Runs {@link #checkPayloadLimits()} on every Jackson deserialization 
path. */
     public static class PostDeserializationCheck extends 
StdConverter<PipesConfig, PipesConfig> {
         @Override
@@ -166,11 +170,8 @@ public class PipesConfig {
     private ArrayList<String> forkedJvmArgs = new ArrayList<>();
     private String javaPath = "java";
 
-    /**
-     * Optional directory for temporary files during pipes-based parsing.
-     * If not set, the system default temp directory will be used.
-     * Consider using a RAM-backed filesystem (e.g., /dev/shm) for better 
performance.
-     */
+    /** @deprecated since 4.1, removal planned for 5.0; see {@link 
#setTempDirectory(String)} */
+    @Deprecated
     private String tempDirectory = null;
 
     /**
@@ -528,11 +529,8 @@ public class PipesConfig {
         this.configStoreParams = configStoreParams;
     }
 
-    /**
-     * Gets the directory for temporary files during pipes-based parsing.
-     *
-     * @return the temp directory path, or null to use system default
-     */
+    /** @deprecated since 4.1, removal planned for 5.0; see {@link 
#setTempDirectory(String)} */
+    @Deprecated
     public String getTempDirectory() {
         return tempDirectory;
     }
@@ -541,7 +539,10 @@ public class PipesConfig {
      * Creates a temp directory under {@link #getTempDirectory()}, or under 
the system default
      * when unset. Callers must not use {@code Files.createTempDirectory} 
directly or the
      * configured directory is silently ignored.
+     *
+     * @deprecated since 4.1, removal planned for 5.0; use {@code 
Files.createTempDirectory(prefix)}
      */
+    @Deprecated
     public Path createTempDirectory(String prefix) throws IOException {
         if (StringUtils.isBlank(tempDirectory)) {
             return Files.createTempDirectory(prefix);
@@ -552,13 +553,25 @@ public class PipesConfig {
     }
 
     /**
-     * Sets the directory for temporary files during pipes-based parsing.
-     * If not set, the system default temp directory will be used.
-     * Consider using a RAM-backed filesystem (e.g., /dev/shm or /tmpfs) for 
better performance.
+     * Directory the forks create their private temp dirs under. It only ever 
covered the
+     * forks: this JVM's own spooling and every library calling {@code 
File.createTempFile}
+     * use {@code java.io.tmpdir}, fixed at JVM start. Set {@code 
-Djava.io.tmpdir} on the
+     * parent JVM instead; forks inherit subdirectories under it.
+     * <p>
+     * <b>DO NOT USE tmpfs</b> ({@code /dev/shm}) here or for {@code 
java.io.tmpdir}: spool
+     * size is bounded by input, not config, and a fork dir orphaned by a 
killed parent pins
+     * RAM until deleted.
      *
-     * @param tempDirectory the temp directory path, or null to use system 
default
+     * @deprecated since 4.1, removal planned for 5.0; set {@code 
-Djava.io.tmpdir} instead
      */
+    @Deprecated
     public void setTempDirectory(String tempDirectory) {
+        if (!StringUtils.isBlank(tempDirectory)) {
+            LOG.warn("pipes.tempDirectory={} is deprecated (removal in 5.0) 
and only redirects " +
+                    "the forks; this JVM's dependencies (POI, PDFBox, ...) 
write to " +
+                    "java.io.tmpdir={}, fixed at JVM start. Set 
-Djava.io.tmpdir on the parent JVM.",
+                    tempDirectory, System.getProperty("java.io.tmpdir"));
+        }
         this.tempDirectory = tempDirectory;
     }
 
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 a4a0478ae9..c0a8088d2c 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
@@ -295,6 +295,7 @@ public class SharedServerManager implements ServerManager {
         }
     }
 
+    @SuppressWarnings("deprecation")
     private void startServer() throws IOException, InterruptedException, 
TimeoutException, ServerInitializationException {
         // Clean up any previous server
         if (process != null || tmpDir != null) {
@@ -333,7 +334,7 @@ public class SharedServerManager implements ServerManager {
                 "  in-flight parses as collateral damage.\n");
         LOG.info("Starting shared server with {} connections", numConnections);
 
-        tmpDir = pipesConfig.createTempDirectory("pipes-shared-server-");
+        tmpDir = 
pipesConfig.createTempDirectory(PipesServer.SHARED_TEMP_DIR_PREFIX);
         ProcessBuilder pb = new ProcessBuilder(getCommandline());
         // Pass port and auth token via environment variables so they are not
         // visible in /proc/<pid>/cmdline. The token is only readable via
@@ -397,7 +398,6 @@ public class SharedServerManager implements ServerManager {
                 if (!process.isAlive()) {
                     int exitValue = process.exitValue();
                     LOG.error("Shared server process exited with code {} 
before becoming ready", exitValue);
-                    ServerProcessIO.surfaceCrashDiagnostics(LOG, 
"shared-server", tmpDir);
                     throw new ServerInitializationException(
                             "Shared server failed to start (exit code " + 
exitValue + "). Check JVM arguments and classpath.");
                 }
@@ -406,7 +406,6 @@ public class SharedServerManager implements ServerManager {
                 long elapsed = System.currentTimeMillis() - startTime;
                 if (elapsed > STARTUP_TIMEOUT_MILLIS) {
                     LOG.error("Timed out waiting for shared server to start 
after {}ms", elapsed);
-                    ServerProcessIO.surfaceCrashDiagnostics(LOG, 
"shared-server", tmpDir);
                     destroyProcessUnsafe();
                     throw new ServerInitializationException(
                             "Shared server did not start within " + 
STARTUP_TIMEOUT_MILLIS + "ms");
@@ -452,6 +451,8 @@ public class SharedServerManager implements ServerManager {
         destroyProcessUnsafe();
 
         if (tmpDir != null) {
+            // never delete a crash log unread
+            ServerProcessIO.surfaceCrashDiagnostics(LOG, "shared-server", 
tmpDir);
             deleteDir(tmpDir);
             tmpDir = null;
         }
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
index dd545e38bb..a8a0a2d277 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
@@ -41,6 +41,7 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 
+import org.apache.commons.io.FileUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.xml.sax.SAXException;
@@ -158,6 +159,10 @@ public class PipesServer implements AutoCloseable {
      *  process handle and exit promptly if the parent dies. */
     public static final String PARENT_PID_ENV = "TIKA_PIPES_PARENT_PID";
 
+    /** Prefixes of the temp dirs the parent creates for forks; a fork deletes 
only a dir so named. */
+    public static final String TEMP_DIR_PREFIX = "pipes-server-";
+    public static final String SHARED_TEMP_DIR_PREFIX = "pipes-shared-server-";
+
     /** Exit code used when the child self-terminates because its parent JVM
      *  disappeared. Distinct from UNSPECIFIED_CRASH (19) so log readers can
      *  tell the difference between "I crashed" and "my parent went away". */
@@ -737,17 +742,43 @@ public class PipesServer implements AutoCloseable {
         if (parent.isEmpty()) {
             LOG.error("parent pid {} not found at startup; exiting to avoid 
orphan",
                     parentPid);
-            System.exit(PARENT_GONE_EXIT_CODE);
+            exitParentGone();
             return;
         }
         parent.get().onExit().thenRun(() -> {
             LOG.error("parent pid {} exited; shutting down to avoid orphan",
                     parentPid);
-            System.exit(PARENT_GONE_EXIT_CODE);
+            exitParentGone();
         });
         LOG.info("watching parent pid {} for exit", parentPid);
     }
 
+    /** Only when the parent is gone: on the fork's own crash the dir must 
survive for the parent to read. */
+    private static void exitParentGone() {
+        try {
+            deleteOwnTempDir(Paths.get(System.getProperty("java.io.tmpdir")));
+        } finally {
+            System.exit(PARENT_GONE_EXIT_CODE);
+        }
+    }
+
+    /** @return true if {@code dir} is parent-created (by name) and is now 
deleted */
+    static boolean deleteOwnTempDir(Path dir) {
+        String name = dir.getFileName() == null ? "" : 
dir.getFileName().toString();
+        if (!name.startsWith(TEMP_DIR_PREFIX) && 
!name.startsWith(SHARED_TEMP_DIR_PREFIX)) {
+            LOG.warn("java.io.tmpdir={} was not created by a parent manager; 
leaving it", dir);
+            return false;
+        }
+        try {
+            FileUtils.deleteDirectory(dir.toFile());
+            LOG.info("deleted own temp dir {}", dir);
+            return true;
+        } catch (IOException e) {
+            LOG.warn("couldn't delete own temp dir {}: {}", dir, e.toString());
+            return false;
+        }
+    }
+
     /** Below this, ordinary documents -- not just pathological ones -- start 
OOMing. */
     private static final long MIN_USABLE_HEAP_BYTES = 256L * 1024 * 1024;
 
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PipesServerTempDirTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PipesServerTempDirTest.java
new file mode 100644
index 0000000000..39b7e0033e
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PipesServerTempDirTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.server;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class PipesServerTempDirTest {
+
+    /** Only a parent-named dir is deleted; the system temp dir a 
hand-launched server sees is not. */
+    @Test
+    public void testDeletesOnlyParentCreatedDir(@TempDir Path tmp) throws 
Exception {
+        for (String prefix : new String[]{PipesServer.TEMP_DIR_PREFIX,
+                PipesServer.SHARED_TEMP_DIR_PREFIX}) {
+            Path own = Files.createDirectories(tmp.resolve(prefix + "1-abc"));
+            Files.writeString(own.resolve("spooled.tmp"), "document bytes");
+            Files.createDirectories(own.resolve("nested"));
+            assertTrue(PipesServer.deleteOwnTempDir(own));
+            assertFalse(Files.exists(own));
+        }
+
+        Path notOwn = Files.createDirectories(tmp.resolve("something-else"));
+        Files.writeString(notOwn.resolve("keep.tmp"), "not ours");
+        assertFalse(PipesServer.deleteOwnTempDir(notOwn));
+        assertTrue(Files.exists(notOwn.resolve("keep.tmp")));
+        assertFalse(PipesServer.deleteOwnTempDir(tmp));
+        assertTrue(Files.exists(notOwn));
+    }
+}
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
index e117b71553..e04120c6cd 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
@@ -19,7 +19,9 @@ package org.apache.tika.config.loader;
 import java.io.File;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -30,6 +32,8 @@ import com.fasterxml.jackson.core.StreamReadConstraints;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import org.apache.tika.detect.CompositeDetector;
 import org.apache.tika.detect.CompositeEncodingDetector;
@@ -60,6 +64,7 @@ import org.apache.tika.serialization.JsonMetadataList;
 import org.apache.tika.serialization.ParseContextUtils;
 import org.apache.tika.serialization.config.GlobalSettings;
 import org.apache.tika.serialization.serdes.ParseContextDeserializer;
+import org.apache.tika.utils.StringUtils;
 
 /**
  * Main entry point for loading Tika components from JSON configuration.
@@ -93,6 +98,10 @@ import 
org.apache.tika.serialization.serdes.ParseContextDeserializer;
  */
 public class TikaLoader {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(TikaLoader.class);
+
+    private static volatile boolean tempDirectoryLogged = false;
+
     // Static registration of component configurations
     static {
         registerComponentConfigs();
@@ -170,9 +179,30 @@ public class TikaLoader {
      * @throws TikaConfigException if loading global settings fails
      */
     private void init() throws TikaConfigException, IOException {
+        checkTempDirectory();
         loadGlobalSettings();
     }
 
+    /** Fail here, naming the dir, rather than on the first spool with a bare 
NoSuchFileException. */
+    private static void checkTempDirectory() throws TikaConfigException {
+        String prop = System.getProperty("java.io.tmpdir");
+        if (StringUtils.isBlank(prop)) {
+            throw new TikaConfigException("java.io.tmpdir is not set");
+        }
+        Path dir = Paths.get(prop);
+        if (!Files.isDirectory(dir)) {
+            throw new TikaConfigException(
+                    "java.io.tmpdir does not exist or is not a directory: " + 
dir);
+        }
+        if (!Files.isWritable(dir)) {
+            throw new TikaConfigException("java.io.tmpdir is not writable: " + 
dir);
+        }
+        if (!tempDirectoryLogged) {
+            LOG.info("temporary files go to java.io.tmpdir={}", 
dir.toAbsolutePath());
+            tempDirectoryLogged = true;
+        }
+    }
+
     /**
      * Loads a Tika configuration from a file.
      * Global settings are automatically loaded and applied during 
initialization.
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/TikaLoaderTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TikaLoaderTest.java
index d294ac203a..ec7e3b1782 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/TikaLoaderTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TikaLoaderTest.java
@@ -28,6 +28,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.xml.sax.helpers.DefaultHandler;
 
 import org.apache.tika.config.EmbeddedLimits;
@@ -56,6 +57,27 @@ public class TikaLoaderTest {
         assertNotNull(parser, "Parser should not be null");
     }
 
+    @Test
+    public void testUnusableTempDirectoryFailsAtLoad(@TempDir Path tmp) throws 
Exception {
+        Path configPath = 
Path.of(getClass().getResource("/configs/test-loader-config.json").toURI());
+        Path missing = tmp.resolve("missing");
+        Path file = Files.writeString(tmp.resolve("file"), "");
+        String original = System.getProperty("java.io.tmpdir");
+        try {
+            for (Path bad : new Path[]{missing, file}) {
+                System.setProperty("java.io.tmpdir", bad.toString());
+                TikaConfigException e = assertThrows(TikaConfigException.class,
+                        () -> TikaLoader.load(configPath), bad.toString());
+                assertTrue(e.getMessage().contains("java.io.tmpdir"), 
e.getMessage());
+                assertTrue(e.getMessage().contains(bad.toString()), 
e.getMessage());
+            }
+            System.setProperty("java.io.tmpdir", tmp.toString());
+            assertNotNull(TikaLoader.load(configPath));
+        } finally {
+            System.setProperty("java.io.tmpdir", original);
+        }
+    }
+
     @Test
     public void testConfigurableParserConfiguration() throws Exception {
         URL configUrl = 
getClass().getResource("/configs/test-loader-config.json");
diff --git 
a/tika-translate/src/main/java/org/apache/tika/language/translate/impl/MarianTranslator.java
 
b/tika-translate/src/main/java/org/apache/tika/language/translate/impl/MarianTranslator.java
index 9170e154f7..11b882c9ac 100644
--- 
a/tika-translate/src/main/java/org/apache/tika/language/translate/impl/MarianTranslator.java
+++ 
b/tika-translate/src/main/java/org/apache/tika/language/translate/impl/MarianTranslator.java
@@ -132,15 +132,14 @@ public class MarianTranslator extends AbstractTranslator {
 
         StringBuilder translation = new StringBuilder();
         File tmpFile = Files.createTempFile(INPUT_TMP_NAME, ".tmp").toFile();
-        tmpFile.deleteOnExit();
-        try (OutputStreamWriter out = new OutputStreamWriter(new 
FileOutputStream(tmpFile),
-                Charset.defaultCharset())) {
-            out.append(text).append('\n').close();
-        }
-        File tmpTranslatedFile = Files.createTempFile(OUTPUT_TMP_NAME, 
".tmp").toFile();
-        tmpTranslatedFile.deleteOnExit();
-
+        File tmpTranslatedFile = null;
         try {
+            try (OutputStreamWriter out = new OutputStreamWriter(new 
FileOutputStream(tmpFile),
+                    Charset.defaultCharset())) {
+                out.append(text).append('\n').close();
+            }
+            tmpTranslatedFile = Files.createTempFile(OUTPUT_TMP_NAME, 
".tmp").toFile();
+
             String preProcessScript = 
config.getProperty("translator.marian.preprocess");
             executeScript(preProcessScript, tmpFile);
 
@@ -161,15 +160,25 @@ public class MarianTranslator extends AbstractTranslator {
 
         } catch (InterruptedException e) {
             throw new TikaException("Failed perform translation", e);
-        }
-
-        if (!tmpFile.delete() || !tmpTranslatedFile.delete()) {
-            throw new IOException("Failed to delete temporary files.");
+        } finally {
+            deleteQuietly(tmpFile);
+            deleteQuietly(tmpTranslatedFile);
         }
 
         return translation.toString();
     }
 
+    private static void deleteQuietly(File file) {
+        if (file == null) {
+            return;
+        }
+        try {
+            Files.deleteIfExists(file.toPath());
+        } catch (IOException e) {
+            LOG.warn("Failed to delete temporary file {}", file, e);
+        }
+    }
+
     /**
      * Process the translation request using a local instance of Marian - i.e. 
either </i>marian-decoder</i>
      * or <i>marian</i> command line applications.

Reply via email to