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

ffang pushed a commit to branch 4.1.x-fixes
in repository https://gitbox.apache.org/repos/asf/cxf.git


The following commit(s) were added to refs/heads/4.1.x-fixes by this push:
     new bcec379beb1 [CXF-9251]Logging: don't cache writes after 
LoggingOutputStream is closed (#3553)
bcec379beb1 is described below

commit bcec379beb169a8494d0a81d0e2a82aa6fa0266e
Author: Freeman(Yue) Fang <[email protected]>
AuthorDate: Wed Oct 7 14:32:40 2026 -0400

    [CXF-9251]Logging: don't cache writes after LoggingOutputStream is closed 
(#3553)
    
    Since #3541, LoggingOutputStream closes itself (and logs) when a write
    fails, and any later close() is a no-op. If something writes to the
    stream after that, and the underlying stream silently accepts writes
    after close (as Tomcat 10.1 does), CacheAndWriteOutputStream caches
    those bytes again. Because totalLength is never reset, they spill into
    a new temp file and the stream is registered with the
    DelayedCachedOutputStreamCleaner again. When the cleaner fires, its
    close() is a no-op, so the temp file is never deleted, not even by
    the cleanup thread.
    
    This happens on a client connection reset during a large response:
    the write fails, the stream closes itself, and the fault chain
    (SoapOutEndingInterceptor) then writes the closing tags to the same
    stream.
    
    Once closed, the write methods now only pass the bytes through to the
    flow-through stream and no longer cache them, so nothing is spilled to
    disk after the payload has been logged.
    
    Add 2 tests to LoggingOutInterceptorTest (write after close, write
    after a failed write) that check the temp file is deleted after
    forceClean(); both fail without this change.
    
    (cherry picked from commit b5ba52bd7b6dea4cf9ad23e5bf028389258cb8c0)
---
 .../cxf/ext/logging/LoggingOutputStream.java       | 20 +++++
 .../cxf/ext/logging/LoggingOutInterceptorTest.java | 89 ++++++++++++++++++++++
 2 files changed, 109 insertions(+)

diff --git 
a/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/LoggingOutputStream.java
 
b/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/LoggingOutputStream.java
index 202f577672b..ac24ac1a528 100644
--- 
a/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/LoggingOutputStream.java
+++ 
b/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/LoggingOutputStream.java
@@ -80,9 +80,19 @@ public class LoggingOutputStream extends 
CacheAndWriteOutputStream {
      * We override the write() methods in order to catch some error that would 
not
      * close the CachedOutputStream (ex. IOException "Connection reset by 
peer").
      * This caused ghost/delayed OUT log and possible memory-leak due to 
DelayedCachedOutputStreamCleaner
+     *
+     * Once closed (and logged), any late write (for example the fault chain 
writing the closing tags
+     * through a writer still wrapping this stream) is only passed through to 
the flow-through stream and
+     * is not cached anymore: some containers (e.g. Tomcat 10.1) silently 
accept writes after close, and
+     * caching them would spill into a new temp file that close() (now a 
no-op) could never delete.
      */
     @Override
     public void write(byte[] b) throws IOException {
+        if (closed.get()) {
+            // already closed and logged: pass through only, do not cache again
+            getFlowThroughStream().write(b);
+            return;
+        }
         try {
             super.write(b);
         } catch (RuntimeException | IOException ex) {
@@ -93,6 +103,11 @@ public class LoggingOutputStream extends 
CacheAndWriteOutputStream {
 
     @Override
     public void write(byte[] b, int off, int len) throws IOException {
+        if (closed.get()) {
+            // already closed and logged: pass through only, do not cache again
+            getFlowThroughStream().write(b, off, len);
+            return;
+        }
         try {
             super.write(b, off, len);
         } catch (RuntimeException | IOException ex) {
@@ -103,6 +118,11 @@ public class LoggingOutputStream extends 
CacheAndWriteOutputStream {
 
     @Override
     public void write(int b) throws IOException {
+        if (closed.get()) {
+            // already closed and logged: pass through only, do not cache again
+            getFlowThroughStream().write(b);
+            return;
+        }
         try {
             super.write(b);
         } catch (RuntimeException | IOException ex) {
diff --git 
a/rt/features/logging/src/test/java/org/apache/cxf/ext/logging/LoggingOutInterceptorTest.java
 
b/rt/features/logging/src/test/java/org/apache/cxf/ext/logging/LoggingOutInterceptorTest.java
index 87f82c5def7..e94f726432f 100644
--- 
a/rt/features/logging/src/test/java/org/apache/cxf/ext/logging/LoggingOutInterceptorTest.java
+++ 
b/rt/features/logging/src/test/java/org/apache/cxf/ext/logging/LoggingOutInterceptorTest.java
@@ -20,10 +20,12 @@
 package org.apache.cxf.ext.logging;
 
 import java.io.ByteArrayOutputStream;
+import java.io.File;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.UncheckedIOException;
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import org.apache.cxf.Bus;
 import org.apache.cxf.BusFactory;
@@ -306,6 +308,93 @@ public class LoggingOutInterceptorTest {
         assertThat(event.getPayload(), equalToIgnoringCase(buf.toString()));
     }
 
+    @Test
+    public void shouldDeleteTempFileWhenWrittenAfterClose() throws IOException 
{
+        message.put(Message.ENDPOINT_ADDRESS, "http://localhost:9001/";);
+        message.put(Message.REQUEST_URI, "/api");
+
+        final StringBuilder buf = content();
+        String ct = "multipart/related; type=\"application/xop+xml\"; "
+                + "boundary=\"----=_Part_0_2180223.1203118300920\"";
+
+        final byte[] bytes = buf.toString().getBytes(StandardCharsets.UTF_8);
+        final OutputStream os = new ByteArrayOutputStream();
+        message.setContent(OutputStream.class, os);
+        message.put(Message.CONTENT_TYPE, ct);
+
+        interceptor.setInMemThreshold(1);
+        interceptor.addBinaryContentMediaTypes("application/xop+xml");
+        interceptor.setLogMultipart(true);
+        interceptor.setLogBinary(true);
+        interceptor.handleMessage(message);
+
+        final OutputStream cached = message.getContent(OutputStream.class);
+        cached.write(bytes, 0, bytes.length);
+        cached.close();
+        assertThat(sender.getEvents(), hasSize(1));
+        assertThat(cleaner.size(), equalTo(0));
+
+        // A late writer still holding the old stream reference, with a 
flow-through stream that
+        // accepts writes after close: it must not be cached (and spilled to a 
new temp file) again
+        cached.write(bytes, 0, bytes.length);
+        assertThat(((ByteArrayOutputStream) os).size(), equalTo(2 * 
bytes.length));
+        final File tempFile = ((CachedOutputStream) cached).getTempFile();
+
+        cleaner.forceClean();
+        assertThat(cleaner.size(), equalTo(0));
+        assertThat(sender.getEvents(), hasSize(1));
+        assertThat("temp file leaked: " + tempFile, tempFile != null && 
tempFile.exists(), equalTo(false));
+    }
+
+    @Test
+    public void shouldDeleteTempFileWhenWrittenAfterFailedWrite() throws 
IOException {
+        message.put(Message.ENDPOINT_ADDRESS, "http://localhost:9001/";);
+        message.put(Message.REQUEST_URI, "/api");
+
+        final StringBuilder buf = content();
+        String ct = "multipart/related; type=\"application/xop+xml\"; "
+                + "boundary=\"----=_Part_0_2180223.1203118300920\"";
+
+        final byte[] bytes = buf.toString().getBytes(StandardCharsets.UTF_8);
+        final AtomicBoolean failNextWrite = new AtomicBoolean();
+        final OutputStream os = new ByteArrayOutputStream() {
+            @Override
+            public synchronized void write(byte[] b, int off, int len) {
+                if (failNextWrite.compareAndSet(true, false)) {
+                    throw new UncheckedIOException(new 
IOException("Simulated"));
+                }
+                super.write(b, off, len);
+            }
+        };
+        message.setContent(OutputStream.class, os);
+        message.put(Message.CONTENT_TYPE, ct);
+
+        interceptor.setInMemThreshold(1);
+        interceptor.addBinaryContentMediaTypes("application/xop+xml");
+        interceptor.setLogMultipart(true);
+        interceptor.setLogBinary(true);
+        interceptor.handleMessage(message);
+
+        final OutputStream cached = message.getContent(OutputStream.class);
+        cached.write(bytes, 0, bytes.length);
+        failNextWrite.set(true);
+        assertThrows(UncheckedIOException.class, () -> cached.write(bytes, 0, 
bytes.length));
+        assertThat(sender.getEvents(), hasSize(1));
+        assertThat(cleaner.size(), equalTo(0));
+
+        // e.g. the fault chain (SoapOutEndingInterceptor) writing the closing 
tags through the
+        // XMLStreamWriter that still wraps this stream, with a flow-through 
stream that accepts writes
+        // after close (as Tomcat 10.1 does): it must not be cached (and 
spilled to a new temp file) again
+        cached.write(bytes, 0, bytes.length);
+        assertThat(((ByteArrayOutputStream) os).size(), equalTo(2 * 
bytes.length));
+        final File tempFile = ((CachedOutputStream) cached).getTempFile();
+
+        cleaner.forceClean();
+        assertThat(cleaner.size(), equalTo(0));
+        assertThat(sender.getEvents(), hasSize(1));
+        assertThat("temp file leaked: " + tempFile, tempFile != null && 
tempFile.exists(), equalTo(false));
+    }
+
     private static StringBuilder content() {
         StringBuilder buf = new StringBuilder(512);
         buf.append("------=_Part_0_2180223.1203118300920\n");

Reply via email to