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 cbfeb5141c TIKA-4829 -- handle byte passing more cleanly (#3043)
cbfeb5141c is described below

commit cbfeb5141c73c2a86341779a7c9b86e10858b8f1
Author: Tim Allison <[email protected]>
AuthorDate: Fri Aug 21 21:24:27 2026 -0400

    TIKA-4829 -- handle byte passing more cleanly (#3043)
---
 CHANGES.txt                                        |   9 +-
 docs/modules/ROOT/pages/pipes/fetchers.adoc        |   2 +-
 .../ROOT/pages/using-tika/server/index.adoc        |   6 +-
 .../org/apache/tika/pipes/core/PipesClient.java    |  18 ++-
 .../org/apache/tika/pipes/core/PipesConfig.java    |   2 +-
 .../tika/pipes/core/fetcher/BytesFetcher.java      |   3 +-
 .../tika/pipes/core/fetcher/InlineBytes.java       |  11 +-
 .../protocol/PayloadLimitExceededException.java    |  13 +-
 .../serialization/FetchEmitTupleDeserializer.java  |  20 ++-
 .../serialization/FetchEmitTupleSerializer.java    |  15 +-
 .../pipes/core/serialization/JsonPipesIpc.java     |  11 +-
 .../pipes/core/serialization/PipesRequest.java     |  81 ++++++++++
 .../serialization/PipesRequestDeserializer.java    |  43 +++++
 .../core/serialization/PipesRequestSerializer.java |  42 +++++
 .../tika/pipes/core/server/ConnectionHandler.java  |  10 +-
 .../apache/tika/pipes/core/server/PipesServer.java |  10 +-
 .../tika/pipes/core/PipesClientInterruptTest.java  |  53 -------
 .../pipes/core/PipesClientPayloadLimitTest.java    |  94 +++++++++++
 .../tika/pipes/core/SentinelServerManager.java     |  75 +++++++++
 .../core/serialization/InlineBytesWireTest.java    | 173 +++++++++++++++++++++
 .../serialization/SystemComponentIdWireTest.java   |   9 +-
 .../WireRestrictedFetchEmitTupleTest.java          |   9 +-
 .../pipes/core/serialization/WireTestUtil.java}    |  26 ++--
 .../tika/pipes/fork/PipesForkParserTest.java       |   2 +
 24 files changed, 627 insertions(+), 110 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 5dde33b1b4..1b59350a8c 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,10 @@
-Release 4.1.0 - ???
+Release 4.1.0 - unreleased
+
+   * Pipes IPC: carry inline document bytes as a raw binary field beside the
+     tuple in the request envelope -- never inside the tuple or its
+     ParseContext -- and disable Smile's 7-bit binary encoding. Tuple JSON
+     serialized by 4.0.0 with an "inline-bytes" parse-context entry no longer
+     loads; it is rejected with a tailored message (TIKA-4829).
 
    * Digesting embedded documents no longer buffers each embedded object to a
      temp file. Zip entries are re-read from the parent archive on rewind, and
@@ -34,7 +40,6 @@ Release 4.1.0 - ???
      detection but also gains the attachments. Disable via
      "raw-tiff-parser": {"extractPreviews": false} (TIKA-4824).
 
-
 Release 4.0.0 - 8/18/2026
 
   This section is the complete delta from 3.x. It includes everything first
diff --git a/docs/modules/ROOT/pages/pipes/fetchers.adoc 
b/docs/modules/ROOT/pages/pipes/fetchers.adoc
index 64608a8a48..8ec15ae40f 100644
--- a/docs/modules/ROOT/pages/pipes/fetchers.adoc
+++ b/docs/modules/ROOT/pages/pipes/fetchers.adoc
@@ -70,7 +70,7 @@ IDs you configure yourself may contain only letters, digits, 
`.`, `_` and `-`, a
 
 A host that already holds a document -- tika-server serving `/tika`, or an 
application calling `PipesForkParser` with an in-memory stream -- does not have 
to write it to disk for the forked worker to read. Content at or below 
xref:pipes/configuration.adoc#payload-limits[`maxInlineBytes`] travels inside 
the request and is served in the worker by the built-in `\_\_bytes` fetcher; 
larger content is written once to a file instead. A stream that is already 
backed by a file always keeps its file.
 
-This is automatic and needs no configuration. `\_\_bytes` is not declarable in 
a config file and cannot be named by a request.
+This is automatic and needs no configuration. `\_\_bytes` is not declarable in 
a config file and cannot be named by a request. The payload itself has no 
request-suppliable form either: it travels beside the tuple in the host's 
parent-to-worker request envelope, never as a tuple field (`inlineBytes` in a 
request tuple is rejected). The IPC wire format is internal and 
same-version-only; parent and worker run from the same classpath by default, 
and a version-skewed worker classpath (a `-cp` [...]
 
 [#plugins]
 == Available Fetchers
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc 
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index f7c8f6c024..a156fa187f 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -156,7 +156,11 @@ curl -X POST http://localhost:9998/async -H "Content-Type: 
application/json" \
 
 A tuple's fields are `id`, `fetcher`, `fetchKey`, `emitter`, `emitKey`, and 
optionally
 `fetchRangeStart`, `fetchRangeEnd`, `metadata`, `parse-context` and 
`onParseException`. Any other
-field is a `400` — there is no silent tolerance for a typo. The 
`{"tuples":[...]}` envelope is
+field is a `400` — there is no silent tolerance for a typo. There is no field 
for content:
+`inlineBytes` draws a tailored `400`, because inline content is
+xref:pipes/fetchers.adoc#reserved-ids[how the server feeds its own workers], 
not something a
+tuple can carry. To parse content you already hold, PUT it to `/tika` or 
`/rmeta`, which inline
+it for you. The `{"tuples":[...]}` envelope is
 required on `/async`; a bare array is rejected.
 
 === Best practices
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 d0db707baf..7b2b34a974 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
@@ -49,6 +49,7 @@ import 
org.apache.tika.pipes.core.protocol.PayloadLimitExceededException;
 import org.apache.tika.pipes.core.protocol.PipesMessage;
 import org.apache.tika.pipes.core.protocol.PipesMessageType;
 import org.apache.tika.pipes.core.serialization.JsonPipesIpc;
+import org.apache.tika.pipes.core.serialization.PipesRequest;
 import org.apache.tika.pipes.core.server.IntermediateResult;
 import org.apache.tika.utils.ExceptionUtils;
 import org.apache.tika.utils.StringUtils;
@@ -240,6 +241,14 @@ public class PipesClient implements Closeable {
             serverManager.connectionAbandoned();
             closeConnection();
             throw e;
+        } catch (PayloadLimitExceededException e) {
+            // Only writeTask's pre-send check throws this here (waitForServer 
handles its
+            // own); nothing was written, so the connection stays in sync -- 
keep it.
+            LOG.warn("clientId={}: request too large for id={}: {}", 
pipesClientId, t.getId(),
+                    e.getMessage());
+            return buildFatalResult(t.getId(), t.getEmitKey(),
+                    PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
+                    intermediateResult.get(), e.getMessage());
         } catch (Exception e) {
             LOG.error("exception waiting for server to complete task: {} ", 
t.getId(), e);
             closeConnection();
@@ -340,7 +349,14 @@ public class PipesClient implements Closeable {
             throw new IOException("connection closed");
         }
         LOG.debug("pipesClientId={}: sending NEW_REQUEST for id={}", 
pipesClientId, t.getId());
-        byte[] bytes = JsonPipesIpc.toBytes(t);
+        byte[] bytes = JsonPipesIpc.toBytes(PipesRequest.of(t));
+        // Fail fast before sending: the server would refuse the frame anyway, 
but only by
+        // dying or dropping the connection, misreported as a crash.
+        if (bytes.length > maxIpcPayloadBytes) {
+            throw new PayloadLimitExceededException("serialized request for 
id=" + t.getId()
+                    + " is " + bytes.length + " bytes, over 
maxIpcPayloadBytes="
+                    + maxIpcPayloadBytes + "; raise maxIpcPayloadBytes or 
shrink the request");
+        }
         PipesMessage.newRequest(bytes).write(tuple.output);
     }
 
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 c33512fc77..a694d04b44 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
@@ -601,7 +601,7 @@ public class PipesConfig {
      * (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.
+     * client-side before sending, reported as {@code PAYLOAD_LIMIT_EXCEEDED}.
      * <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
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/BytesFetcher.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/BytesFetcher.java
index 4a85f80210..7d86b0a314 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/BytesFetcher.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/BytesFetcher.java
@@ -29,7 +29,8 @@ import org.apache.tika.plugins.ExtensionConfig;
 import org.apache.tika.utils.StringUtils;
 
 /**
- * Serves the bytes a caller put in the {@link InlineBytes} parse-context 
entry, so a host that
+ * Serves the bytes in the {@link InlineBytes} parse-context entry -- set by 
the caller
+ * in-process, or planted by the server from the {@code PipesRequest} envelope 
-- so a host that
  * already holds the content does not have to spool it to disk purely to hand 
it across the
  * process boundary.
  * <p>
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/InlineBytes.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/InlineBytes.java
index 2981847f49..de834d5139 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/InlineBytes.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/InlineBytes.java
@@ -19,18 +19,19 @@ package org.apache.tika.pipes.core.fetcher;
 import java.io.Serializable;
 import java.util.Arrays;
 
-import org.apache.tika.annotation.TikaComponent;
-
 /**
  * Document bytes carried in the {@code ParseContext} instead of fetched from 
a source, for
  * callers that already hold the content and would otherwise have to spool it 
to disk just to
  * hand it to the forked worker.
  * <p>
  * Read by {@link BytesFetcher}, which the tuple selects with fetcher id
- * {@link BytesFetcher#FETCHER_ID}. The IPC is Smile, so this rides as native 
binary rather than
- * base64; it counts against {@code maxIpcPayloadBytes} like any other part of 
the request.
+ * {@link BytesFetcher#FETCHER_ID}. In-process only: deliberately not a 
registered component,
+ * so no serialized form of it exists and serialization refuses loudly. On the 
IPC wire the
+ * payload travels beside
+ * the tuple in {@code PipesRequest} (which lifts it out on the parent and 
plants it back into
+ * the worker's context on the child); it counts against {@code 
maxIpcPayloadBytes} like any
+ * other part of the request. A request can supply it in no form at all.
  */
-@TikaComponent(name = "inline-bytes", spi = false)
 public class InlineBytes implements Serializable {
 
     private static final long serialVersionUID = 1L;
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/protocol/PayloadLimitExceededException.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/protocol/PayloadLimitExceededException.java
index 739cf51921..b3fc6e0bfd 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/protocol/PayloadLimitExceededException.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/protocol/PayloadLimitExceededException.java
@@ -19,13 +19,14 @@ package org.apache.tika.pipes.core.protocol;
 import java.io.IOException;
 
 /**
- * Thrown when an incoming IPC payload's declared length exceeds the 
configured limit
+ * Thrown when an IPC payload exceeds the configured limit
  * (see {@link org.apache.tika.pipes.core.PipesConfig#getMaxIpcPayloadBytes()};
- * default {@link PipesMessage#MAX_PAYLOAD_BYTES}). The payload bytes were not 
consumed,
- * so the stream is desynchronized and the connection must be closed. With a 
shared server
- * the process keeps running (only this connection ends); with the default 
per-client forked
- * server the process may still exit on the failed write, and the client 
reconnects on the
- * next task.
+ * default {@link PipesMessage#MAX_PAYLOAD_BYTES}). On the read side the 
payload bytes were
+ * not consumed, so the stream is desynchronized and the connection must be 
closed: with a
+ * shared server the process keeps running (only this connection ends); with 
the default
+ * per-client forked server the process may still exit on the failed write, 
and the client
+ * reconnects on the next task. On the send side ({@code PipesClient} pre-send 
check) nothing
+ * was written and the connection stays usable.
  */
 public class PayloadLimitExceededException extends IOException {
     public PayloadLimitExceededException(String message) {
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
index 07bc0c6f09..0b656dda94 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
@@ -32,6 +32,7 @@ import java.io.IOException;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 
 import com.fasterxml.jackson.core.JacksonException;
 import com.fasterxml.jackson.core.JsonParser;
@@ -49,6 +50,9 @@ import 
org.apache.tika.serialization.serdes.ParseContextDeserializer;
 
 public class FetchEmitTupleDeserializer extends 
JsonDeserializer<FetchEmitTuple> {
 
+    /** The parse-context name InlineBytes was registered under in 4.0.0. */
+    static final String LEGACY_INLINE_BYTES_ENTRY = "inline-bytes";
+
     private static final Set<String> KNOWN_KEYS = Set.of(
             ID, FETCHER, FETCH_KEY, EMITTER, EMIT_KEY, FETCH_RANGE_START, 
FETCH_RANGE_END,
             METADATA_KEY, PARSE_CONTEXT, ON_PARSE_EXCEPTION);
@@ -80,6 +84,20 @@ public class FetchEmitTupleDeserializer extends 
JsonDeserializer<FetchEmitTuple>
     @Override
     public FetchEmitTuple deserialize(JsonParser jsonParser, 
DeserializationContext deserializationContext) throws IOException, 
JacksonException {
         JsonNode root = jsonParser.readValueAsTree();
+        // Both checked before rejectUnknownKeys so they get tailored messages.
+        if (root.has(PipesRequest.INLINE_BYTES)) {
+            throw new IOException("'" + PipesRequest.INLINE_BYTES
+                    + "' is not a FetchEmitTuple field; content travels 
outside the tuple, and"
+                    + " only on the host's internal IPC. For tika-server, PUT 
content you"
+                    + " already hold to /tika or /rmeta, which inline it for 
you.");
+        }
+        if (root.path(PARSE_CONTEXT).has(LEGACY_INLINE_BYTES_ENTRY)) {
+            // 4.0.0 serialized this entry; the generic "check for a typo" 
would mislead upgraders.
+            throw new IOException("'" + LEGACY_INLINE_BYTES_ENTRY + "' is no 
longer a serializable"
+                    + " parse-context entry (4.0.0 wrote it as base64): 
content travels outside"
+                    + " the tuple, and only on the host's internal IPC. For 
tika-server, PUT"
+                    + " content you already hold to /tika or /rmeta, which 
inline it for you.");
+        }
         rejectUnknownKeys(root);
 
         String id = readVal(ID, root, null, true);
@@ -124,7 +142,7 @@ public class FetchEmitTupleDeserializer extends 
JsonDeserializer<FetchEmitTuple>
             String name = it.next();
             if (!KNOWN_KEYS.contains(name)) {
                 throw new IOException("Unrecognized FetchEmitTuple field '" + 
name
-                        + "'. Check for a typo; known fields are " + 
KNOWN_KEYS + ".");
+                        + "'. Check for a typo; known fields are " + new 
TreeSet<>(KNOWN_KEYS) + ".");
             }
         }
     }
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
index 9dc06b560c..608ab15f37 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
@@ -25,7 +25,9 @@ import com.fasterxml.jackson.core.JsonGenerator;
 import com.fasterxml.jackson.databind.JsonSerializer;
 import com.fasterxml.jackson.databind.SerializerProvider;
 
+import org.apache.tika.parser.ParseContext;
 import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.core.fetcher.InlineBytes;
 import org.apache.tika.utils.StringUtils;
 
 public class FetchEmitTupleSerializer extends JsonSerializer<FetchEmitTuple> {
@@ -57,8 +59,17 @@ public class FetchEmitTupleSerializer extends 
JsonSerializer<FetchEmitTuple> {
             jsonGenerator.writeObjectField(METADATA_KEY, t.getMetadata());
         }
         jsonGenerator.writeStringField(ON_PARSE_EXCEPTION, 
t.getOnParseException().name().toLowerCase(Locale.US));
-        if (!t.getParseContext().isEmpty()) {
-            jsonGenerator.writeObjectField(PARSE_CONTEXT, t.getParseContext());
+        ParseContext parseContext = t.getParseContext();
+        // Tailored: ParseContextSerializer's generic refusal suggests 
registering the
+        // component -- for InlineBytes, exactly the forbidden fix.
+        if (parseContext.get(InlineBytes.class) != null) {
+            throw new IOException("A FetchEmitTuple whose ParseContext holds 
InlineBytes has no"
+                    + " serialized form: inline content is in-process only 
and, on the pipes IPC,"
+                    + " travels beside the tuple in the request envelope. 
Remove the InlineBytes"
+                    + " entry before serializing.");
+        }
+        if (!parseContext.isEmpty()) {
+            jsonGenerator.writeObjectField(PARSE_CONTEXT, parseContext);
         }
         jsonGenerator.writeEndObject();
     }
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 d35f45a66d..6f5cfbe074 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
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.core.StreamReadConstraints;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.module.SimpleModule;
 import com.fasterxml.jackson.dataformat.smile.SmileFactory;
+import com.fasterxml.jackson.dataformat.smile.SmileGenerator;
 
 import org.apache.tika.config.loader.TikaObjectMapperFactory;
 import org.apache.tika.pipes.api.FetchEmitTuple;
@@ -42,8 +43,12 @@ public class JsonPipesIpc {
     private static final ObjectMapper OBJECT_MAPPER;
 
     static {
-        // Use SmileFactory for binary format - more compact and faster than 
text JSON
-        SmileFactory smileFactory = new SmileFactory();
+        // Use SmileFactory for binary format - more compact and faster than 
text JSON.
+        // 7-bit binary encoding (the Smile default) costs +14% size and a 
transcode pass
+        // on every binary payload; this is a private same-version channel, so 
write raw.
+        SmileFactory smileFactory = SmileFactory.builder()
+                .disable(SmileGenerator.Feature.ENCODE_BINARY_AS_7BIT)
+                .build();
 
         // Configure stream constraints for large content (e.g., 30MB+ 
documents)
         // Default Jackson limit is 20MB which is too small for IPC with large 
documents
@@ -60,6 +65,8 @@ public class JsonPipesIpc {
         pipesModule.addSerializer(FetchEmitTuple.class, new 
FetchEmitTupleSerializer());
         // Parent-to-child IPC: the host builds these tuples itself and they 
name __ components.
         pipesModule.addDeserializer(FetchEmitTuple.class, 
FetchEmitTupleDeserializer.internal());
+        pipesModule.addSerializer(PipesRequest.class, new 
PipesRequestSerializer());
+        pipesModule.addDeserializer(PipesRequest.class, new 
PipesRequestDeserializer());
         pipesModule.addSerializer(EmitData.class, new EmitDataSerializer());
         pipesModule.addDeserializer(EmitDataImpl.class, new 
EmitDataDeserializer());
         pipesModule.addSerializer(PipesResult.class, new 
PipesResultSerializer());
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
new file mode 100644
index 0000000000..15745cb41a
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.serialization;
+
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.core.fetcher.InlineBytes;
+
+/**
+ * The NEW_REQUEST envelope on the parent-to-child IPC: the tuple, plus the 
optional inline
+ * document payload beside it. Bytes are data, not tuple state -- a {@link 
FetchEmitTuple} and
+ * its ParseContext are never serialized with content in them ({@code 
FetchEmitTupleSerializer}
+ * refuses loudly if an {@link InlineBytes} slips through).
+ * <p>
+ * {@link #of} lifts the payload out of the caller's context on the parent;
+ * {@link #applyTo} plants it into the worker's merged context on the child, 
where
+ * {@link org.apache.tika.pipes.core.fetcher.BytesFetcher} reads it.
+ */
+public final class PipesRequest {
+
+    /** Wire name of the payload field; not a FetchEmitTuple field. */
+    static final String INLINE_BYTES = "inlineBytes";
+
+    static final String TUPLE = "tuple";
+
+    private final FetchEmitTuple tuple;
+    private final byte[] inlineBytes;
+
+    PipesRequest(FetchEmitTuple tuple, byte[] inlineBytes) {
+        this.tuple = tuple;
+        this.inlineBytes = inlineBytes;
+    }
+
+    /**
+     * Wraps {@code t} for the wire, lifting any {@link InlineBytes} out of 
its ParseContext.
+     * The caller's live context is never mutated; the stripped copy exists 
only for
+     * serialization.
+     */
+    public static PipesRequest of(FetchEmitTuple t) {
+        ParseContext ctx = t.getParseContext();
+        InlineBytes inline = ctx == null ? null : ctx.get(InlineBytes.class);
+        if (inline == null) {
+            return new PipesRequest(t, null);
+        }
+        ParseContext copy = new ParseContext();
+        copy.copyFrom(ctx);
+        copy.set(InlineBytes.class, null);
+        FetchEmitTuple stripped = new FetchEmitTuple(t.getId(), 
t.getFetchKey(), t.getEmitKey(),
+                t.getMetadata(), copy, t.getOnParseException());
+        return new PipesRequest(stripped, inline.getBytes());
+    }
+
+    /** Plants the payload into the worker's context for BytesFetcher; no-op 
without one. */
+    public void applyTo(ParseContext mergedContext) {
+        if (inlineBytes != null) {
+            mergedContext.set(InlineBytes.class, new InlineBytes(inlineBytes));
+        }
+    }
+
+    public FetchEmitTuple getTuple() {
+        return tuple;
+    }
+
+    public byte[] getInlineBytes() {
+        return inlineBytes;
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequestDeserializer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequestDeserializer.java
new file mode 100644
index 0000000000..a6970814dd
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequestDeserializer.java
@@ -0,0 +1,43 @@
+/*
+ * 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.serialization;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import org.apache.tika.pipes.api.FetchEmitTuple;
+
+class PipesRequestDeserializer extends JsonDeserializer<PipesRequest> {
+
+    @Override
+    public PipesRequest deserialize(JsonParser p, DeserializationContext ctxt)
+            throws IOException {
+        JsonNode root = p.readValueAsTree();
+        JsonNode tupleNode = root.get(PipesRequest.TUPLE);
+        if (tupleNode == null) {
+            throw new IOException("PipesRequest is missing its '" + 
PipesRequest.TUPLE + "' field");
+        }
+        FetchEmitTuple tuple = p.getCodec().treeToValue(tupleNode, 
FetchEmitTuple.class);
+        JsonNode bytesNode = root.get(PipesRequest.INLINE_BYTES);
+        byte[] inlineBytes = bytesNode == null ? null : 
bytesNode.binaryValue();
+        return new PipesRequest(tuple, inlineBytes);
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequestSerializer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequestSerializer.java
new file mode 100644
index 0000000000..b350770073
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequestSerializer.java
@@ -0,0 +1,42 @@
+/*
+ * 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.serialization;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+/**
+ * Payload as a raw top-level binary field beside the tuple: on the Smile IPC 
this avoids both
+ * base64 (+33%) and 7-bit (+14%) inflation, and keeps content out of the 
tuple's serialized form
+ * entirely.
+ */
+class PipesRequestSerializer extends JsonSerializer<PipesRequest> {
+
+    @Override
+    public void serialize(PipesRequest r, JsonGenerator gen, 
SerializerProvider provider)
+            throws IOException {
+        gen.writeStartObject();
+        gen.writeObjectField(PipesRequest.TUPLE, r.getTuple());
+        if (r.getInlineBytes() != null) {
+            gen.writeBinaryField(PipesRequest.INLINE_BYTES, 
r.getInlineBytes());
+        }
+        gen.writeEndObject();
+    }
+}
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 19808fce9f..b1b7c1861e 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
@@ -52,6 +52,7 @@ import org.apache.tika.pipes.core.PipesConfig;
 import org.apache.tika.pipes.core.protocol.PipesMessage;
 import org.apache.tika.pipes.core.protocol.PipesMessageType;
 import org.apache.tika.pipes.core.serialization.JsonPipesIpc;
+import org.apache.tika.pipes.core.serialization.PipesRequest;
 import org.apache.tika.serialization.ParseContextUtils;
 
 /**
@@ -151,11 +152,13 @@ public class ConnectionHandler implements Runnable, 
Closeable {
                         intermediateResult.clear();
                         CountDownLatch countDownLatch = new CountDownLatch(1);
 
+                        PipesRequest pipesRequest;
                         FetchEmitTuple fetchEmitTuple;
                         try {
-                            fetchEmitTuple = 
JsonPipesIpc.fromBytes(msg.payload(), FetchEmitTuple.class);
+                            pipesRequest = 
JsonPipesIpc.fromBytes(msg.payload(), PipesRequest.class);
+                            fetchEmitTuple = pipesRequest.getTuple();
                         } catch (IOException e) {
-                            LOG.error("handlerId={}: problem deserializing 
FetchEmitTuple", handlerId, e);
+                            LOG.error("handlerId={}: problem deserializing 
PipesRequest", handlerId, e);
                             handleCrash(PipesMessageType.UNSPECIFIED_CRASH, 
"unknown", e);
                             return; // connection is unsalvageable after 
deserialization failure
                         }
@@ -167,6 +170,9 @@ public class ConnectionHandler implements Runnable, 
Closeable {
                             ServerProtocolIO.clampRequestTimeoutLimits(
                                     fetchEmitTuple.getParseContext(), 
mergedContext,
                                     
pipesConfig.getMaxTotalTaskTimeoutMillis());
+                            // After resolveAll: the payload is typed runtime 
state for
+                            // BytesFetcher, never a resolvable config entry.
+                            pipesRequest.applyTo(mergedContext);
                             // Installed here, before submit, so the worker 
thread's own
                             // ParseTimeout.getOrCreate(mergedContext) call 
(inside CompositeParser)
                             // sees this instance rather than racing to 
install its own.
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 c359f89227..ee14e103f5 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
@@ -73,6 +73,7 @@ import org.apache.tika.pipes.core.protocol.PipesMessage;
 import org.apache.tika.pipes.core.protocol.PipesMessageType;
 import org.apache.tika.pipes.core.protocol.ShutDownReceivedException;
 import org.apache.tika.pipes.core.serialization.JsonPipesIpc;
+import org.apache.tika.pipes.core.serialization.PipesRequest;
 import org.apache.tika.plugins.ExtensionConfig;
 import org.apache.tika.plugins.TikaPluginManager;
 import org.apache.tika.sax.ContentHandlerFactory;
@@ -414,11 +415,13 @@ public class PipesServer implements AutoCloseable {
                         intermediateResult.clear();
                         CountDownLatch countDownLatch = new CountDownLatch(1);
 
+                        PipesRequest pipesRequest;
                         FetchEmitTuple fetchEmitTuple;
                         try {
-                            fetchEmitTuple = 
JsonPipesIpc.fromBytes(msg.payload(), FetchEmitTuple.class);
+                            pipesRequest = 
JsonPipesIpc.fromBytes(msg.payload(), PipesRequest.class);
+                            fetchEmitTuple = pipesRequest.getTuple();
                         } catch (IOException e) {
-                            LOG.error("problem deserializing FetchEmitTuple", 
e);
+                            LOG.error("problem deserializing PipesRequest", e);
                             handleCrash(PipesMessageType.UNSPECIFIED_CRASH, 
"unknown", e);
                             break; // unreachable after handleCrash/exit, but 
needed for compilation
                         }
@@ -431,6 +434,9 @@ public class PipesServer implements AutoCloseable {
                             ServerProtocolIO.clampRequestTimeoutLimits(
                                     fetchEmitTuple.getParseContext(), 
mergedContext,
                                     
pipesConfig.getMaxTotalTaskTimeoutMillis());
+                            // After resolveAll: the payload is typed runtime 
state for
+                            // BytesFetcher, never a resolvable config entry.
+                            pipesRequest.applyTo(mergedContext);
                             // Installed here, before submit, so the worker 
thread's own
                             // ParseTimeout.getOrCreate(mergedContext) call 
(inside CompositeParser)
                             // sees this instance rather than racing to 
install its own.
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientInterruptTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientInterruptTest.java
index a7a3e1be53..48a7336934 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientInterruptTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientInterruptTest.java
@@ -224,57 +224,4 @@ public class PipesClientInterruptTest {
         }
     }
 
-    /**
-     * Points the client at the scripted server; no forked process anywhere.
-     */
-    private static final class SentinelServerManager implements ServerManager {
-        private final int port;
-        private volatile boolean abandoned;
-
-        private SentinelServerManager(int port) {
-            this.port = port;
-        }
-
-        @Override
-        public void connectionAbandoned() {
-            abandoned = true;
-        }
-
-        @Override
-        public int getPort() {
-            return port;
-        }
-
-        @Override
-        public void ensureRunning() {
-            // the scripted server is already listening
-        }
-
-        @Override
-        public Socket connect(int socketTimeoutMs) throws IOException {
-            Socket socket = new Socket("localhost", port);
-            socket.setSoTimeout(socketTimeoutMs);
-            return socket;
-        }
-
-        @Override
-        public void shutdown() {
-            // nothing to shut down
-        }
-
-        @Override
-        public boolean isRunning() {
-            return true;
-        }
-
-        @Override
-        public java.nio.file.Path getTempDirectory() {
-            return null;
-        }
-
-        @Override
-        public void close() {
-            // nothing to close
-        }
-    }
 }
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientPayloadLimitTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientPayloadLimitTest.java
new file mode 100644
index 0000000000..67b62a50f3
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientPayloadLimitTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.pipes.api.emitter.EmitKey;
+import org.apache.tika.pipes.api.fetcher.FetchKey;
+import org.apache.tika.pipes.core.protocol.PipesMessage;
+import org.apache.tika.pipes.core.server.ServerProtocolIO;
+
+public class PipesClientPayloadLimitTest {
+
+    /**
+     * A request whose serialized form exceeds maxIpcPayloadBytes must be 
refused before
+     * sending -- a clean PAYLOAD_LIMIT_EXCEEDED, not a worker death 
misreported as a crash --
+     * and must leave the connection usable.
+     */
+    @Test
+    @Timeout(45)
+    public void oversizedRequestFailsFastWithoutSending() throws Exception {
+        try (ServerSocket serverSocket = new ServerSocket(0)) {
+            CountDownLatch connectionClosed = new CountDownLatch(1);
+            Thread sentinel = new Thread(() -> 
runReadyOnlyServer(serverSocket, connectionClosed));
+            sentinel.setDaemon(true);
+            sentinel.start();
+
+            PipesConfig pipesConfig = new PipesConfig();
+            
pipesConfig.setMaxIpcPayloadBytes(ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES);
+            SentinelServerManager manager = new 
SentinelServerManager(serverSocket.getLocalPort());
+            try (PipesClient client = new PipesClient(pipesConfig, manager)) {
+                Metadata metadata = new Metadata();
+                metadata.set("oversized", "x".repeat(10_000));
+                PipesResult result = client.process(new 
FetchEmitTuple("payload-limit-test",
+                        new FetchKey("fetcher", "key"), new EmitKey(), 
metadata,
+                        new ParseContext(), 
FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+
+                assertEquals(PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED, 
result.status(),
+                        "expected client-side refusal, got: " + result.status()
+                                + " / " + result.message());
+                assertTrue(result.message().contains("maxIpcPayloadBytes"),
+                        "message should name the limit, got: " + 
result.message());
+                assertFalse(manager.abandoned, "nothing was sent; no reason to 
abandon");
+                assertFalse(connectionClosed.await(300, TimeUnit.MILLISECONDS),
+                        "nothing was sent; the connection must stay usable");
+            }
+        }
+    }
+
+    /** Accepts one connection, sends READY, then just holds the socket open. 
*/
+    private static void runReadyOnlyServer(ServerSocket serverSocket,
+            CountDownLatch connectionClosed) {
+        try (Socket socket = serverSocket.accept();
+                DataInputStream in = new 
DataInputStream(socket.getInputStream());
+                DataOutputStream out = new 
DataOutputStream(socket.getOutputStream())) {
+            PipesMessage.ready().write(out);
+            PipesMessage.read(in);
+        } catch (IOException e) {
+            // EOF or reset: the connection is gone
+        }
+        connectionClosed.countDown();
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/SentinelServerManager.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/SentinelServerManager.java
new file mode 100644
index 0000000000..7007072b51
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/SentinelServerManager.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.core;
+
+import java.io.IOException;
+import java.net.Socket;
+import java.nio.file.Path;
+
+/**
+ * Points a {@link PipesClient} at a scripted in-test server; no forked 
process anywhere.
+ */
+final class SentinelServerManager implements ServerManager {
+    private final int port;
+    volatile boolean abandoned;
+
+    SentinelServerManager(int port) {
+        this.port = port;
+    }
+
+    @Override
+    public void connectionAbandoned() {
+        abandoned = true;
+    }
+
+    @Override
+    public int getPort() {
+        return port;
+    }
+
+    @Override
+    public void ensureRunning() {
+        // the scripted server is already listening
+    }
+
+    @Override
+    public Socket connect(int socketTimeoutMs) throws IOException {
+        Socket socket = new Socket("localhost", port);
+        socket.setSoTimeout(socketTimeoutMs);
+        return socket;
+    }
+
+    @Override
+    public void shutdown() {
+        // nothing to shut down
+    }
+
+    @Override
+    public boolean isRunning() {
+        return true;
+    }
+
+    @Override
+    public Path getTempDirectory() {
+        return null;
+    }
+
+    @Override
+    public void close() {
+        // nothing to close
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/InlineBytesWireTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/InlineBytesWireTest.java
new file mode 100644
index 0000000000..4d88c6a4b0
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/InlineBytesWireTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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.serialization;
+
+import static org.apache.tika.pipes.core.serialization.WireTestUtil.root;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.Random;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.api.ParseMode;
+import org.apache.tika.pipes.api.emitter.EmitKey;
+import org.apache.tika.pipes.api.fetcher.FetchKey;
+import org.apache.tika.pipes.core.fetcher.BytesFetcher;
+import org.apache.tika.pipes.core.fetcher.InlineBytes;
+import org.apache.tika.serialization.ParseContextUtils;
+import org.apache.tika.serialization.serdes.ParseContextDeserializer;
+
+/**
+ * The inline payload travels beside the tuple in the {@link PipesRequest} 
envelope -- never
+ * inside the tuple or its serialized ParseContext, in any format.
+ */
+public class InlineBytesWireTest {
+
+    private static byte[] payload(int size) {
+        byte[] b = new byte[size];
+        new Random(17).nextBytes(b);
+        return b;
+    }
+
+    private static FetchEmitTuple tuple(byte[] payload) {
+        ParseContext ctx = new ParseContext();
+        ctx.set(InlineBytes.class, new InlineBytes(payload));
+        ctx.set(ParseMode.class, ParseMode.RMETA);
+        return new FetchEmitTuple("t", new FetchKey(BytesFetcher.FETCHER_ID, 
"doc.bin"),
+                EmitKey.NO_EMIT, new Metadata(), ctx);
+    }
+
+    @Test
+    public void ipcRoundTripPreservesPayload() throws Exception {
+        byte[] payload = payload(100_000);
+        FetchEmitTuple t = tuple(payload);
+        PipesRequest request = PipesRequest.of(t);
+        // lifting the payload into the envelope must not mutate the caller's 
live context
+        assertNotNull(t.getParseContext().get(InlineBytes.class));
+
+        byte[] wire = JsonPipesIpc.toBytes(request);
+        PipesRequest back = JsonPipesIpc.fromBytes(wire, PipesRequest.class);
+        // the child's path: merge, resolve, plant, fetch
+        ParseContext merged = new ParseContext();
+        merged.copyFrom(back.getTuple().getParseContext());
+        ParseContextUtils.resolveAll(merged, getClass().getClassLoader());
+        back.applyTo(merged);
+        assertEquals(ParseMode.RMETA, merged.get(ParseMode.class));
+
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis = new BytesFetcher().fetch("doc.bin", 
metadata, merged)) {
+            assertArrayEquals(payload, tis.readAllBytes());
+        }
+        assertEquals("doc.bin", 
metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+    }
+
+    @Test
+    public void wireCarriesRawBinary() throws Exception {
+        byte[] payload = payload(1_000_000);
+        byte[] wire = JsonPipesIpc.toBytes(PipesRequest.of(tuple(payload)));
+        // raw binary: no base64 (+33%) and no Smile 7-bit encoding (+14%)
+        assertTrue(wire.length >= payload.length, "wire shorter than 
payload?");
+        assertTrue(wire.length < payload.length + 1024,
+                "payload not raw on the wire: " + wire.length + " bytes for " 
+ payload.length);
+    }
+
+    @Test
+    public void payloadStaysOutOfTheTuple() throws Exception {
+        byte[] wire = 
JsonPipesIpc.toBytes(PipesRequest.of(tuple(payload(1000))));
+        PipesRequest back = JsonPipesIpc.fromBytes(wire, PipesRequest.class);
+        
assertFalse(back.getTuple().getParseContext().hasJsonConfig("inline-bytes"),
+                "payload leaked into the lazy-config path");
+        assertNull(back.getTuple().getParseContext().get(InlineBytes.class),
+                "payload leaked into the tuple's context");
+        assertNotNull(back.getInlineBytes());
+    }
+
+    @Test
+    public void serializingATupleStillCarryingPayloadFailsLoudly() {
+        // A tuple whose context still holds InlineBytes has no serialized 
form;
+        // PipesRequest.of is the only way onto the wire.
+        Exception e = assertThrows(Exception.class, () -> 
JsonPipesIpc.toBytes(tuple(payload(10))));
+        assertTrue(root(e).contains("no serialized form"),
+                "expected loud refusal, got: " + root(e));
+        Exception text = assertThrows(Exception.class,
+                () -> JsonFetchEmitTuple.toJson(tuple(payload(10))));
+        assertTrue(root(text).contains("no serialized form"),
+                "expected loud refusal, got: " + root(text));
+    }
+
+    @Test
+    public void requestBodyRejectsInlineBytes() {
+        String json = "{\"id\":\"t\",\"fetcher\":\"f\",\"fetchKey\":\"k\"," +
+                "\"emitter\":\"e\",\"inlineBytes\":\"QUJD\"}";
+        Exception e = assertThrows(IOException.class,
+                () -> JsonFetchEmitTuple.fromJson(new StringReader(json)));
+        assertTrue(root(e).contains("not a FetchEmitTuple field"),
+                "expected inlineBytes rejection, got: " + root(e));
+    }
+
+    @Test
+    public void requestBodyRejectsLegacyParseContextSpelling() {
+        // 4.0.0 serialized InlineBytes under this parse-context name; 
upgraders get a
+        // tailored message, not the generic "check for a typo".
+        String json = 
"{\"id\":\"t\",\"fetcher\":\"f\",\"fetchKey\":\"k\",\"emitter\":\"e\"," +
+                "\"parse-context\":{\"inline-bytes\":{\"bytes\":\"QUJD\"}}}";
+        Exception e = assertThrows(IOException.class,
+                () -> JsonFetchEmitTuple.fromJson(new StringReader(json)));
+        assertTrue(root(e).contains("no longer a serializable parse-context 
entry"),
+                "expected legacy-spelling rejection, got: " + root(e));
+    }
+
+    @Test
+    public void requestParseContextFormCannotBind() throws Exception {
+        // Defense in depth below the tuple gate: even if the entry reaches a 
ParseContext
+        // (it is admitted as an inert config at most), resolution fails 
closed.
+        JsonNode node = new ObjectMapper()
+                .readTree("{\"inline-bytes\":{\"bytes\":\"QUJD\"}}");
+        ParseContext ctx = ParseContextDeserializer.readParseContext(node, 
true);
+        Exception e = assertThrows(Exception.class,
+                () -> ParseContextUtils.resolveAll(ctx, 
getClass().getClassLoader()));
+        assertTrue(root(e).contains("Unrecognized parse-context entry"),
+                "expected fail-closed resolution, got: " + root(e));
+        assertNull(ctx.get(InlineBytes.class));
+    }
+
+    @Test
+    public void unknownFieldErrorDoesNotAdvertiseInlineBytes() {
+        String json = 
"{\"id\":\"t\",\"fetcher\":\"f\",\"fetchKey\":\"k\",\"emitter\":\"e\"," +
+                "\"fetchKye\":\"typo\"}";
+        Exception e = assertThrows(IOException.class,
+                () -> JsonFetchEmitTuple.fromJson(new StringReader(json)));
+        assertTrue(root(e).contains("Unrecognized"), "expected unknown-field 
error, got: " + root(e));
+        assertFalse(root(e).contains("inlineBytes"),
+                "IPC-only field advertised to requests: " + root(e));
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/SystemComponentIdWireTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/SystemComponentIdWireTest.java
index 574bd351b8..7c70728dba 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/SystemComponentIdWireTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/SystemComponentIdWireTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.tika.pipes.core.serialization;
 
+import static org.apache.tika.pipes.core.serialization.WireTestUtil.root;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -118,12 +119,4 @@ public class SystemComponentIdWireTest {
                 () -> JsonPipesIpc.fromBytes(smile, FetchEmitTuple.class));
         assertTrue(root(e).contains("Illegal"), "expected charset rejection, 
got: " + root(e));
     }
-
-    private static String root(Throwable t) {
-        StringBuilder sb = new StringBuilder();
-        for (Throwable c = t; c != null; c = c.getCause()) {
-            sb.append(c.getMessage()).append(' ');
-        }
-        return sb.toString();
-    }
 }
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java
index 9815f63695..143bc9b5dd 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireRestrictedFetchEmitTupleTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.tika.pipes.core.serialization;
 
+import static org.apache.tika.pipes.core.serialization.WireTestUtil.root;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -98,12 +99,4 @@ public class WireRestrictedFetchEmitTupleTest {
         assertTrue(root(e).contains("may not be supplied via a request 
parseContext"),
                 "expected wire-blocked rejection at fork IPC, got: " + 
root(e));
     }
-
-    private static String root(Throwable t) {
-        Throwable r = t;
-        while (r.getCause() != null && r.getCause() != r) {
-            r = r.getCause();
-        }
-        return String.valueOf(r.getMessage());
-    }
 }
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/protocol/PayloadLimitExceededException.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireTestUtil.java
similarity index 51%
copy from 
tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/protocol/PayloadLimitExceededException.java
copy to 
tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireTestUtil.java
index 739cf51921..fa82b44095 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/protocol/PayloadLimitExceededException.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/WireTestUtil.java
@@ -14,21 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.pipes.core.protocol;
+package org.apache.tika.pipes.core.serialization;
 
-import java.io.IOException;
+final class WireTestUtil {
 
-/**
- * Thrown when an incoming IPC payload's declared length exceeds the 
configured limit
- * (see {@link org.apache.tika.pipes.core.PipesConfig#getMaxIpcPayloadBytes()};
- * default {@link PipesMessage#MAX_PAYLOAD_BYTES}). The payload bytes were not 
consumed,
- * so the stream is desynchronized and the connection must be closed. With a 
shared server
- * the process keeps running (only this connection ends); with the default 
per-client forked
- * server the process may still exit on the failed write, and the client 
reconnects on the
- * next task.
- */
-public class PayloadLimitExceededException extends IOException {
-    public PayloadLimitExceededException(String message) {
-        super(message);
+    private WireTestUtil() {
+    }
+
+    /** Root-cause message, for asserting on rejections Jackson may wrap. */
+    static String root(Throwable t) {
+        Throwable r = t;
+        while (r.getCause() != null && r.getCause() != r) {
+            r = r.getCause();
+        }
+        return String.valueOf(r.getMessage());
     }
 }
diff --git 
a/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/PipesForkParserTest.java
 
b/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/PipesForkParserTest.java
index 725c446062..832255c917 100644
--- 
a/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/PipesForkParserTest.java
+++ 
b/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/PipesForkParserTest.java
@@ -126,6 +126,8 @@ public class PipesForkParserTest {
             PipesForkResult result = parser.parse(tis, new Metadata(), 
parseContext);
             assertTrue(result.isSuccess(), "Parse should succeed. Status: " + 
result.getStatus()
                     + ", message: " + result.getMessage());
+            assertTrue(result.getContent().contains("inline body"),
+                    "worker did not parse the inline payload; content: " + 
result.getContent());
             assertNull(parseContext.get(InlineBytes.class),
                     "inline payload must not outlive its request in the 
caller's context");
         }

Reply via email to