Skip to content

Commit 96c0946

Browse files
author
Mark Pollack
committed
Null-mark the Jetty WebSocket and Streamable HTTP transport packages
Before-start and disconnected state is @nullable (and the WebSocket transport's server field volatile, read once into a local), as are the optional session headers and JSON-RPC params the router takes. A session route scope exposes its always-present id through boundSessionId(), and the servlet no longer writes a null exception message into the response.
1 parent abe618f commit 96c0946

9 files changed

Lines changed: 88 additions & 30 deletions

File tree

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStream.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import jakarta.servlet.ServletOutputStream;
1616
import jakarta.servlet.WriteListener;
1717
import jakarta.servlet.http.HttpServletResponse;
18+
import org.jspecify.annotations.Nullable;
1819
import org.slf4j.Logger;
1920
import org.slf4j.LoggerFactory;
2021

@@ -55,7 +56,7 @@ final class SseOutboundStream {
5556
private final ArrayDeque<String> mailbox = new ArrayDeque<>();
5657

5758
/** Guarded by {@code this}. */
58-
private SseSubscriber current;
59+
private @Nullable SseSubscriber current;
5960

6061
/** Guarded by {@code this}. */
6162
private boolean closed;

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.eclipse.jetty.server.Server;
2525
import org.eclipse.jetty.server.ServerConnector;
2626
import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler;
27+
import org.jspecify.annotations.Nullable;
2728
import org.slf4j.Logger;
2829
import org.slf4j.LoggerFactory;
2930
import reactor.core.publisher.Mono;
@@ -94,9 +95,9 @@ public class StreamableHttpAcpAgentTransport {
9495

9596
private final Sinks.One<Void> terminationSink = Sinks.one();
9697

97-
private volatile Server server;
98+
private volatile @Nullable Server server;
9899

99-
private volatile ServerConnector connector;
100+
private volatile @Nullable ServerConnector connector;
100101

101102
/**
102103
* Creates a new Streamable HTTP listener on the default ACP path.

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServlet.java‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import jakarta.servlet.http.HttpServlet;
3131
import jakarta.servlet.http.HttpServletRequest;
3232
import jakarta.servlet.http.HttpServletResponse;
33+
import org.jspecify.annotations.Nullable;
3334
import org.slf4j.Logger;
3435
import org.slf4j.LoggerFactory;
3536
import reactor.core.Disposable;
@@ -89,9 +90,9 @@ public class StreamableHttpAcpServlet extends HttpServlet {
8990

9091
private final transient AtomicBoolean closing = new AtomicBoolean(false);
9192

92-
private transient volatile Scheduler keepAliveScheduler;
93+
private transient volatile @Nullable Scheduler keepAliveScheduler;
9394

94-
private transient volatile Disposable keepAliveTask;
95+
private transient volatile @Nullable Disposable keepAliveTask;
9596

9697
/**
9798
* Creates a servlet with the default limits.
@@ -428,10 +429,12 @@ private Optional<String> header(HttpServletRequest request, String name) {
428429
return Optional.ofNullable(request.getHeader(name)).filter(value -> !value.isBlank());
429430
}
430431

431-
private void writeText(HttpServletResponse response, int status, String body) throws IOException {
432+
private void writeText(HttpServletResponse response, int status, @Nullable String body) throws IOException {
432433
response.setStatus(status);
433434
response.setContentType("text/plain");
434-
response.getWriter().write(body);
435+
if (body != null) {
436+
response.getWriter().write(body);
437+
}
435438
}
436439

437440
}

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpConnection.java‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import jakarta.servlet.AsyncContext;
2525
import jakarta.servlet.http.HttpServletRequest;
2626
import jakarta.servlet.http.HttpServletResponse;
27+
import org.jspecify.annotations.Nullable;
2728
import org.slf4j.Logger;
2829
import org.slf4j.LoggerFactory;
2930
import reactor.core.publisher.Mono;
@@ -66,7 +67,7 @@ final class StreamableHttpConnection {
6667

6768
private final AtomicBoolean initialized = new AtomicBoolean(false);
6869

69-
private volatile Object initializeRequestId;
70+
private volatile @Nullable Object initializeRequestId;
7071

7172
private final AcpJsonMapper jsonMapper;
7273

@@ -105,7 +106,7 @@ Mono<JSONRPCMessage> initialize(AcpSchema.JSONRPCRequest request) {
105106
return initializeResponse.asMono().doOnSuccess(ignored -> initialized.set(true));
106107
}
107108

108-
void acceptClientPost(JSONRPCMessage message, String sessionHeader) {
109+
void acceptClientPost(JSONRPCMessage message, @Nullable String sessionHeader) {
109110
if (message instanceof AcpSchema.JSONRPCResponse response) {
110111
validateClientResponseScope(response, sessionHeader);
111112
connection.acceptInbound(message);
@@ -114,7 +115,7 @@ void acceptClientPost(JSONRPCMessage message, String sessionHeader) {
114115

115116
ResolvedInboundRoute resolved = routing.resolveInboundRoute(message, sessionHeader);
116117
if (resolved.requestScope().isSession()) {
117-
prepareSessionForInbound(resolved.requestScope().sessionId(), resolved.requestRoute());
118+
prepareSessionForInbound(resolved.requestScope().boundSessionId(), resolved.requestRoute());
118119
}
119120
if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null
120121
&& resolved.requestRoute() != null) {
@@ -123,12 +124,12 @@ void acceptClientPost(JSONRPCMessage message, String sessionHeader) {
123124
connection.acceptInbound(message);
124125
}
125126

126-
void openStream(HttpServletRequest request, HttpServletResponse response, String sessionId)
127+
void openStream(HttpServletRequest request, HttpServletResponse response, @Nullable String sessionId)
127128
throws IOException {
128129
RouteScope scope = sessionId == null ? RouteScope.connection() : RouteScope.session(sessionId);
129130
SseOutboundStream stream;
130131
if (scope.isSession()) {
131-
stream = openSessionStream(scope.sessionId());
132+
stream = openSessionStream(scope.boundSessionId());
132133
}
133134
else {
134135
stream = connectionStream;
@@ -139,7 +140,7 @@ void openStream(HttpServletRequest request, HttpServletResponse response, String
139140
response.setHeader("Cache-Control", "no-cache");
140141
response.setHeader(HEADER_CONNECTION_ID, id);
141142
if (scope.isSession()) {
142-
response.setHeader(HEADER_SESSION_ID, scope.sessionId());
143+
response.setHeader(HEADER_SESSION_ID, scope.boundSessionId());
143144
}
144145
AsyncContext asyncContext = request.startAsync();
145146
asyncContext.setTimeout(0);
@@ -169,7 +170,7 @@ private void routeAgentMessage(JSONRPCMessage message) {
169170
RouteScope scope = resolveAgentOutboundScope(message);
170171
String payload = jsonMapper.writeValueAsString(message);
171172
if (scope.isSession()) {
172-
sessionStream(scope.sessionId()).push(payload);
173+
sessionStream(scope.boundSessionId()).push(payload);
173174
}
174175
else {
175176
connectionStream.push(payload);
@@ -197,10 +198,10 @@ private RouteScope resolveAgentOutboundScope(JSONRPCMessage message) {
197198
}
198199
if (route.kind() == RequestKind.SESSION_LOAD) {
199200
if (response.error() == null) {
200-
markSessionKnown(route.requestScope().sessionId());
201+
markSessionKnown(route.requestScope().boundSessionId());
201202
}
202203
else {
203-
discardProvisionalSession(route.requestScope().sessionId());
204+
discardProvisionalSession(route.requestScope().boundSessionId());
204205
}
205206
}
206207
return route.responseScope();
@@ -229,7 +230,7 @@ else if (message instanceof AcpSchema.JSONRPCNotification notification) {
229230
return scope;
230231
}
231232

232-
private void prepareSessionForInbound(String sessionId, ClientRequestRoute route) {
233+
private void prepareSessionForInbound(String sessionId, @Nullable ClientRequestRoute route) {
233234
SessionState current = sessions.get(sessionId);
234235
if (route != null && route.kind() == RequestKind.SESSION_LOAD) {
235236
if (current == null) {
@@ -243,7 +244,7 @@ private void prepareSessionForInbound(String sessionId, ClientRequestRoute route
243244
}
244245
}
245246

246-
private void validateClientResponseScope(AcpSchema.JSONRPCResponse response, String sessionHeader) {
247+
private void validateClientResponseScope(AcpSchema.JSONRPCResponse response, @Nullable String sessionHeader) {
247248
RouteScope expected = agentRequestRoutes.get(response.id());
248249
if (expected == null) {
249250
logger.warn("Client posted response for unknown agent request id {}", response.id());

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpRouting.java‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.agentclientprotocol.sdk.json.TypeRef;
1313
import com.agentclientprotocol.sdk.spec.AcpSchema;
1414
import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage;
15+
import org.jspecify.annotations.Nullable;
1516

1617
/**
1718
* Routing rules of the Streamable HTTP transport: which stream (connection or session)
@@ -63,7 +64,7 @@ enum SessionState {
6364

6465
}
6566

66-
record RouteScope(ScopeKind kind, String sessionId) {
67+
record RouteScope(ScopeKind kind, @Nullable String sessionId) {
6768

6869
static RouteScope connection() {
6970
return new RouteScope(ScopeKind.CONNECTION, null);
@@ -77,12 +78,24 @@ boolean isSession() {
7778
return kind == ScopeKind.SESSION;
7879
}
7980

81+
/**
82+
* The session id this scope routes to. Only session scopes carry one, and every
83+
* caller has already established that the scope is one.
84+
*/
85+
String boundSessionId() {
86+
if (sessionId == null) {
87+
throw new IllegalStateException("A " + kind + " route scope has no session id");
88+
}
89+
return sessionId;
90+
}
91+
8092
}
8193

8294
record ClientRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) {
8395
}
8496

85-
record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestScope, ClientRequestRoute requestRoute) {
97+
record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestScope,
98+
@Nullable ClientRequestRoute requestRoute) {
8699
}
87100

88101
static boolean isInitialize(JSONRPCMessage message) {
@@ -113,9 +126,9 @@ RouteScope resolveAgentRequestOrNotificationScope(String method, Object params)
113126
}
114127
}
115128

116-
ResolvedInboundRoute resolveInboundRoute(JSONRPCMessage message, String sessionHeader) {
129+
ResolvedInboundRoute resolveInboundRoute(JSONRPCMessage message, @Nullable String sessionHeader) {
117130
String method;
118-
Object params;
131+
@Nullable Object params;
119132
if (message instanceof AcpSchema.JSONRPCRequest request) {
120133
method = request.method();
121134
params = request.params();
@@ -178,14 +191,14 @@ else if (message instanceof AcpSchema.JSONRPCNotification notification) {
178191
}
179192

180193
/** Like {@link #requireSessionScope} but tolerates a missing header; a conflicting one is still rejected. */
181-
RouteScope sessionScopeFromParams(String method, Object params, String sessionHeader) {
194+
RouteScope sessionScopeFromParams(String method, @Nullable Object params, @Nullable String sessionHeader) {
182195
if (sessionHeader == null) {
183196
return RouteScope.session(requireSessionId(params, method));
184197
}
185198
return requireSessionScope(method, params, sessionHeader);
186199
}
187200

188-
RouteScope requireSessionScope(String method, Object params, String sessionHeader) {
201+
RouteScope requireSessionScope(String method, @Nullable Object params, @Nullable String sessionHeader) {
189202
String sessionId = requireSessionId(params, method);
190203
if (sessionHeader == null) {
191204
throw new AcpConnectionException(
@@ -198,7 +211,7 @@ RouteScope requireSessionScope(String method, Object params, String sessionHeade
198211
return RouteScope.session(sessionId);
199212
}
200213

201-
Optional<String> extractSessionId(Object params) {
214+
Optional<String> extractSessionId(@Nullable Object params) {
202215
if (params == null) {
203216
return Optional.empty();
204217
}
@@ -207,7 +220,7 @@ Optional<String> extractSessionId(Object params) {
207220
return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString());
208221
}
209222

210-
String requireSessionId(Object params, String method) {
223+
String requireSessionId(@Nullable Object params, String method) {
211224
return extractSessionId(params)
212225
.filter(sessionId -> !sessionId.isBlank())
213226
.orElseThrow(() -> new AcpConnectionException("Missing sessionId for method " + method));

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpWebSocketConnection.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
2424
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen;
2525
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
26+
import org.jspecify.annotations.Nullable;
2627
import org.slf4j.Logger;
2728
import org.slf4j.LoggerFactory;
2829
import reactor.core.publisher.Mono;
@@ -50,7 +51,8 @@ final class StreamableHttpWebSocketConnection {
5051

5152
private final SerializedWebSocketSender outboundSender = new SerializedWebSocketSender();
5253

53-
private volatile Session session;
54+
/** The client's WebSocket session; null until {@link #open}. */
55+
private volatile @Nullable Session session;
5456

5557
private final AcpJsonMapper jsonMapper;
5658

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright 2025-2026 the original author or authors.
3+
*/
4+
5+
/**
6+
* The Jetty Streamable HTTP agent transport (acp-streamable-http-jetty); this module's part of the package shared with acp-core.
7+
*
8+
* <p>
9+
* The package is null-marked: every type is non-null unless annotated
10+
* {@link org.jspecify.annotations.Nullable @Nullable}.
11+
* </p>
12+
*/
13+
@NullMarked
14+
package com.agentclientprotocol.sdk.agent.transport;
15+
16+
import org.jspecify.annotations.NullMarked;

‎acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/WebSocketAcpAgentTransport.java‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen;
2727
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
2828
import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler;
29+
import org.jspecify.annotations.Nullable;
2930
import org.slf4j.Logger;
3031
import org.slf4j.LoggerFactory;
3132
import reactor.core.publisher.Mono;
@@ -85,7 +86,8 @@ public class WebSocketAcpAgentTransport implements AcpAgentTransport {
8586

8687
private final String path;
8788

88-
private Server server;
89+
/** The embedded Jetty server; null until {@link #start} creates it. */
90+
private volatile @Nullable Server server;
8991

9092
private final Sinks.Many<JSONRPCMessage> inboundSink;
9193

@@ -103,7 +105,8 @@ public class WebSocketAcpAgentTransport implements AcpAgentTransport {
103105

104106
private Consumer<Throwable> exceptionHandler = t -> logger.error("Transport error", t);
105107

106-
private volatile Session clientSession;
108+
/** The connected client's session; null while no client is connected. */
109+
private volatile @Nullable Session clientSession;
107110

108111
private Duration idleTimeout = Duration.ofMinutes(30);
109112

@@ -173,7 +176,8 @@ public Mono<Void> start(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> han
173176
handleIncomingMessages(handler);
174177

175178
// Create and configure Jetty server
176-
server = new Server();
179+
Server server = new Server();
180+
this.server = server;
177181
ServerConnector connector = new ServerConnector(server);
178182
connector.setPort(port);
179183
server.addConnector(connector);
@@ -278,6 +282,7 @@ public Mono<Void> closeGracefully() {
278282
if (currentSession != null && currentSession.isOpen()) {
279283
currentSession.close();
280284
}
285+
Server server = this.server;
281286
if (server != null) {
282287
server.stop();
283288
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright 2025-2026 the original author or authors.
3+
*/
4+
5+
/**
6+
* The Jetty WebSocket agent transport (acp-websocket-jetty); this module's part of the package shared with acp-core.
7+
*
8+
* <p>
9+
* The package is null-marked: every type is non-null unless annotated
10+
* {@link org.jspecify.annotations.Nullable @Nullable}.
11+
* </p>
12+
*/
13+
@NullMarked
14+
package com.agentclientprotocol.sdk.agent.transport;
15+
16+
import org.jspecify.annotations.NullMarked;

0 commit comments

Comments
 (0)