Expect100ContinueOnPooledChannelTest.java

/*
 * Copyright (c) 2026 Contributors to the Eclipse Foundation.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0, which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the
 * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
 * version 2 with the GNU Classpath Exception, which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 */

package org.glassfish.jersey.netty.connector;

import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Application;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.client.Entity;
import java.util.ArrayList;
import java.util.List;
import java.util.Collections;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.client.RequestEntityProcessing;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.test.JerseyTest;
import org.glassfish.jersey.test.jetty.JettyTestContainerFactory;
import org.glassfish.jersey.test.spi.TestContainerException;
import org.glassfish.jersey.test.spi.TestContainerFactory;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
 * Reproduces <a href="https://github.com/eclipse-ee4j/jersey/issues/6125">issue #6125</a>:
 * {@code NettyConnector} creates a new {@code JerseyExpectContinueHandler} instance for every request.
 * If the request carries an {@code Expect: 100-continue} header, the connector gives a latch to
 * this instance and waits on that latch. The latch can be released by the handler that
 * receives the {@code 100 Continue} response. To receive the {@code 100 Continue} response, the handler
 * must be in the pipeline. But the handler is only added to the pipeline when a <b>new</b> channel
 * is created. On a channel taken from the pool, the pipeline holds a different handler:
 * the one that was created for the first request on that channel.
 * That handler has no latch, so it releases nothing.
 * <p>
 * As a result, every {@code Expect: 100-continue} request sent on a reused channel waits until
 * the timeout expires, even if the {@code 100 Continue} response arrives immediately.
 * The connector handles the timeout by writing an empty last chunk,
 * so the server sees an empty finished request. After writing the last chunk,
 * the connector sends the request again without the {@code Expect: 100-continue} header, as a fallback.
 * This is how one JAX-RS call becomes two requests.
 */
public class Expect100ContinueOnPooledChannelTest extends JerseyTest {

    private static final int REQUEST_COUNT = 4;
    private static final byte[] ENTITY = new byte[256 * 1024];

    private static final List<Integer> RECEIVED = Collections.synchronizedList(new ArrayList<>());

    private static volatile CountDownLatch received = new CountDownLatch(REQUEST_COUNT);

    @Path("/upload")
    public static class UploadResource {
        @PUT
        @Path("{name}")
        public Response put(@PathParam("name") String name, byte[] entity) {
            RECEIVED.add(entity == null ? 0 : entity.length);
            received.countDown();
            return Response.ok().build();
        }
    }

    @Override
    protected Application configure() {
        return new ResourceConfig(UploadResource.class);
    }

    @Override
    protected TestContainerFactory getTestContainerFactory() throws TestContainerException {
        return new JettyTestContainerFactory();
    }

    @Override
    protected void configureClient(ClientConfig config) {
        config.connectorProvider(new NettyConnectorProvider())
                .property(ClientProperties.EXPECT_100_CONTINUE, true)
                .property(ClientProperties.REQUEST_ENTITY_PROCESSING, RequestEntityProcessing.CHUNKED);
    }

    @BeforeEach
    public void clearReceived() {
        RECEIVED.clear();
        received = new CountDownLatch(REQUEST_COUNT);
    }

    @Test
    public void testOneRequestPerCallOnPooledChannel() throws InterruptedException {
        for (int i = 1; i <= REQUEST_COUNT; i++) {
            try (Response response = target("upload").path("entity-" + i).request()
                    .put(Entity.entity(ENTITY, MediaType.APPLICATION_OCTET_STREAM_TYPE))) {
                Assertions.assertEquals(200, response.getStatus(), "request " + i + " failed");
            }
        }
        Assertions.assertTrue(received.await(10 * getAsyncTimeoutMultiplier(), TimeUnit.SECONDS),
                "the server did not receive all the requests in time: " + RECEIVED.size()
                        + " of " + REQUEST_COUNT);
        Assertions.assertFalse(RECEIVED.contains(0),
                "the resource was invoked with an empty body, the requests it received: " + RECEIVED);
        Assertions.assertEquals(REQUEST_COUNT, RECEIVED.size(),
                "the resource was invoked " + RECEIVED.size() + " times for " + REQUEST_COUNT + " calls: " + RECEIVED);
    }
}