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 7a79fd2549 TIKA-4793: make the Pipes IPC payload limit configurable
(#3009)
7a79fd2549 is described below
commit 7a79fd2549c69b6588c2277816e62869fa7ab694
Author: Srinivasarao Daruna <[email protected]>
AuthorDate: Fri Aug 14 06:42:07 2026 -0400
TIKA-4793: make the Pipes IPC payload limit configurable (#3009)
Adds maxIpcPayloadBytes (default 100 MiB) and a server-side
BoundedOutputStream guard so an oversized result fails as
PAYLOAD_LIMIT_EXCEEDED instead of OOMing the worker. Preserves
already-emitted statuses on overflow and fixes archive sizing.
Closes #3009
Co-authored-by: Tim Allison <[email protected]>
---
CHANGES.txt | 5 +
docs/modules/ROOT/pages/pipes/configuration.adoc | 11 +
.../org/apache/tika/pipes/core/PipesClient.java | 10 +
.../org/apache/tika/pipes/core/PipesConfig.java | 25 +-
.../tika/pipes/core/emitter/EmitDataImpl.java | 12 +-
.../pipes/core/serialization/JsonPipesIpc.java | 11 +
.../tika/pipes/core/server/ConnectionHandler.java | 16 +-
.../apache/tika/pipes/core/server/PipesServer.java | 8 +-
.../tika/pipes/core/server/ServerProtocolIO.java | 180 +++++++++++-
.../tika/pipes/core/TikaPipesConfigTest.java | 13 +-
.../pipes/core/server/ServerProtocolIOTest.java | 303 ++++++++++++++++++++-
11 files changed, 563 insertions(+), 31 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index d2f1ae5bcf..848567bb09 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -111,6 +111,11 @@ Release 4.0.0 - ???
OTHER CHANGES
+ * PipesClient/PipesServer IPC now enforces a configurable payload limit
+ (pipes.maxIpcPayloadBytes, default 100 MB) in both directions. Results
+ that exceed the limit return PAYLOAD_LIMIT_EXCEEDED instead of causing
+ heap exhaustion; crash messages are also size-capped (TIKA-4793).
+
* MagicDetector now compiles its regular expression once, in the
constructor, instead of recompiling it on every match (TIKA-4796).
diff --git a/docs/modules/ROOT/pages/pipes/configuration.adoc
b/docs/modules/ROOT/pages/pipes/configuration.adoc
index 29528b011d..9a75633aa7 100644
--- a/docs/modules/ROOT/pages/pipes/configuration.adoc
+++ b/docs/modules/ROOT/pages/pipes/configuration.adoc
@@ -138,6 +138,17 @@ These settings control how parsed results are batched
before sending to emitters
|When `false`, only successfully-parsed tuples reach the emitter — files that
crash, time out, or otherwise fail are dropped from the output. When `true`,
every tuple is emitted, including failures (the metadata carries the
exception). Turn this on if you need a complete record of what was attempted
(audit, retry logic, chaos-monkey tests).
|===
+== IPC Payload Limit
+
+[cols="1,1,3"]
+|===
+|Field |Default |Description
+
+|`maxIpcPayloadBytes`
+|`104857600` (100 MB)
+|Maximum size in bytes of a single IPC message between the client and the
forked server. This limit is *bidirectional*: it applies both to parse results
returned from the server (FINISHED) and to requests sent from the client
(NEW_REQUEST). Raising it lets very large documents pass over IPC; set the
forked JVM `-Xmx` to at least approximately 3× this value to keep heap usage
under control. Setting it too small (below the size of a typical
`FetchEmitTuple`) will cause requests to be rejec [...]
+|===
+
== Emit Strategy
`emitStrategy` controls whether parsed extracts are emitted directly from the
forked PipesServer or passed back to the parent process first. The default is
balanced for typical workloads — tune only if you have a memory or throughput
problem.
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
index 1ded2996d9..7fb00cb9f3 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
@@ -421,6 +421,16 @@ public class PipesClient implements Closeable {
if (result.emitData() instanceof EmitDataImpl
emitDataImpl) {
emitDataImpl.setParseContext(t.getParseContext());
}
+ // The server's static PAYLOAD_LIMIT_EXCEEDED fallback
frame carries null
+ // emitData/emitKey. AsyncEmitter silently skips
null-emitData results, so
+ // the document would disappear from the audit trail.
Rebuild with the
+ // original emit key using what partial metadata we
have.
+ if (result.emitData() == null
+ && result.status() ==
PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED) {
+ return buildFatalResult(t.getId(), t.getEmitKey(),
+
PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
+ intermediateResult.get());
+ }
return result;
default:
throw new IOException("Unexpected message type from
server: " + msg.type());
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 ac266b7253..8f2907fc0a 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
@@ -30,6 +30,7 @@ import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.pipes.api.FetchEmitTuple;
import org.apache.tika.pipes.api.ParseMode;
import org.apache.tika.pipes.core.protocol.PipesMessage;
+import org.apache.tika.pipes.core.server.ServerProtocolIO;
public class PipesConfig {
@@ -546,18 +547,26 @@ public class PipesConfig {
}
/**
- * Sets the maximum IPC payload size in bytes. Must be a positive value.
- * This bounds the size of a message the client will accept back from the
- * forked server (chiefly the FINISHED result). Request payloads
- * (client to server) are small and use the built-in default.
+ * Sets the maximum IPC payload size in bytes. This limit is
<em>bidirectional</em>:
+ * it controls both the largest result the client will accept back from
the forked server
+ * (the FINISHED payload) and the largest request the server will accept
from the client
+ * (the NEW_REQUEST payload). Lowering this value below the size of a
typical
+ * {@link org.apache.tika.pipes.api.FetchEmitTuple} will cause requests to
be rejected
+ * on the server side and reported as undiagnosable {@code
UNSPECIFIED_CRASH} errors.
+ * <p>
+ * The value must be at least {@link
org.apache.tika.pipes.core.server.ServerProtocolIO#MIN_FALLBACK_PAYLOAD_BYTES}
+ * so that the server can always write a {@code PAYLOAD_LIMIT_EXCEEDED}
response
+ * that the client will accept.
*
- * @param maxIpcPayloadBytes positive payload limit in bytes
- * @throws IllegalArgumentException if the value is not positive
+ * @param maxIpcPayloadBytes payload limit in bytes (must be ≥ {@code
ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES})
+ * @throws IllegalArgumentException if the value is below the minimum
*/
public void setMaxIpcPayloadBytes(int maxIpcPayloadBytes) {
- if (maxIpcPayloadBytes <= 0) {
+ if (maxIpcPayloadBytes < ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES) {
throw new IllegalArgumentException(
- "maxIpcPayloadBytes must be positive, got: " +
maxIpcPayloadBytes);
+ "maxIpcPayloadBytes must be at least " +
+ ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES +
+ " (minimum to carry a PAYLOAD_LIMIT_EXCEEDED response),
got: " + maxIpcPayloadBytes);
}
this.maxIpcPayloadBytes = maxIpcPayloadBytes;
}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java
index 930d594918..5fec421209 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java
@@ -77,13 +77,17 @@ public class EmitDataImpl implements EmitData {
private static long estimateSizeInBytes(String id, List<Metadata>
metadataList,
String containerStackTrace) {
- long sz = 36 + id.length() * 2;
- sz += 36 + containerStackTrace.length() * 2;
+ // Estimates Java heap cost (UTF-16: 2 bytes/char + object overhead).
+ // Used by the DYNAMIC emit strategy to decide passback vs.
direct-emit; it is not
+ // used to enforce the IPC payload limit (that is handled by
BoundedOutputStream in
+ // ServerProtocolIO, which measures actual wire bytes during
serialization).
+ long sz = 36 + id.length() * 2L;
+ sz += 36 + containerStackTrace.length() * 2L;
for (Metadata m : metadataList) {
for (String n : m.names()) {
- sz += 36 + n.length() * 2;
+ sz += 36 + n.length() * 2L;
for (String v : m.getValues(n)) {
- sz += 36 + v.length() * 2;
+ sz += 36 + v.length() * 2L;
}
}
}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/JsonPipesIpc.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/JsonPipesIpc.java
index 30cb4c6b6a..1de3451cc2 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/JsonPipesIpc.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/JsonPipesIpc.java
@@ -17,6 +17,7 @@
package org.apache.tika.pipes.core.serialization;
import java.io.IOException;
+import java.io.OutputStream;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -72,6 +73,16 @@ public class JsonPipesIpc {
return OBJECT_MAPPER.writeValueAsBytes(obj);
}
+ /**
+ * Serialize an object to Smile binary format, writing directly into
{@code out}.
+ * Any {@link IOException} thrown by {@code out} (e.g. from a size-capped
stream)
+ * propagates unchanged, letting callers distinguish payload-limit aborts
from
+ * genuine I/O errors.
+ */
+ public static void toStream(Object obj, OutputStream out) throws
IOException {
+ OBJECT_MAPPER.writeValue(out, obj);
+ }
+
/**
* Deserialize Smile binary format bytes to an object.
*/
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
index f00fc7012a..19808fce9f 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
@@ -104,7 +104,7 @@ public class ConnectionHandler implements Runnable,
Closeable {
this.resources = resources;
this.pipesConfig = pipesConfig;
this.heartbeatIntervalMillis =
pipesConfig.getHeartbeatIntervalMillis();
- this.protocolIO = new ServerProtocolIO(input, output);
+ this.protocolIO = new ServerProtocolIO(input, output,
pipesConfig.getMaxIpcPayloadBytes());
}
@Override
@@ -181,6 +181,20 @@ public class ConnectionHandler implements Runnable,
Closeable {
LOG.error("handlerId={}: config error processing
request", handlerId, e);
handleCrash(PipesMessageType.UNSPECIFIED_CRASH,
fetchEmitTuple.getId(), e);
} catch (Throwable t) {
+ if (t instanceof Error) {
+ // OOM or other JVM-level error: don't trust
the heap; exit
+ // immediately. Everything before the exit is
best-effort and
+ // inside the try -- a secondary OOM in
logging or writeCrash
+ // must not escape and leave this shared JVM
alive post-Error.
+ try {
+ LOG.error("handlerId={}: fatal JVM error;
exiting", handlerId, t);
+
protocolIO.writeCrash(PipesMessageType.OOM, t);
+ } catch (Throwable ignored) {
+ //swallow
+ } finally {
+
System.exit(PipesMessageType.OOM.getExitCode().orElse(18));
+ }
+ }
// respond, or the client blocks until socket
timeout and
// restarts a healthy server
LOG.error("handlerId={}: error processing
request", handlerId, t);
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 1a2e87aba2..e0602b1123 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
@@ -189,7 +189,7 @@ public class PipesServer implements AutoCloseable {
validateHeartbeatInterval(pipesConfig);
emitStrategy = pipesConfig.getEmitStrategy().getType();
- this.protocolIO = new ServerProtocolIO(input, output);
+ this.protocolIO = new ServerProtocolIO(input, output,
pipesConfig.getMaxIpcPayloadBytes());
}
@@ -402,6 +402,12 @@ public class PipesServer implements AutoCloseable {
try {
loopUntilDone(fetchEmitTuple, mergedContext,
executorCompletionService, intermediateResult, countDownLatch, parseTimeout);
} catch (Throwable t) {
+ if (t instanceof Error) {
+ // OOM or other JVM-level error: exit rather
than continue in a
+ // possibly corrupt heap state.
+ handleCrash(PipesMessageType.OOM,
fetchEmitTuple.getId(), t);
+ return; // handleCrash calls exit();
unreachable
+ }
LOG.error("Serious problem processing request", t);
}
break;
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
index 54ee7de184..e94407d1c5 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ServerProtocolIO.java
@@ -16,9 +16,12 @@
*/
package org.apache.tika.pipes.core.server;
+import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -50,48 +53,163 @@ public class ServerProtocolIO {
private static final Logger LOG =
LoggerFactory.getLogger(ServerProtocolIO.class);
+ /**
+ * Pre-serialized fallback payload (Smile-encoded {@code
PAYLOAD_LIMIT_EXCEEDED} result).
+ * Private to prevent external mutation of the array contents — {@code
static final}
+ * prevents reference reassignment but not element writes.
+ */
+ private static final byte[] FALLBACK_PAYLOAD_BYTES;
+
+ /**
+ * The minimum value accepted for {@code maxPayloadBytes} in the
constructor and in
+ * {@link
org.apache.tika.pipes.core.PipesConfig#setMaxIpcPayloadBytes(int)}: the
+ * serialized byte length of {@link #FALLBACK_PAYLOAD_BYTES}.
+ * Any configured limit smaller than this cannot carry even the fallback
frame.
+ */
+ public static final int MIN_FALLBACK_PAYLOAD_BYTES;
+
+ static {
+ try {
+ FALLBACK_PAYLOAD_BYTES = JsonPipesIpc.toBytes(
+ new
PipesResult(PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
+ "payload_limit_exceeded"));
+ MIN_FALLBACK_PAYLOAD_BYTES = FALLBACK_PAYLOAD_BYTES.length;
+ } catch (IOException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
private final DataInputStream input;
private final DataOutputStream output;
+ private final int maxIpcPayloadBytes;
- public ServerProtocolIO(DataInputStream input, DataOutputStream output) {
+ public ServerProtocolIO(DataInputStream input, DataOutputStream output,
int maxIpcPayloadBytes) {
+ if (maxIpcPayloadBytes < MIN_FALLBACK_PAYLOAD_BYTES) {
+ throw new IllegalArgumentException(String.format(Locale.ROOT,
+ "maxIpcPayloadBytes %d is below the minimum %d required to
carry a PAYLOAD_LIMIT_EXCEEDED response",
+ maxIpcPayloadBytes, MIN_FALLBACK_PAYLOAD_BYTES));
+ }
this.input = input;
this.output = output;
+ this.maxIpcPayloadBytes = maxIpcPayloadBytes;
}
/**
* Writes a FINISHED message with the serialized result and waits for ACK.
+ * <p>
+ * Serialization is streamed into a {@link BoundedOutputStream} capped at
+ * {@code maxIpcPayloadBytes}. If the payload overflows the cap, the
stream aborts
+ * before any bytes are sent to the client and a pre-computed
+ * {@code PAYLOAD_LIMIT_EXCEEDED} frame is sent instead. This keeps the
original
+ * result status intact when the payload fits, avoids unbounded heap
allocation,
+ * and prevents wire desynchronization on the client side.
*
* @throws ShutDownReceivedException if SHUT_DOWN is received instead of
ACK
* @throws IOException on serialization or I/O errors
*/
public void writeFinished(PipesResult pipesResult) throws IOException {
- byte[] bytes = JsonPipesIpc.toBytes(pipesResult);
- PipesMessage.finished(bytes).write(output);
+ BoundedOutputStream bos = new BoundedOutputStream(maxIpcPayloadBytes);
+ try {
+ JsonPipesIpc.toStream(pipesResult, bos);
+ } catch (IOException e) {
+ if (!bos.overflowed()) {
+ throw e;
+ }
+ LOG.warn("Payload exceeded maxIpcPayloadBytes {}; returning
PAYLOAD_LIMIT_EXCEEDED",
+ maxIpcPayloadBytes);
+ // If content was already emitted server-side, preserve that
status so the
+ // client does not duplicate the emission on the passback path.
The fixed
+ // message replaces the original, which may itself be the overflow
source
+ // (an accumulated parse-exception stack).
+ if (alreadyEmitted(pipesResult.status())) {
+ BoundedOutputStream fallbackBos = new
BoundedOutputStream(maxIpcPayloadBytes);
+ try {
+ JsonPipesIpc.toStream(
+ new PipesResult(pipesResult.status(),
"payload_limit_exceeded"),
+ fallbackBos);
+
PipesMessage.finished(fallbackBos.toByteArray()).write(output);
+ awaitAck();
+ return;
+ } catch (IOException fallbackE) {
+ if (!fallbackBos.overflowed()) {
+ throw fallbackE;
+ }
+ // Even the status-only result overflows — fall through to
the
+ // guaranteed-fit static fallback.
+ }
+ }
+ doWritePayloadLimitExceeded();
+ return;
+ }
+ PipesMessage.finished(bos.toByteArray()).write(output);
+ awaitAck();
+ }
+
+ /**
+ * True for statuses whose content the server already emitted. Replacing
one of these
+ * with a failure status makes the client treat an emitted document as
failed, so a
+ * retry emits it a second time.
+ */
+ private static boolean alreadyEmitted(PipesResult.RESULT_STATUS status) {
+ return status == PipesResult.RESULT_STATUS.EMIT_SUCCESS ||
+ status == PipesResult.RESULT_STATUS.EMIT_SUCCESS_PASSBACK ||
+ status ==
PipesResult.RESULT_STATUS.EMIT_SUCCESS_PARSE_EXCEPTION;
+ }
+
+ private void doWritePayloadLimitExceeded() throws IOException {
+ // FALLBACK_PAYLOAD_BYTES is pre-computed at class load and guaranteed
to be smaller
+ // than maxIpcPayloadBytes (enforced by the constructor), so the
client always accepts it.
+ PipesMessage.finished(FALLBACK_PAYLOAD_BYTES).write(output);
awaitAck();
}
/**
* Writes an INTERMEDIATE_RESULT message with the serialized metadata and
waits for ACK.
+ * If the metadata exceeds {@code maxIpcPayloadBytes}, the intermediate is
silently skipped
+ * (the FINISHED message will still follow).
*
* @throws ShutDownReceivedException if SHUT_DOWN is received instead of
ACK
* @throws IOException on serialization or I/O errors
*/
public void writeIntermediate(Metadata metadata) throws IOException {
- byte[] bytes = JsonPipesIpc.toBytes(metadata);
- PipesMessage.intermediateResult(bytes).write(output);
+ BoundedOutputStream bos = new BoundedOutputStream(maxIpcPayloadBytes);
+ try {
+ JsonPipesIpc.toStream(metadata, bos);
+ } catch (IOException e) {
+ if (bos.overflowed()) {
+ LOG.warn("Intermediate result payload exceeded
maxIpcPayloadBytes {}; skipping intermediate",
+ maxIpcPayloadBytes);
+ return;
+ }
+ throw e;
+ }
+ PipesMessage.intermediateResult(bos.toByteArray()).write(output);
awaitAck();
}
/**
* Writes a crash message (OOM, TIMEOUT, or UNSPECIFIED_CRASH) with the
- * serialized stack trace and waits for ACK.
+ * serialized stack trace and waits for ACK. Serialization is streamed into
+ * a {@link BoundedOutputStream} capped at {@code maxIpcPayloadBytes}. If
+ * the stack trace overflows the cap, an empty payload is sent instead.
*
* @throws IOException on serialization, I/O, or unexpected ACK response
*/
public void writeCrash(PipesMessageType crashType, Throwable t) throws
IOException {
String msg = (t != null) ? ExceptionUtils.getStackTrace(t) : "";
- byte[] bytes = JsonPipesIpc.toBytes(msg);
- PipesMessage.crash(crashType, bytes).write(output);
+ BoundedOutputStream bos = new BoundedOutputStream(maxIpcPayloadBytes);
+ try {
+ JsonPipesIpc.toStream(msg, bos);
+ } catch (IOException e) {
+ if (!bos.overflowed()) {
+ throw e;
+ }
+ // Stack trace overflows limit (e.g., CJK chars encode at 3
bytes/char in Smile).
+ // Fall back to an empty payload, guaranteed to fit within any
valid limit.
+ bos = new BoundedOutputStream(maxIpcPayloadBytes);
+ JsonPipesIpc.toStream("", bos);
+ }
+ PipesMessage.crash(crashType, bos.toByteArray()).write(output);
awaitAck();
}
@@ -102,7 +220,7 @@ public class ServerProtocolIO {
* @throws IOException if the message is any other non-ACK type, or on I/O
error
*/
public void awaitAck() throws IOException {
- PipesMessage msg = PipesMessage.read(input);
+ PipesMessage msg = PipesMessage.read(input, maxIpcPayloadBytes);
if (msg.type() == PipesMessageType.ACK) {
return;
}
@@ -165,4 +283,48 @@ public class ServerProtocolIO {
mergedContext.set(TimeoutLimits.class, clamped);
}
}
+
+ /**
+ * An {@link OutputStream} backed by a {@link ByteArrayOutputStream} that
aborts
+ * with an {@link IOException} the moment accumulated bytes would exceed
{@code limit}.
+ * The caller distinguishes an overflow abort from genuine I/O errors via
+ * {@link #overflowed()}.
+ */
+ private static final class BoundedOutputStream extends OutputStream {
+
+ private final int limit;
+ private final ByteArrayOutputStream buf;
+ private boolean overflowed = false;
+
+ BoundedOutputStream(int limit) {
+ this.limit = limit;
+ this.buf = new ByteArrayOutputStream(Math.min(limit, 8192));
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ if (buf.size() >= limit) {
+ overflowed = true;
+ throw new IOException("payload_overflow");
+ }
+ buf.write(b);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ if ((long) buf.size() + len > limit) {
+ overflowed = true;
+ throw new IOException("payload_overflow");
+ }
+ buf.write(b, off, len);
+ }
+
+ boolean overflowed() {
+ return overflowed;
+ }
+
+ byte[] toByteArray() {
+ return buf.toByteArray();
+ }
+ }
}
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/TikaPipesConfigTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/TikaPipesConfigTest.java
index 4e10fe1da6..ffa24c5879 100644
---
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/TikaPipesConfigTest.java
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/TikaPipesConfigTest.java
@@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test;
import org.apache.tika.TikaTest;
import org.apache.tika.config.loader.TikaJsonConfig;
import org.apache.tika.pipes.core.protocol.PipesMessage;
+import org.apache.tika.pipes.core.server.ServerProtocolIO;
public class TikaPipesConfigTest extends TikaTest {
@@ -63,10 +64,20 @@ public class TikaPipesConfigTest extends TikaTest {
}
@Test
- void testMaxIpcPayloadBytesRejectsNonPositive() {
+ void testMaxIpcPayloadBytesRejectsTooSmall() {
PipesConfig config = new PipesConfig();
+ // 0 and -1 are rejected (below MIN_FALLBACK_PAYLOAD_BYTES)
assertThrows(IllegalArgumentException.class, () ->
config.setMaxIpcPayloadBytes(0));
assertThrows(IllegalArgumentException.class, () ->
config.setMaxIpcPayloadBytes(-1));
+ // A small-but-positive value below the minimum is also rejected
+ int belowMin = ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES - 1;
+ if (belowMin > 0) {
+ assertThrows(IllegalArgumentException.class, () ->
config.setMaxIpcPayloadBytes(belowMin));
+ }
+ // A value at or above the minimum is accepted
+ int atMin = ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES;
+ config.setMaxIpcPayloadBytes(atMin);
+ assertEquals(atMin, config.getMaxIpcPayloadBytes());
}
@Test
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
index cc90f7765f..016f630ee4 100644
---
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/ServerProtocolIOTest.java
@@ -17,16 +17,37 @@
package org.apache.tika.pipes.core.server;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.apache.tika.config.TimeoutLimits;
+import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.pipes.core.emitter.EmitDataImpl;
+import org.apache.tika.pipes.core.protocol.PipesMessage;
+import org.apache.tika.pipes.core.protocol.PipesMessageType;
+import org.apache.tika.pipes.core.serialization.JsonPipesIpc;
-public class ServerProtocolIOTest {
+/**
+ * Unit tests for payload-size protection in {@link
ServerProtocolIO#writeFinished}
+ * and timeout-limit clamping in {@link
ServerProtocolIO#clampRequestTimeoutLimits}.
+ */
+class ServerProtocolIOTest {
private static final long MAX = 3_600_000L;
+ // ---- timeout clamping tests ----
+
@Test
public void testRequestLimitsOverCapAreClamped() {
ParseContext request = new ParseContext();
@@ -43,13 +64,9 @@ public class ServerProtocolIOTest {
@Test
public void testUnresolvedJsonRequestLimitsAlsoTriggerClamp() {
- // A request can carry timeout-limits as an unresolved JSON config;
after
- // resolveAll the value lives in the merged context's typed slot --
the clamp
- // must key off the request's json entry, not only its typed one.
ParseContext request = new ParseContext();
request.setJsonConfig("timeout-limits", "{\"totalTaskTimeoutMillis\":
9999999999}");
ParseContext merged = new ParseContext();
- // simulate post-resolveAll state
merged.set(TimeoutLimits.class, new TimeoutLimits(9_999_999_999L,
120_000));
ServerProtocolIO.clampRequestTimeoutLimits(request, merged, MAX);
@@ -59,8 +76,6 @@ public class ServerProtocolIOTest {
@Test
public void testServerConfigLimitsAreTrustedAndNeverClamped() {
- // Operator raised the total in the server's own tika-config; the
request carries
- // no limits, so the cap must not apply.
ParseContext request = new ParseContext();
ParseContext merged = new ParseContext();
merged.set(TimeoutLimits.class, new TimeoutLimits(7_200_000L,
120_000));
@@ -69,4 +84,278 @@ public class ServerProtocolIOTest {
assertEquals(7_200_000L,
merged.get(TimeoutLimits.class).getTotalTaskTimeoutMillis());
}
+
+ // ---- payload guard tests ----
+
+ /**
+ * Runs a writeFinished() call through a pair of piped streams, acting as
the
+ * "client" in a background thread: reads the FINISHED message using the
same
+ * {@code maxPayloadBytes} limit the server uses, sends ACK, and returns
the
+ * deserialized PipesResult.
+ */
+ private PipesResult exchange(PipesResult toWrite, int maxPayloadBytes)
throws Exception {
+ PipedOutputStream serverOutPipe = new PipedOutputStream();
+ PipedInputStream clientInPipe = new PipedInputStream(serverOutPipe,
1024 * 1024);
+ PipedOutputStream clientOutPipe = new PipedOutputStream();
+ PipedInputStream serverInPipe = new PipedInputStream(clientOutPipe,
1024);
+
+ AtomicReference<PipesResult> received = new AtomicReference<>();
+ AtomicReference<Exception> clientError = new AtomicReference<>();
+
+ Thread clientThread = new Thread(() -> {
+ try {
+ DataInputStream clientDis = new DataInputStream(clientInPipe);
+ DataOutputStream clientDos = new
DataOutputStream(clientOutPipe);
+
+ // Read with the same limit the server uses — mirrors
production PipesClient behaviour.
+ PipesMessage msg = PipesMessage.read(clientDis,
maxPayloadBytes);
+ assertEquals(PipesMessageType.FINISHED, msg.type());
+ received.set(JsonPipesIpc.fromBytes(msg.payload(),
PipesResult.class));
+ PipesMessage.ack().write(clientDos);
+ } catch (Exception e) {
+ clientError.set(e);
+ }
+ });
+ clientThread.setDaemon(true);
+ clientThread.start();
+
+ ServerProtocolIO io = new ServerProtocolIO(
+ new DataInputStream(serverInPipe),
+ new DataOutputStream(serverOutPipe),
+ maxPayloadBytes);
+ io.writeFinished(toWrite);
+
+ clientThread.join(5000);
+
+ if (clientError.get() != null) {
+ throw clientError.get();
+ }
+ assertNotNull(received.get(), "client never received a FINISHED
message");
+ return received.get();
+ }
+
+ /**
+ * A result whose serialized size is under the configured limit passes
through
+ * unchanged — original status is preserved.
+ */
+ @Test
+ void testSmallResultPassesThrough() throws Exception {
+ PipesResult original = new
PipesResult(PipesResult.RESULT_STATUS.PARSE_SUCCESS,
+ new EmitDataImpl("key", List.of(new Metadata())));
+
+ PipesResult returned = exchange(original,
PipesMessage.MAX_PAYLOAD_BYTES);
+
+ assertEquals(PipesResult.RESULT_STATUS.PARSE_SUCCESS,
returned.status());
+ }
+
+ /**
+ * When the serialized payload is one byte over the configured limit the
+ * BoundedOutputStream aborts serialization mid-stream and the server
returns
+ * PAYLOAD_LIMIT_EXCEEDED instead of writing the oversized frame.
+ */
+ @Test
+ void testPayloadOneByteTooLargeReturnPayloadLimitExceeded() throws
Exception {
+ Metadata m = new Metadata();
+ m.add("content", "a".repeat(500));
+ PipesResult big = new
PipesResult(PipesResult.RESULT_STATUS.PARSE_SUCCESS,
+ new EmitDataImpl("mykey", List.of(m)));
+
+ byte[] serialized = JsonPipesIpc.toBytes(big);
+ int tinyLimit = serialized.length - 1;
+
+ PipesResult returned = exchange(big, tinyLimit);
+
+ assertEquals(PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
returned.status());
+ }
+
+ /**
+ * A result whose serialized size clearly exceeds the configured limit
triggers
+ * PAYLOAD_LIMIT_EXCEEDED. Uses a fixed small limit so the test is
independent of
+ * the estimate formula.
+ */
+ @Test
+ void testLargePayloadReturnPayloadLimitExceeded() throws Exception {
+ Metadata m = new Metadata();
+ m.add("content", "x".repeat(10_000));
+ PipesResult result = new
PipesResult(PipesResult.RESULT_STATUS.PARSE_SUCCESS,
+ new EmitDataImpl("key", List.of(m)));
+
+ // The 10,000-char content serializes to ~10 KB; pick a limit well
below that.
+ int limit = 512;
+ assertTrue(JsonPipesIpc.toBytes(result).length > limit,
+ "test setup: serialized content must exceed limit");
+
+ PipesResult returned = exchange(result, limit);
+
+ assertEquals(PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
returned.status());
+ }
+
+ /**
+ * Status-only results (no emitData) always pass through unchanged.
+ */
+ @Test
+ void testStatusOnlyResultPassesThrough() throws Exception {
+ PipesResult statusOnly = new
PipesResult(PipesResult.RESULT_STATUS.FETCH_EXCEPTION,
+ "something went wrong");
+
+ PipesResult returned = exchange(statusOnly, 1024);
+
+ assertEquals(PipesResult.RESULT_STATUS.FETCH_EXCEPTION,
returned.status());
+ }
+
+ /**
+ * Regression for the "fallback-too-big" bug: when the configured limit
equals
+ * MIN_FALLBACK_PAYLOAD_BYTES (the tightest limit the constructor
accepts), the
+ * fallback PAYLOAD_LIMIT_EXCEEDED frame must still fit within that limit
so the
+ * client can read it with the same configured limit.
+ */
+ @Test
+ void testFallbackFitsWithinMinimumConfiguredLimit() throws Exception {
+ int limit = ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES;
+
+ Metadata m = new Metadata();
+ m.add("content", "x".repeat(10_000));
+ PipesResult result = new
PipesResult(PipesResult.RESULT_STATUS.PARSE_SUCCESS,
+ new EmitDataImpl("key", List.of(m)));
+
+ PipesResult returned = exchange(result, limit);
+
+ assertEquals(PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
returned.status());
+ }
+
+ /**
+ * When an EMIT_SUCCESS_PASSBACK result overflows the limit, the server
preserves
+ * the EMIT_SUCCESS_PASSBACK status rather than replacing it with
PAYLOAD_LIMIT_EXCEEDED,
+ * so the client does not re-emit content that was already emitted
server-side.
+ */
+ @Test
+ void testEmitSuccessPassbackStatusPreservedOnOverflow() throws Exception {
+ Metadata m = new Metadata();
+ m.add("content", "x".repeat(10_000));
+ PipesResult result = new
PipesResult(PipesResult.RESULT_STATUS.EMIT_SUCCESS_PASSBACK,
+ new EmitDataImpl("key", List.of(m)));
+
+ int limit = 512;
+ assertTrue(JsonPipesIpc.toBytes(result).length > limit,
+ "test setup: serialized content must exceed limit");
+
+ PipesResult returned = exchange(result, limit);
+
+ assertEquals(PipesResult.RESULT_STATUS.EMIT_SUCCESS_PASSBACK,
returned.status());
+ }
+
+ /**
+ * EMIT_SUCCESS_PARSE_EXCEPTION also means the content was already emitted
server-side,
+ * so an overflow must not downgrade it to a failure status -- that would
make the
+ * client record an emitted document as failed and a retry would emit it
twice.
+ */
+ @Test
+ void testEmitSuccessParseExceptionStatusPreservedOnOverflow() throws
Exception {
+ Metadata m = new Metadata();
+ m.add("content", "x".repeat(10_000));
+ PipesResult result =
+ new
PipesResult(PipesResult.RESULT_STATUS.EMIT_SUCCESS_PARSE_EXCEPTION,
+ new EmitDataImpl("key", List.of(m)));
+
+ int limit = 512;
+ assertTrue(JsonPipesIpc.toBytes(result).length > limit,
+ "test setup: serialized content must exceed limit");
+
+ PipesResult returned = exchange(result, limit);
+
+ assertEquals(PipesResult.RESULT_STATUS.EMIT_SUCCESS_PARSE_EXCEPTION,
returned.status());
+ }
+
+ /**
+ * The overflow can be the message itself -- EmitHandler accumulates
parse-exception
+ * stacks into it -- so the status-preserving retry must replace the
message, not
+ * carry it over.
+ */
+ @Test
+ void testOversizedMessageOnAlreadyEmittedStatusPreservesStatus() throws
Exception {
+ PipesResult result =
+ new
PipesResult(PipesResult.RESULT_STATUS.EMIT_SUCCESS_PARSE_EXCEPTION,
+ "stack".repeat(5_000));
+
+ int limit = 512;
+ assertTrue(JsonPipesIpc.toBytes(result).length > limit,
+ "test setup: serialized message must exceed limit");
+
+ PipesResult returned = exchange(result, limit);
+
+ assertEquals(PipesResult.RESULT_STATUS.EMIT_SUCCESS_PARSE_EXCEPTION,
returned.status());
+ }
+
+ // ---- writeCrash tests ----
+
+ /**
+ * Helper: calls writeCrash on the server side, reads the crash message
from the client side,
+ * sends ACK, and returns the deserialized stack trace string.
+ */
+ private String exchangeCrash(Throwable t, int maxPayloadBytes) throws
Exception {
+ PipedOutputStream serverOutPipe = new PipedOutputStream();
+ PipedInputStream clientInPipe = new PipedInputStream(serverOutPipe,
1024 * 1024);
+ PipedOutputStream clientOutPipe = new PipedOutputStream();
+ PipedInputStream serverInPipe = new PipedInputStream(clientOutPipe,
1024);
+
+ AtomicReference<String> received = new AtomicReference<>();
+ AtomicReference<Exception> clientError = new AtomicReference<>();
+
+ Thread clientThread = new Thread(() -> {
+ try {
+ DataInputStream clientDis = new DataInputStream(clientInPipe);
+ DataOutputStream clientDos = new
DataOutputStream(clientOutPipe);
+
+ PipesMessage msg = PipesMessage.read(clientDis,
maxPayloadBytes);
+ assertEquals(PipesMessageType.UNSPECIFIED_CRASH, msg.type());
+ received.set(JsonPipesIpc.fromBytes(msg.payload(),
String.class));
+ PipesMessage.ack().write(clientDos);
+ } catch (Exception e) {
+ clientError.set(e);
+ }
+ });
+ clientThread.setDaemon(true);
+ clientThread.start();
+
+ ServerProtocolIO io = new ServerProtocolIO(
+ new DataInputStream(serverInPipe),
+ new DataOutputStream(serverOutPipe),
+ maxPayloadBytes);
+ io.writeCrash(PipesMessageType.UNSPECIFIED_CRASH, t);
+
+ clientThread.join(5000);
+ if (clientError.get() != null) {
+ throw clientError.get();
+ }
+ assertNotNull(received.get(), "client never received a CRASH message");
+ return received.get();
+ }
+
+ /**
+ * A crash with a small stack trace passes through unchanged and the
client reads
+ * it within the configured limit.
+ */
+ @Test
+ void testCrashSmallPayloadPassesThrough() throws Exception {
+ RuntimeException ex = new RuntimeException("something failed");
+ String returned = exchangeCrash(ex, PipesMessage.MAX_PAYLOAD_BYTES);
+ assertTrue(returned.contains("something failed"));
+ }
+
+ /**
+ * A crash whose stack trace would overflow the limit falls back to an
empty string,
+ * not a truncated string that could still exceed the limit due to
multi-byte encoding.
+ * The client must be able to read the response with the same configured
limit.
+ */
+ @Test
+ void testCrashOversizedPayloadFallsBackToEmptyString() throws Exception {
+ // Build a large exception message that will serialize beyond a small
limit.
+ RuntimeException ex = new RuntimeException("x".repeat(10_000));
+ int limit = 512;
+
+ String returned = exchangeCrash(ex, limit);
+
+ // Empty string fallback: the trace was too large, we get an empty
payload.
+ assertEquals("", returned);
+ }
}