Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,10 @@ private Buffer encodeHttpPacket(final Connection connection, final HttpPacket ht
Buffer encodedBuffer = null;
if (!httpHeader.isCommitted()) {
encodedBuffer = AjpMessageUtils.encodeHeaders(memoryManager, httpResponsePacket);
if (httpResponsePacket.isAcknowledgement()) {
if (httpResponsePacket.isInterimResponse()) {
encodedBuffer.trim();

httpResponsePacket.acknowledged();
httpResponsePacket.interimResponseSent();
return encodedBuffer; // DO NOT MARK COMMITTED
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.glassfish.grizzly.http.util.BufferChunk;
import org.glassfish.grizzly.http.util.DataChunk;
import org.glassfish.grizzly.http.util.HexUtils;
import org.glassfish.grizzly.http.util.HttpStatus;
import org.glassfish.grizzly.http.util.HttpUtils;
import org.glassfish.grizzly.http.util.MimeHeaders;
import org.glassfish.grizzly.memory.Buffers;
Expand Down Expand Up @@ -368,24 +369,36 @@ private static int setStringAttributeValue(final AjpHttpRequest req, final Strin
}

public static Buffer encodeHeaders(final MemoryManager mm, final HttpResponsePacket httpResponsePacket) {
final boolean isInterim = httpResponsePacket.isInterimResponse();
final HttpStatus status = isInterim ? httpResponsePacket.getInterimStatus() : httpResponsePacket.getHttpStatus();

Buffer encodedBuffer = mm.allocate(4096);
int startPos = encodedBuffer.position();
// Skip 4 bytes for the Ajp header
encodedBuffer.position(startPos + 4);

encodedBuffer.put(AjpConstants.JK_AJP13_SEND_HEADERS);
encodedBuffer.putShort((short) httpResponsePacket.getStatus());
encodedBuffer.putShort((short) status.getStatusCode());
final byte[] tempBuffer = httpResponsePacket.getTempHeaderEncodingBuffer();
if (httpResponsePacket.isCustomReasonPhraseSet()) {
// CustomReasonPhrase belongs to the final response, never to an interim status.
if (!isInterim && httpResponsePacket.isCustomReasonPhraseSet()) {
encodedBuffer = putBytes(mm, encodedBuffer, HttpUtils.filter(httpResponsePacket.getReasonPhraseDC()), tempBuffer);
} else {
encodedBuffer = putBytes(mm, encodedBuffer, httpResponsePacket.getHttpStatus().getReasonPhraseBytes());
encodedBuffer = putBytes(mm, encodedBuffer, status.getReasonPhraseBytes());
}

if (httpResponsePacket.isAcknowledgement()) {
// If it's acknoledgment packet - don't encode the headers
// Serialize 0 num_headers
encodedBuffer = putShort(mm, encodedBuffer, 0);
if (isInterim) {
// Interim (1xx) responses carry the currently-set headers as-is — for 100-Continue this is typically an
// empty map; for 103 Early Hints it carries the Link headers set by the application. Content-Type,
// Content-Language and Content-Length are deliberately not auto-injected here — they describe the final
// response, not the interim one, and must remain available for the SEND_HEADERS packet that follows.
final MimeHeaders headers = httpResponsePacket.getHeaders();
final int numHeaders = headers.size();
encodedBuffer = putShort(mm, encodedBuffer, numHeaders);
for (int i = 0; i < numHeaders; i++) {
encodedBuffer = putBytes(mm, encodedBuffer, headers.getName(i), tempBuffer);
encodedBuffer = putBytes(mm, encodedBuffer, headers.getValue(i), tempBuffer);
}
} else {
final MimeHeaders headers = httpResponsePacket.getHeaders();
final String contentType = httpResponsePacket.getContentType();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Contributors to the Eclipse Foundation. All rights reserved.
*
* 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.grizzly.http.ajp;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;

import org.glassfish.grizzly.http.server.HttpHandler;
import org.glassfish.grizzly.http.server.Request;
import org.glassfish.grizzly.http.server.Response;
import org.junit.Test;

/**
* Test the <code>103 Early Hints</code> interim response support over the AJP protocol. Mirrors
* {@link org.glassfish.grizzly.http.server.EarlyHintsTest}.
*/
public class AjpEarlyHintsTest extends AjpTestBase {

private static final String LINK_VALUE = "</style.css>; rel=preload; as=style";

@Test
public void testEarlyHintsThenFinalResponse() throws Exception {
startHttpServer(new HttpHandler() {
@Override
public void service(Request request, Response response) throws Exception {
response.setHeader("Link", LINK_VALUE);
response.sendEarlyHints();
response.setContentType("text/plain");
response.getWriter().write("done");
}
});

send(new AjpForwardRequestPacket("GET", "/path", 80, PORT).toByteArray());

final AjpResponse interim = Utils.parseResponse(readAjpMessage());
assertEquals(AjpConstants.JK_AJP13_SEND_HEADERS, interim.getType());
assertEquals(103, interim.getResponseCode());
assertEquals("Early Hints", interim.getResponseMessage());
assertEquals(LINK_VALUE, interim.getHeaders().getHeader("Link"));

// Auto-injected Content-* headers describe the final response and must not leak into the 103 packet.
assertNull("Content-Type must not be auto-injected into the 103 packet", interim.getHeaders().getHeader("Content-Type"));
assertNull("Content-Length must not be auto-injected into the 103 packet", interim.getHeaders().getHeader("Content-Length"));

final AjpResponse finalHeaders = Utils.parseResponse(readAjpMessage());
assertEquals(AjpConstants.JK_AJP13_SEND_HEADERS, finalHeaders.getType());
assertEquals(200, finalHeaders.getResponseCode());

// Headers set before sendEarlyHints() must still appear on the final response.
assertEquals(LINK_VALUE, finalHeaders.getHeaders().getHeader("Link"));
assertTrue("expected Content-Type to start with text/plain, got: " + finalHeaders.getHeaders().getHeader("Content-Type"),
finalHeaders.getHeaders().getHeader("Content-Type").startsWith("text/plain"));
}

@Test
public void testEarlyHintsMayBeSentMultipleTimes() throws Exception {
startHttpServer(new HttpHandler() {
@Override
public void service(Request request, Response response) throws Exception {
response.setHeader("Link", LINK_VALUE);
response.sendEarlyHints();
response.sendEarlyHints();
response.setContentType("text/plain");
response.getWriter().write("done");
}
});

send(new AjpForwardRequestPacket("GET", "/path", 80, PORT).toByteArray());

assertEquals(103, Utils.parseResponse(readAjpMessage()).getResponseCode());
assertEquals(103, Utils.parseResponse(readAjpMessage()).getResponseCode());
assertEquals(200, Utils.parseResponse(readAjpMessage()).getResponseCode());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,6 @@ protected SessionManager getSessionManager(final Request request) {
protected boolean sendAcknowledgment(final Request request, final Response response) throws IOException {

if ("100-continue".equalsIgnoreCase(request.getHeader(Header.Expect))) {
response.setStatus(HttpStatus.CONINTUE_100);
response.sendAcknowledgement();
return true;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ public void setTrailers(Supplier<Map<String, String>> trailerSupplier) {
throw new IllegalStateException("Response has already been committed.");
}
final Protocol protocol = request.getProtocol();
if (protocol.equals(Protocol.HTTP_0_9) || protocol.equals(Protocol.HTTP_1_0)) {
if (!protocol.isAtLeast(Protocol.HTTP_1_1)) {
throw new IllegalStateException("Trailers not supported by response protocol version " + protocol);
}
if (protocol.equals(Protocol.HTTP_1_1)) {
Expand Down Expand Up @@ -1183,9 +1183,26 @@ public void sendAcknowledgement() throws IOException {
return;
}

response.setAcknowledgement(true);
outputBuffer.acknowledge();
response.setInterimStatus(HttpStatus.CONINTUE_100);
outputBuffer.writeInterimResponse();
}

/**
* Send a <code>103 Early Hints</code> interim response using the headers currently set on this response.
* <p>
* This method does not commit the response and may be called multiple times before the response is committed. It has
* no effect if the response is already committed or if the request protocol is neither HTTP/1.1 nor HTTP/2 (interim
* responses are not supported by HTTP/1.0 and earlier).
*
* @exception java.io.IOException if an input/output error occurs
*/
public void sendEarlyHints() throws IOException {
if (isCommitted() || !request.getProtocol().isAtLeast(Protocol.HTTP_1_1)) {
return;
}

response.setInterimStatus(HttpStatus.EARLY_HINTS_103);
outputBuffer.writeInterimResponse();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2026 Contributors to the Eclipse Foundation. All rights reserved.
*
* 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.grizzly.http.server;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

import javax.net.SocketFactory;

import org.junit.After;
import org.junit.Test;

/**
* Test the <code>103 Early Hints</code> interim response support exposed via {@link Response#sendEarlyHints()}.
*/
public class EarlyHintsTest {

private static final int PORT = 9497;
private static final String LINK_VALUE = "</style.css>; rel=preload; as=style";

private HttpServer server;

@After
public void tearDown() {
if (server != null) {
server.shutdownNow();
}
}

@Test
public void testEarlyHintsThenFinalResponse() throws Exception {
startServer(new HttpHandler() {
@Override
public void service(Request request, Response response) throws Exception {
response.setHeader("Link", LINK_VALUE);
response.sendEarlyHints();
response.setContentType("text/plain");
response.getWriter().write("done");
}
});

try (Socket s = connect()) {
sendRequest(s, "GET /path HTTP/1.1");
InputStream in = s.getInputStream();

String interim = readBlock(in);
assertTrue("expected 103 interim, got: " + interim, interim.startsWith("HTTP/1.1 103 Early Hints"));
assertTrue("103 must carry the Link header, got: " + interim, interim.contains("Link: " + LINK_VALUE));

String finalResponse = readBlock(in);
assertTrue("expected 200 final, got: " + finalResponse, finalResponse.startsWith("HTTP/1.1 200 OK"));
// The headers set before sendEarlyHints() must still be present in the final response.
assertTrue("final response must still carry the Link header, got: " + finalResponse,
finalResponse.contains("Link: " + LINK_VALUE));
}
}

@Test
public void testEarlyHintsMayBeSentMultipleTimes() throws Exception {
startServer(new HttpHandler() {
@Override
public void service(Request request, Response response) throws Exception {
response.setHeader("Link", LINK_VALUE);
response.sendEarlyHints();
response.sendEarlyHints();
response.setContentType("text/plain");
response.getWriter().write("done");
}
});

try (Socket s = connect()) {
sendRequest(s, "GET /path HTTP/1.1");
InputStream in = s.getInputStream();

assertTrue(readBlock(in).startsWith("HTTP/1.1 103 Early Hints"));
assertTrue(readBlock(in).startsWith("HTTP/1.1 103 Early Hints"));
assertTrue(readBlock(in).startsWith("HTTP/1.1 200 OK"));
}
}

@Test
public void testEarlyHintsIgnoredForHttp10() throws Exception {
startServer(new HttpHandler() {
@Override
public void service(Request request, Response response) throws Exception {
response.setHeader("Link", LINK_VALUE);
response.sendEarlyHints();
response.setContentType("text/plain");
response.getWriter().write("done");
}
});

try (Socket s = connect()) {
sendRequest(s, "GET /path HTTP/1.0");
InputStream in = s.getInputStream();

String response = readBlock(in);
assertFalse("HTTP/1.0 must not receive a 103 interim response, got: " + response,
response.contains("103 Early Hints"));
assertTrue("expected the final 200 response, got: " + response, response.contains("200 OK"));
}
}

// --------------------------------------------------------- Private Methods

private void startServer(final HttpHandler httpHandler) throws IOException {
server = new HttpServer();
server.addListener(new NetworkListener("grizzly", NetworkListener.DEFAULT_NETWORK_HOST, PORT));
server.getServerConfiguration().addHttpHandler(httpHandler, "/path");
server.start();
}

private static Socket connect() throws IOException {
Socket s = SocketFactory.getDefault().createSocket("localhost", PORT);
s.setSoTimeout(10 * 1000);
return s;
}

private static void sendRequest(final Socket s, final String requestLine) throws IOException {
OutputStream out = s.getOutputStream();
out.write((requestLine + "\r\n").getBytes());
out.write(("Host: localhost:" + PORT + "\r\n").getBytes());
out.write("\r\n".getBytes());
out.flush();
}

/**
* Read a single HTTP message head (status line and headers) up to and including the terminating empty line.
*/
private static String readBlock(final InputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
int c;
while ((c = in.read()) != -1) {
sb.append((char) c);
int len = sb.length();
if (len >= 4 && sb.charAt(len - 4) == '\r' && sb.charAt(len - 3) == '\n' && sb.charAt(len - 2) == '\r'
&& sb.charAt(len - 1) == '\n') {
break;
}
}
return sb.toString();
}

}
Loading