-
Notifications
You must be signed in to change notification settings - Fork 11
netty pipeline
Verdict: one static-order pipeline per channel. Every registered Protocol's handlers sit in it unconditionally and no-op for other versions β no dynamic surgery except the initβoperation handler swap.
NettyServer NettyServer
- I/O engine
AUTOβNettyIoEngine.detect()order io_uring > epoll > kqueue > NIO, reflective, cached per JVMNettyIoEngine#IO_URING,NettyIoEngine#detect. - Event loops = platform threads
netty-io(native transports pin anyway)NettyServer#NettyServer. -
SO_BACKLOG 1024,TCP_NODELAY,SO_KEEPALIVE, write watermark 32K/128KNettyServer#NettyServer. -
stopAccepting()closes server channel only;close()closes children + event loops (graceful 0..3s)NettyServer#stopAccepting.
McpChannelInitializer.initChannel McpChannelInitializer
| # | Name | Handler | Rejects / does |
|---|---|---|---|
| 1 | flush |
FlushConsolidationHandler |
batch flushes |
| β | logger |
LoggingHandler |
only if logger dev.tachyonmcp.transport.netty.channel DEBUG (McpChannelInitializer#CHANNEL_LOGGER_NAME) |
| 2 | http |
HttpServerCodec |
|
| β | session-touch |
SessionTouchHandler |
added lazily after http (ahead of http-pipelining) when session bound; every outbound write touch()es session SessionTouchHandler#install
|
| 3 | http-pipelining |
HttpPipeliningGate (per channel) |
one request in flight per connection: a pipelined request (and its content) is queued until the previous response's LastHttpContent is written, so responses leave in request order (RFC 9112 Β§9.3.2) β errors, JSON, 202 and POST-SSE alike. 1xx doesn't complete a request. A final non-keep-alive response drops the queue and every later request: nothing runs unanswered HttpPipeliningGate#write. Cap NetworkConfig#maxPipelinedRequests (default 16, 0 = no pipelining) queued requests: one more β it and every later read are released unrun, and once the ones ahead are answered it gets 429 Too Many Requests + Connection: close (no CORS: it never passed the guards), then the channel closes HttpPipeliningGate#channelRead, HttpPipeliningGate#drain. Owns autoRead β π Backpressure |
| 4 | http-keep-alive |
HttpServerKeepAliveHandler |
honors Connection; responses set keep-alive intent |
| 5 | dns-rebinding |
DnsRebindingProtectionHandler |
403 β security-guards |
| 6 | mcp-endpoint |
EndpointValidatorHandler |
404 path β endpoint (trailing /, query ignored) EndpointValidatorHandler#channelRead. Only path check: every later handler and Protocol#matches trust it, so a custom endpointPath works end to end. Ahead of CORS β other paths get no CORS grant |
| β | cors-mcp-param |
CorsPreflightHandler |
just before cors: canonicalizes the preflight Origin for Netty during the synchronous call, applies its original CorsDecision (including Vary on misses), then clears call-scoped state; appends requested Mcp-Param-<token> names to a granted preflight's Access-Control-Allow-Headers (no *) CorsPreflightHandler#write
|
| 7 | cors |
TachyonCorsHandler |
always (non-null NettyServerConfig#corsConfig, default NettyServerConfig#defaultCorsConfig); answers preflights synchronously with Netty's method/header logic and CorsPreflightHandler's request decision; write() is a pass-through β writers apply the request's own CorsDecision (TachyonCorsHandler#decide) β security-guards
|
| 8 | mcp-header-guard |
McpHeaderGuardHandler |
400 duplicate singleton MCP header (incl. SEP-2243 mirrors); body-independent, so it runs pre-aggregation McpHeaderGuardHandler#hasDuplicateSingleton
|
| 9 | protocol-version |
ProtocolVersionHandler |
every POST: resolve protocol, bind ctx, or flag unsupported. Flag is a channel attr, cleared on every request: a flagged request refused before #14 (aggregator 413 keeps keep-alive open) must not reject the next one on the connection ProtocolVersionHandler#channelRead
|
| 10 | accept-header |
AcceptValidationHandler#INSTANCE |
406 |
| 11 | content-type |
ContentTypeValidationHandler#INSTANCE |
415 JSON-RPC -32600 on POST without application/json: a CORS "simple" request no preflight gated. Every POST past mcp-endpoint, no path match ContentTypeValidationHandler#channelRead
|
| 12 | stateless-mcp |
StatelessValidatorHandler |
only stateless server: 404 on session/Last-Event-ID headers, 405 DELETE |
| 13 | http-aggregator |
CorsHttpObjectAggregator(maxContentLength) |
413/417 with the request's CORS decision; Netty's close rule kept (CorsHttpObjectAggregator#handleOversizedMessage). Expect rejects are decorated through CorsHttpObjectAggregator#newContinueResponse using an independent copy; successful 100 Continue is unchanged |
| 14 | unsupported-protocol-version |
UnsupportedProtocolVersionHandler |
400 JSON-RPC error with body id + supported list UnsupportedProtocolVersionHandler#channelRead
|
| 15 | interaction |
InteractionHandler |
fallback protocol resolve for GET/DELETE; lifecycle events β ctx InteractionHandler#userEventTriggered
|
| 16 | idle |
IdleStateHandler |
if reader/writer idle > 0 |
| 17 | mcp-header-match |
McpHeaderMatchHandler |
SEP-2243 mirror agreement vs body, every version, ungated McpHeaderMatchHandler#channelRead
|
| 18 | mcp-<ver>-* |
Protocol.requestHandlers(server) for each protocol |
2025: none; 2026: RequestValidationHandler (_meta/removed methods) β RequiredHeadersHandler (mirror presence) β ExtensionNegotiationHandler
|
| 19 | mcp-phase-init |
McpInitializationHandler (per channel) |
first request |
| 20 | lifecycle |
LifecyclePipelineCoordinator |
replaces mcp-phase-init (phase 19) β mcp-phase-operations
|
| β | customizer | ServerBuilder.pipelineCustomizer |
user hook, runs last InteractionHandler
|
-
InteractionEventsealed:OperationStarted(session?),ShutdownStarted(sessionId?),ShutdownCompleteInteractionEvent. -
LifecyclePipelineCoordinatoronOperationStartedβpipeline.replace(init, ops, new McpOperationHandler)LifecyclePipelineCoordinator#userEventTriggered; onShutdownStartedβMcpHandlerManager.onShutdownStartedremoves session on executorMcpHandlerManager#onShutdownStarted. - POST without session id stays under the init handler during dispatch:
initializefiresOperationStarted(localSession)before writing its response; other requests dispatch without a phase swap McpInitializationHandler#handleRequest, McpInitializationHandler#dispatchPreSessionRequest. Session-header requests, GET and DELETE forward to operations; OPTIONS answers directly McpInitializationHandler#forwardToOperationHandler. Both phases exempt heartbeat-enabled SSE from reader-idle closure only β sse-streams. - A pipelined request can't race the swap:
http-pipeliningholds it untilinitialize's response is written, afterOperationStartedfired. - Init channel closed abruptly with a session β
ShutdownStartedβ session removedInteractionEvent.
| Attr | Key | Proof |
|---|---|---|
ChannelContext (protocol, session, lifecycle, enabled extensions, attrs) |
InteractionHandler.INTERACTION_CONTEXT_KEY |
InteractionHandler#logger |
Session |
tachyonSession |
ChannelHandlerUtils#SESSION_KEY |
| rejected flag (drop rest of request) | tachyonRequestRejected |
ChannelHandlerUtils |
| unsupported version | unsupportedProtocolVersion |
ProtocolVersionHandler.java (UNSUPPORTED_VERSION_KEY) |
| first abnormal close cause |
closeFailure (setIfAbsent) |
ChannelHandlerUtils#markCloseFailure |
| SSE heartbeat active/future | sseHeartbeatActive |
SseHeartbeat |
Written by PostSseStream#closeOnWriteFailure, SseHeartbeat#send and McpOperationHandler#exceptionCaught β sse-streams.
HttpPipeliningGate is the single autoRead owner: autoRead = writable && nothing queued HttpPipeliningGate#channelWritabilityChanged. Reads stop on the first queued pipelined request, so the queue holds at most one read's worth; clients that don't pipeline keep autoRead (and disconnect detection) untouched HttpPipeliningGate#channelRead. Session.send drops (returns false) when connection not writable β event stays in log for replay Session#send.
Sustained non-writability on a heartbeat stream ends in writer idle, which both phases honor SseHeartbeat#ignoresIdle β sse-streams.
ChannelHandlerUtils tachyon-core/src/main/java/dev/tachyonmcp/core/transport/netty/ChannelHandlerUtils.java: sendPlainTextAndClose, sendResponseAndClose (keep-alive false β Connection: close), sendAcceptedAsync (202), completeOn(future, transportCompletion), isRefused (RejectedExecution β 503 "Server shutting down"), captureInitRequest (detached headers copy for custom SessionIdGenerator, only if readsRequest()) SessionIdGenerator.
rejectWithServerError(ctx, req, id, error) ChannelHandlerUtils#rejectWithServerError is the shared JSON-RPC rejection for post-aggregation validators: maps a ServerError through the negotiated protocol's responseMapper, so the same call answers 400/-32020 on 2026-07-28 and 200/-32001 on 2025-11-25. Releases req.
Related: request-lifecycle, security-guards, sse-streams.
π source .llm-wiki/concepts/netty-pipeline.md Β· updated 2026-09-24 Β· verified at d2a0bdba Β· tags [concept, transport, netty]
π§ Start
βοΈ Concepts (cross-cutting)
- request-lifecycle
- netty-pipeline
- protocol-versions
- sessions
- sse-streams
- feature-registries
- tasks
- extensions
- json-layer
- errors
- concurrency
- declarative-configuration
- configuration
- security-guards
- observability
- api-stability
π¦ Modules