001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import com.google.errorprone.annotations.CanIgnoreReturnValue; 020import com.google.errorprone.annotations.CheckReturnValue; 021import com.soklet.internal.mcp.protocol.McpServerRuntimeBridge.SimulationSession; 022import com.soklet.internal.microhttp.StreamLifecycleCoordinator; 023 024import com.soklet.SseRequestResult.HandshakeAccepted; 025import com.soklet.SseRequestResult.HandshakeRejected; 026import com.soklet.annotation.SseEventSource; 027import com.soklet.exception.IllegalRequestException; 028import com.soklet.internal.spring.LinkedCaseInsensitiveMap; 029import com.soklet.internal.streaming.ManagedResponseStream; 030import com.soklet.internal.streaming.ManagedSseLifecycle; 031import com.soklet.internal.streaming.PublisherResponseStream; 032import org.jspecify.annotations.NonNull; 033import org.jspecify.annotations.Nullable; 034 035import javax.annotation.concurrent.NotThreadSafe; 036import javax.annotation.concurrent.ThreadSafe; 037import java.io.ByteArrayOutputStream; 038import java.io.IOException; 039import java.io.Reader; 040import java.lang.reflect.InvocationTargetException; 041import java.nio.ByteBuffer; 042import java.nio.CharBuffer; 043import java.nio.charset.Charset; 044import java.nio.charset.CharsetEncoder; 045import java.nio.charset.CharacterCodingException; 046import java.nio.charset.CoderResult; 047import java.nio.charset.StandardCharsets; 048import java.time.Duration; 049import java.time.Instant; 050import java.util.ArrayList; 051import java.util.Collections; 052import java.util.EnumSet; 053import java.util.HashMap; 054import java.util.LinkedHashMap; 055import java.util.List; 056import java.util.LinkedHashSet; 057import java.util.HashSet; 058import java.util.Locale; 059import java.util.Map; 060import java.util.Map.Entry; 061import java.util.Objects; 062import java.util.Optional; 063import java.util.Set; 064import java.util.concurrent.ConcurrentHashMap; 065import java.util.concurrent.CountDownLatch; 066import java.util.concurrent.RejectedExecutionException; 067import java.util.concurrent.CompletionStage; 068import java.util.concurrent.TimeUnit; 069import java.util.concurrent.atomic.AtomicBoolean; 070import java.util.concurrent.atomic.AtomicReference; 071import java.util.concurrent.locks.ReentrantLock; 072import java.util.function.BiConsumer; 073import java.util.function.Consumer; 074import java.util.function.Function; 075import java.util.function.Supplier; 076import java.util.stream.Collectors; 077 078import static com.soklet.internal.ObjectIdentity.sameInstance; 079import static com.soklet.Utilities.emptyByteArray; 080import static java.lang.String.format; 081import static java.util.Objects.requireNonNull; 082 083/** 084 * Owns one ready-to-terminal Soklet lifecycle and its configured HTTP, SSE, 085 * and MCP transports. {@link #run(SokletConfig)} returns 086 * after all configured transports reach readiness; each instance is one-shot. 087 * Every run registers a JVM shutdown hook by default. An embedding host can 088 * disable only that registration through 089 * {@link SokletConfig.Builder#jvmShutdownHookEnabled(Boolean)}. Bounded application 090 * cleanup, terminal reporting, and process-registration release remain owned 091 * by the instance until finalization finishes, even when nobody waits. 092 * <p> 093 * <pre>{@code 094 * SokletConfig config = SokletConfig.withHttpServer(HttpServer.fromPort(8080)).build(); 095 * Soklet soklet = Soklet.run(config); 096 * ShutdownResult result = soklet.awaitShutdown(); 097 * }</pre> 098 * To stop programmatically, call {@link #requestShutdown()} and then await 099 * completion from an independent owning thread. An embedding host must finish 100 * Soklet shutdown before closing shared databases, telemetry, or logging. 101 * JVM hooks execute concurrently and do not establish resource ordering. 102 * With the automatic hook disabled, the host must prevent new runs once 103 * termination begins and request and synchronously await shutdown before its 104 * own shutdown hook returns. Any deregistration or delay belongs before that 105 * shutdown request; shared dependencies remain available until shutdown succeeds. 106 * <p> 107 * Required application cleanup belongs in bounded {@link ShutdownCleanup}, 108 * rather than post-wait code or observational lifecycle callbacks: during JVM 109 * shutdown the JVM need not let those callers finish. Cleanup is eligible only 110 * after complete core shutdown; a cleanup timeout does not forcibly stop its code. 111 * Running built-in listeners and the lifecycle owner retain JVM liveness until 112 * shutdown and finalization; custom transports own their listener-thread policy. 113 * <p> 114 * Soklet also offers an off-network {@link Simulator} through 115 * {@link SokletSimulator}, useful for integration testing. 116 * <p> 117 * Given a <em>Resource Method</em>... 118 * <pre>{@code public class HelloResource { 119 * @GET("/hello") 120 * public String hello(@QueryParameter String name) { 121 * return String.format("Hello, %s", name); 122 * } 123 * }}</pre> 124 * ...we might test it like this: 125 * <pre>{@code @Test 126 * public void integrationTest() { 127 * // Instead of running on a real HTTP server that listens on a port, 128 * // a non-network Simulator is provided against which you can 129 * // issue requests and receive responses. 130 * SokletSimulator.run(SimulatorConfig.builder().httpServer().build(), simulator -> { 131 * // Construct a request 132 * Request request = Request.withPath(HttpMethod.GET, "/hello") 133 * .queryParameters(Map.of("name", List.of("Mark"))) 134 * .build(); 135 * 136 * // Perform the request and get a handle to the response 137 * HttpRequestResult result = simulator.performHttpRequest(request); 138 * MarshaledResponse marshaledResponse = result.getMarshaledResponse(); 139 * 140 * // Verify status code 141 * Integer expectedCode = 200; 142 * Integer actualCode = marshaledResponse.getStatusCode(); 143 * assertEquals(expectedCode, actualCode, "Bad status code"); 144 * 145 * // Verify response body 146 * marshaledResponse.getBody().ifPresentOrElse(body -> { 147 * String expectedBody = "Hello, Mark"; 148 * byte[] bytes = ((MarshaledResponseBody.Bytes) body).getBytes(); 149 * String actualBody = new String(bytes, StandardCharsets.UTF_8); 150 * assertEquals(expectedBody, actualBody, "Bad response body"); 151 * }, () -> { 152 * Assertions.fail("No response body"); 153 * }); 154 * }); 155 * }}</pre> 156 * <p> 157 * The {@link Simulator} also supports Server-Sent Events. 158 * <p> 159 * Integration testing documentation is available at <a href="https://www.soklet.com/docs/testing">https://www.soklet.com/docs/testing</a>. 160 * 161 * @author <a href="https://www.revetkn.com">Mark Allen</a> 162 */ 163@ThreadSafe 164public final class Soklet { 165 /** 166 * Application work whose return or failure ends one running Soklet scope. 167 * The action runs once, on the calling thread after readiness, without a 168 * lifecycle handle. Checked failures retain their application-selected type. 169 * The action itself has no imposed timeout and is not interrupted by Soklet 170 * when a transport stops or JVM shutdown begins. 171 * 172 * @param <E> throwable type selected by the action 173 */ 174 @NotThreadSafe 175 @FunctionalInterface 176 public interface RunAction<E extends @NonNull Throwable> { 177 /** 178 * Performs work while the enclosing caller owns the running application. 179 * 180 * @throws E if application work fails 181 */ 182 void run() throws E; 183 } 184 185 @NonNull 186 private static final Map<@NonNull String, @NonNull List<@NonNull String>> DEFAULT_ACCEPTED_HANDSHAKE_HEADERS; 187 188 static { 189 // Generally speaking, we always want these headers for SSE streaming responses. 190 // Users can override if they think necessary 191 LinkedCaseInsensitiveMap<List<String>> defaultAcceptedHandshakeHeaders = new LinkedCaseInsensitiveMap<>(4); 192 defaultAcceptedHandshakeHeaders.put("Content-Type", List.of("text/event-stream; charset=UTF-8")); 193 defaultAcceptedHandshakeHeaders.put("Cache-Control", List.of("no-cache", "no-transform")); 194 defaultAcceptedHandshakeHeaders.put("Connection", List.of("keep-alive")); 195 defaultAcceptedHandshakeHeaders.put("X-Accel-Buffering", List.of("no")); 196 197 DEFAULT_ACCEPTED_HANDSHAKE_HEADERS = Collections.unmodifiableMap(defaultAcceptedHandshakeHeaders); 198 } 199 200 /** 201 * Constructs an internal lifecycle for transport-isolated simulation and 202 * low-level lifecycle composition. Claims transport identities immediately. 203 * 204 * @param sokletConfig configuration that drives the Soklet system 205 * @return a Soklet instance 206 * @throws TransportOwnershipException if a configured transport identity 207 * has already been claimed by another lifecycle 208 */ 209 @NonNull 210 static Soklet fromConfig(@NonNull SokletConfig sokletConfig) { 211 requireNonNull(sokletConfig); 212 return new Soklet(sokletConfig, LifecycleRuntimeServices.system(), 213 ignored -> { }); 214 } 215 216 @NonNull 217 static Soklet fromConfig(@NonNull SokletConfig sokletConfig, 218 @NonNull LifecycleRuntimeServices services, 219 @NonNull Consumer<@NonNull InternalLifecycleCoreSnapshot> coreSnapshotPublisher) { 220 return new Soklet(requireNonNull(sokletConfig), requireNonNull(services), 221 requireNonNull(coreSnapshotPublisher)); 222 } 223 224 @NonNull 225 private final SokletConfig sokletConfig; 226 @NonNull 227 private final ReentrantLock lock; 228 @NonNull 229 private final CountDownLatch awaitShutdownLatch; 230 @NonNull 231 private final AtomicReference<@NonNull CountDownLatch> 232 awaitShutdownLatchReference; 233 @NonNull 234 private final SokletFrameworkSetup frameworkSetup; 235 @NonNull 236 private final SokletDirectLifecycle directLifecycle; 237 @NonNull 238 private final CompletionStage<@NonNull ShutdownResult> coreAwaitStage; 239 private volatile @Nullable SokletRunCompletion runCompletion; 240 241 /** 242 * Creates a Soklet instance with the given configuration. 243 * 244 * @param sokletConfig configuration that drives the Soklet system 245 */ 246 private Soklet(@NonNull SokletConfig sokletConfig, 247 @NonNull LifecycleRuntimeServices services, 248 @NonNull Consumer<@NonNull InternalLifecycleCoreSnapshot> coreSnapshotPublisher) { 249 requireNonNull(sokletConfig); 250 251 this.sokletConfig = sokletConfig; 252 this.lock = new ReentrantLock(); 253 this.awaitShutdownLatch = new CountDownLatch(1); 254 this.awaitShutdownLatchReference = 255 new AtomicReference<>(this.awaitShutdownLatch); 256 this.frameworkSetup = new SokletFrameworkSetup(sokletConfig); 257 this.directLifecycle = new SokletDirectLifecycle(this, sokletConfig, 258 this.frameworkSetup, requireNonNull(services), 259 requireNonNull(coreSnapshotPublisher)); 260 this.coreAwaitStage = this.directLifecycle.shutdownStage().thenApply(result -> { 261 this.directLifecycle.throwIfUnsuccessfulShutdown(result); 262 return result; 263 }); 264 } 265 266 /** 267 * Starts one lifecycle, returning only after every configured transport has 268 * reached readiness. By default owns a JVM shutdown hook until full finalization; 269 * {@link SokletConfig.Builder#jvmShutdownHookEnabled(Boolean)} may disable only 270 * that automatic registration. Programmatic shutdown, cleanup, and lifecycle 271 * owner liveness remain unchanged. The host then owns signal-driven shutdown 272 * and must request and synchronously await it before its own hook returns. 273 * Startup failure or cancellation throws 274 * synchronously after attempted teardown; interruption before readiness 275 * requests cancellation and the caller's interrupt status is restored. 276 * Cancellation competes atomically with readiness. If readiness wins, the 277 * ready handle is returned with the interrupt status restored, and that 278 * startup interruption does not request shutdown of the running lifecycle. 279 * 280 * @param sokletConfig configuration with unclaimed transport identities 281 * @return the ready lifecycle handle 282 * @throws TransportOwnershipException if a configured transport is already claimed 283 * @throws SokletStartupException if readiness is not reached 284 */ 285 @NonNull 286 public static Soklet run(@NonNull SokletConfig sokletConfig) { 287 return SokletProcessLifecycle.launch(requireNonNull(sokletConfig), null); 288 } 289 290 /** 291 * Starts a lifecycle with bounded application cleanup eligible after complete 292 * core shutdown. All arguments are validated before transports are claimed. 293 * When enabled, the JVM hook joins bounded core shutdown, cleanup, and reporting. 294 * Both await methods additionally observe process-registration release. 295 * 296 * @param sokletConfig configuration with unclaimed transport identities 297 * @param cleanup bounded synchronous application cleanup 298 * @return the ready lifecycle handle 299 * @throws TransportOwnershipException if a configured transport is already claimed 300 * @throws SokletStartupException if readiness is not reached 301 */ 302 @NonNull 303 public static Soklet run(@NonNull SokletConfig sokletConfig, 304 @NonNull ShutdownCleanup cleanup) { 305 return SokletProcessLifecycle.launch(requireNonNull(sokletConfig), 306 requireNonNull(cleanup)); 307 } 308 309 /** 310 * Starts Soklet, invokes the action after readiness, and requests shutdown 311 * when the action returns or throws. Joins full finalization before returning 312 * the immutable terminal result or propagating a failure. The action runs 313 * once on the calling thread; startup failure prevents its invocation. 314 * All arguments are validated before transports are claimed. 315 * The configuration's {@link SokletConfig#isJvmShutdownHookEnabled()} option 316 * applies to scoped execution as well; disabling the automatic hook does not 317 * disable teardown when the action returns or throws. 318 * <p> 319 * A callback failure remains the exact primary exception or Error. A 320 * concurrent finalization failure is suppressed on it using ordinary Java 321 * suppression semantics, after finalization has finished. Without a callback 322 * failure, finalization uses the same failure classification as 323 * {@link #awaitShutdown()}. A complete forced shutdown can return normally. 324 * <p> 325 * Finalization does not abandon shutdown when the caller is interrupted. 326 * An interrupt flag left set by the action is temporarily cleared for the 327 * join, and it and interruptions received while joining are restored before 328 * returning or throwing. An InterruptedException thrown by the action is 329 * propagated as that action's original failure. 330 * <p> 331 * The action has no imposed timeout. An external shutdown does not terminate 332 * it; the scoped invocation still needs the action to return or throw. 333 * 334 * @param sokletConfig configuration with unclaimed transport identities 335 * @param action work defining the scope's lifetime 336 * @param <E> throwable type selected by the action 337 * @return the immutable successful terminal result; it may be ignored 338 * @throws E if the action fails, after attempted shutdown and full finalization 339 * @throws SokletStartupException if startup does not reach readiness 340 * @throws SokletLifecycleException if finalization fails without an action failure 341 */ 342 @NonNull 343 public static <E extends @NonNull Throwable> ShutdownResult run( 344 @NonNull SokletConfig sokletConfig, 345 @NonNull RunAction<@NonNull E> action) throws E { 346 return runScoped(sokletConfig, null, action); 347 } 348 349 /** 350 * Runs one action-defined scope with bounded application cleanup. Shares the 351 * execution, error, and interruption semantics of 352 * {@link #run(SokletConfig, RunAction)}. Cleanup is eligible only after 353 * complete core shutdown, including when the action failed; incomplete core 354 * shutdown skips cleanup. All arguments are validated before transport claims. 355 * 356 * @param sokletConfig configuration with unclaimed transport identities 357 * @param cleanup bounded synchronous application cleanup 358 * @param action work defining the scope's lifetime 359 * @param <E> throwable type selected by the action 360 * @return the immutable successful terminal result; it may be ignored 361 * @throws E if the action fails, after attempted shutdown and full finalization 362 * @throws SokletStartupException if startup does not reach readiness 363 * @throws SokletLifecycleException if finalization fails without an action failure 364 */ 365 @NonNull 366 public static <E extends @NonNull Throwable> ShutdownResult run( 367 @NonNull SokletConfig sokletConfig, 368 @NonNull ShutdownCleanup cleanup, 369 @NonNull RunAction<@NonNull E> action) throws E { 370 return runScoped(sokletConfig, requireNonNull(cleanup), action); 371 } 372 373 @NonNull 374 private static <E extends @NonNull Throwable> ShutdownResult runScoped( 375 @NonNull SokletConfig sokletConfig, @Nullable ShutdownCleanup cleanup, 376 @NonNull RunAction<E> action) throws E { 377 SokletConfig exactConfig = requireNonNull(sokletConfig); 378 RunAction<E> exactAction = requireNonNull(action); 379 Soklet soklet = SokletProcessLifecycle.launch(exactConfig, cleanup); 380 Throwable actionFailure = null; 381 try { 382 exactAction.run(); 383 } catch (Throwable failure) { 384 actionFailure = failure; 385 } 386 387 boolean interrupted = Thread.interrupted(); 388 Throwable requestFailure = null; 389 Throwable finalizationFailure = null; 390 ShutdownResult result = null; 391 try { 392 try { 393 soklet.requestShutdown(); 394 } catch (Throwable failure) { 395 requestFailure = failure; 396 } 397 for (;;) { 398 try { 399 result = soklet.awaitShutdown(); 400 break; 401 } catch (InterruptedException interruption) { 402 interrupted = true; 403 } catch (Throwable failure) { 404 finalizationFailure = failure; 405 break; 406 } 407 } 408 } finally { 409 if (interrupted) 410 Thread.currentThread().interrupt(); 411 } 412 413 // Never acquire an application exception's monitor until the lifecycle 414 // has finished, and never combine failures while holding lifecycle locks. 415 if (finalizationFailure == null) 416 finalizationFailure = requestFailure; 417 else if (requestFailure != null && !sameInstance(finalizationFailure, requestFailure)) 418 finalizationFailure.addSuppressed(requestFailure); 419 if (actionFailure != null) { 420 if (finalizationFailure != null && !sameInstance(actionFailure, finalizationFailure)) 421 actionFailure.addSuppressed(finalizationFailure); 422 Soklet.<E>throwRunFailure(actionFailure); 423 } 424 if (finalizationFailure != null) 425 Soklet.<E>throwRunFailure(finalizationFailure); 426 return requireNonNull(result); 427 } 428 429 @SuppressWarnings("unchecked") 430 private static <E extends @NonNull Throwable> void throwRunFailure( 431 @NonNull Throwable failure) throws E { 432 throw (E) requireNonNull(failure); 433 } 434 435 /** 436 * Requests shutdown promptly without waiting. Repeated requests are safe. 437 * Handlers and callbacks may request shutdown but must return without waiting 438 * for work whose termination depends on their own return. 439 */ 440 public void requestShutdown() { 441 this.directLifecycle.requestShutdownIntent(); 442 } 443 444 /** 445 * Claims this one-shot lifecycle's sole start attempt and returns only after 446 * every configured transport is globally ready. 447 * If the calling thread is already interrupted, startup is canceled before 448 * readiness and fails with {@link SokletStartupException}; its lifecycle 449 * result records {@link StartupDisposition#CANCELED}. 450 * 451 * @throws SokletStartupException if startup ends before readiness, carrying 452 * the exact immutable lifecycle result 453 * @throws IllegalStateException if another start call already claimed this 454 * lifecycle attempt 455 */ 456 void start() { 457 this.directLifecycle.start(); 458 } 459 460 /** 461 * Publishes Soklet-wide shutdown intent promptly and returns the one cached, 462 * read-only completion stage for this lifecycle attempt. 463 * <p> 464 * This method does not install or own process hooks, operating-system signal 465 * handlers, or standard-input listeners; direct lifecycle callers own those 466 * integrations. 467 * <p> 468 * Handlers, lifecycle/metric callbacks and transport-owned work may request 469 * shutdown here, but must return without joining this stage or waiting on a 470 * future derived from it. Shutdown can be waiting for that same work to exit; 471 * a self-wait can consume the shutdown budget and publish an incomplete result. 472 * Wait for completion from the owning process or another independent thread. 473 * 474 * @return cached shutdown-result stage 475 */ 476 @NonNull 477 CompletionStage<@NonNull ShutdownResult> shutdown() { 478 return this.directLifecycle.shutdown(); 479 } 480 481 /** 482 * Waits for full shutdown, bounded application cleanup, terminal reporting, 483 * and process-registration release, without requesting shutdown. This wait 484 * and {@link #awaitShutdownAsync()} expose the same immutable result or underlying 485 * lifecycle failure; the asynchronous stage wraps failures in 486 * {@link java.util.concurrent.CompletionException}. 487 * 488 * @return the exact immutable terminal result 489 * @throws InterruptedException if this wait is interrupted; the lifecycle continues 490 * @throws SokletLifecycleException if the lifecycle or eligible cleanup fails 491 * @throws IllegalStateException if tracked lifecycle work attempts to join itself 492 */ 493 @NonNull 494 @CanIgnoreReturnValue 495 public ShutdownResult awaitShutdown() throws InterruptedException { 496 SokletRunCompletion completion = this.runCompletion; 497 if (completion != null) 498 return completion.await(); 499 ShutdownResult result = awaitCoreShutdown(); 500 this.directLifecycle.throwIfUnsuccessfulShutdown(result); 501 return result; 502 } 503 504 /** 505 * Observes the same full terminal outcome as {@link #awaitShutdown()} without 506 * blocking or requesting shutdown. Returns one cached read-only stage; no 507 * waiting thread is created by this method. Lifecycle failures complete it 508 * exceptionally, with the underlying lifecycle failure wrapped in 509 * {@link java.util.concurrent.CompletionException}. Mutating a future derived 510 * from it cannot alter the lifecycle. 511 * Callbacks must not join this stage from work that shutdown must drain. 512 * The daemon completion handoff does not keep the JVM alive, including during 513 * normal programmatic shutdown after {@code main} returns. A host composing 514 * asynchronous cleanup must retain process liveness until its dependent stages 515 * finish. Put required standalone application cleanup in {@link ShutdownCleanup}. 516 * 517 * @return cached full-finalization completion stage 518 */ 519 @NonNull 520 @CheckReturnValue 521 public CompletionStage<@NonNull ShutdownResult> awaitShutdownAsync() { 522 SokletRunCompletion completion = this.runCompletion; 523 return completion == null ? this.coreAwaitStage : completion.async(); 524 } 525 526 @NonNull 527 ShutdownResult awaitCoreShutdown() throws InterruptedException { 528 return this.directLifecycle.awaitPublicCompletion(); 529 } 530 531 void attachRunCompletion(@NonNull SokletRunCompletion completion) { 532 this.runCompletion = requireNonNull(completion); 533 } 534 535 /** 536 * Nonpublic "informal" implementation of {@link com.soklet.HttpServer.RequestHandler} so Soklet does not need to expose {@code handleRequest} publicly. 537 * Reasoning: users of this library should never call {@code handleRequest} directly - it should only be invoked in response to events 538 * provided by a {@link HttpServer} or {@link SseServer} implementation. 539 */ 540 void handleRequest(@NonNull Request request, 541 @NonNull ServerType serverType, 542 @NonNull Consumer<@NonNull HttpRequestResult> requestResultConsumer) { 543 try (LifecycleExecutionContext.Scope ignored = 544 this.directLifecycle.enterExecution()) { 545 handleRequestWithinLifecycle(request, serverType, 546 requestResultConsumer); 547 } 548 } 549 550 private void handleRequestWithinLifecycle(@NonNull Request request, 551 @NonNull ServerType serverType, 552 @NonNull Consumer<@NonNull HttpRequestResult> requestResultConsumer) { 553 requireNonNull(request); 554 requireNonNull(serverType); 555 requireNonNull(requestResultConsumer); 556 557 long processingStartedNanos = System.nanoTime(); 558 559 SokletConfig sokletConfig = getSokletConfig(); 560 ResourceMethodResolver resourceMethodResolver = sokletConfig.getResourceMethodResolver(); 561 ResponseMarshaler responseMarshaler = sokletConfig.getResponseMarshaler(); 562 ResponseFinalizationToken postProcessingToken = newPostProcessingToken(); 563 LifecycleObserver lifecycleObserver = sokletConfig.getAggregateLifecycleObserver(); 564 RequestInterceptor requestInterceptor = sokletConfig.getRequestInterceptor(); 565 MetricsCollector metricsCollector = sokletConfig.getMetricsCollector(); 566 567 // Holders to permit mutable effectively-final variables 568 AtomicReference<@Nullable MarshaledResponse> marshaledResponseHolder = new AtomicReference<>(); 569 AtomicReference<@Nullable Throwable> resourceMethodResolutionExceptionHolder = new AtomicReference<>(); 570 AtomicReference<@NonNull Request> requestHolder = new AtomicReference<>(request); 571 // The handler's effective request may be replaced by either interceptor hook. Paired 572 // lifecycle/metrics callbacks retain the original per-dispatch identity, as do HTTP 573 // stream handles, so observers never have to correlate caller-controlled request IDs. 574 AtomicReference<@Nullable ResourceMethod> resourceMethodHolder = new AtomicReference<>(); 575 AtomicReference<@Nullable HttpRequestResult> requestResultHolder = new AtomicReference<>(); 576 AtomicReference<@Nullable Throwable> requestHandlingFailureHolder = new AtomicReference<>(); 577 Consumer<MarshaledResponse> captureHeadRepresentation = representation -> { 578 if (serverType != ServerType.HTTP || requestHolder.get().getHttpMethod() != HttpMethod.HEAD) 579 return; 580 MarshaledResponseBody compressionBody = representation.getBody() 581 .filter(body -> body instanceof MarshaledResponseBody.Bytes || body instanceof MarshaledResponseBody.ByteBuffer) 582 .orElse(null); 583 Long bodyLength = representation.getBody().map(MarshaledResponseBody::getLength).orElse(null); 584 requestResultHolder.updateAndGet(result -> result == null 585 ? HttpRequestResult.withMarshaledResponse(representation).resourceMethod(resourceMethodHolder.get()) 586 .headResponseCompressionBody(compressionBody).headResponseBodyLength(bodyLength).build() 587 : result.copy().marshaledResponse(representation).headResponseCompressionBody(compressionBody) 588 .headResponseBodyLength(bodyLength).finish()); 589 }; 590 591 // Holders to permit mutable effectively-final state tracking 592 AtomicBoolean willStartResponseWritingCompleted = new AtomicBoolean(false); 593 AtomicBoolean didFinishResponseWritingCompleted = new AtomicBoolean(false); 594 AtomicBoolean didFinishRequestHandlingCompleted = new AtomicBoolean(false); 595 AtomicBoolean didInvokeWrapRequestConsumer = new AtomicBoolean(false); 596 AtomicBoolean wrapRequestScopeOpen = new AtomicBoolean(true); 597 Thread wrapRequestThread = Thread.currentThread(); 598 599 List<Throwable> throwables = new ArrayList<>(10); 600 601 // A built-in transport can replace the logical response before commitment. 602 // Keep write/finish observation aligned with that finite wire response. 603 HttpResponseStreamObservation streamObservation = new HttpResponseStreamObservation(request, processingStartedNanos); 604 HttpTransportResponseFeedback feedback = HttpTransportResponseFeedback.forConsumer(requestResultConsumer); 605 if (feedback != null) 606 feedback.claimLifecycleConsumer(); 607 Consumer<HttpRequestResult> transportResultConsumer = requestResult -> { 608 if (serverType == ServerType.SSE) 609 requestResult = requestResult.copy().effectiveSseRequest(requestHolder.get()).finish(); 610 if (serverType == ServerType.HTTP) 611 requestResult = requestResult.copy().responseStreamObservation(streamObservation).finish(); 612 Throwable requestHandlingFailure = requestHandlingFailureHolder.get(); 613 if (serverType == ServerType.SSE && requestHandlingFailure != null) 614 requestResult = requestResult.copy().sseHandshakeResult(null) 615 .requestHandlingFailure(requestHandlingFailure).finish(); 616 else if (serverType == ServerType.SSE 617 && requestResult.getSseHandshakeResult().orElse(null) instanceof SseHandshakeResult.Accepted 618 && !isCompatibleAcceptedSseResponse(requestResult.getMarshaledResponse())) 619 // An interceptor can replace an accepted handshake with a finite denial. 620 // The final response owns that decision in both live and simulated transports. 621 requestResult = requestResult.copy().sseHandshakeResult(null).finish(); 622 HttpTransportResponseOutcome compatibilityOutcome = HttpTransportResponseOutcome.accepted(); 623 try { 624 requestResultConsumer.accept(requestResult); 625 } catch (HttpTransportResponseFeedback.UnclaimedReplacement replacement) { 626 compatibilityOutcome = replacement.getOutcome(); 627 } finally { 628 // Consume feedback even when a decorating callback fails after delivery. 629 HttpTransportResponseOutcome outcome = feedback == null ? compatibilityOutcome : feedback.finish(); 630 if (outcome instanceof HttpTransportResponseOutcome.Accepted) 631 outcome = compatibilityOutcome; 632 switch (outcome) { 633 case HttpTransportResponseOutcome.Accepted ignored -> {} 634 case HttpTransportResponseOutcome.NotWritten notWritten -> notWritten.rethrowWriteFailure(); 635 case HttpTransportResponseOutcome.Replaced replacement -> { 636 marshaledResponseHolder.set(replacement.getMarshaledResponse()); 637 requestResultHolder.set(requestResult.copy().marshaledResponse(replacement.getMarshaledResponse()) 638 .headResponseCompressionBody(null).headResponseBodyLength(null).finish()); 639 replacement.getPreparationFailure().ifPresent(throwables::add); 640 replacement.rethrowWriteFailure(); 641 } 642 } 643 } 644 }; 645 646 Consumer<LogEvent> safelyLog = (logEvent -> { 647 try { 648 lifecycleObserver.didReceiveLogEvent(logEvent); 649 } catch (Throwable throwable) { 650 LifecycleObserverLogFallback.report(throwable); 651 throwables.add(throwable); 652 } 653 }); 654 655 BiConsumer<String, Consumer<MetricsCollector>> safelyCollectMetrics = (message, metricsInvocation) -> { 656 if (metricsCollector == null) 657 return; 658 659 try { 660 metricsInvocation.accept(metricsCollector); 661 } catch (Throwable throwable) { 662 safelyLog.accept(LogEvent.with(LogEventType.METRICS_COLLECTOR_FAILED, message) 663 .throwable(throwable) 664 .request(requestHolder.get()) 665 .resourceMethod(resourceMethodHolder.get()) 666 .marshaledResponse(marshaledResponseHolder.get()) 667 .build()); 668 } 669 }; 670 671 AtomicBoolean didStartRequestHandling = new AtomicBoolean(); 672 Runnable startRequestHandling = () -> { 673 if (!didStartRequestHandling.compareAndSet(false, true)) 674 return; 675 676 try { 677 lifecycleObserver.didStartRequestHandling(serverType, request, resourceMethodHolder.get()); 678 } catch (Throwable t) { 679 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_DID_START_REQUEST_HANDLING_FAILED, 680 format("An exception occurred while invoking %s::didStartRequestHandling", 681 LifecycleObserver.class.getSimpleName())) 682 .throwable(t) 683 .request(requestHolder.get()) 684 .resourceMethod(resourceMethodHolder.get()) 685 .build()); 686 687 throwables.add(t); 688 } 689 690 safelyCollectMetrics.accept( 691 format("An exception occurred while invoking %s::didStartRequestHandling", MetricsCollector.class.getSimpleName()), 692 (metricsInvocation) -> metricsInvocation.didStartRequestHandling(serverType, request, resourceMethodHolder.get())); 693 }; 694 695 requestHolder.set(request); 696 697 try { 698 try { 699 requestInterceptor.wrapRequest(serverType, request, (wrappedRequest) -> { 700 if (!wrapRequestScopeOpen.get() || !sameInstance(Thread.currentThread(), wrapRequestThread)) 701 throw new IllegalStateException("RequestInterceptor::wrapRequest must call requestProcessor synchronously before returning"); 702 if (!didInvokeWrapRequestConsumer.compareAndSet(false, true)) 703 throw new IllegalStateException("RequestInterceptor::wrapRequest must call requestProcessor exactly once"); 704 requestHolder.set(wrappedRequest); 705 706 try { 707 // Resolve after wrapping so path/method rewrites affect routing. 708 ResourceMethod resolvedResourceMethod = 709 resolveResourceMethodForRequest(requireNonNull(requestHolder.get()), resourceMethodResolver, serverType); 710 resourceMethodHolder.set(resolvedResourceMethod); 711 resourceMethodResolutionExceptionHolder.set(null); 712 } catch (Throwable t) { 713 safelyLog.accept(LogEvent.with(LogEventType.RESOURCE_METHOD_RESOLUTION_FAILED, "Unable to resolve Resource Method") 714 .throwable(t) 715 .request(requestHolder.get()) 716 .build()); 717 718 // If an exception occurs here, keep track of it - we will surface them after letting LifecycleObserver 719 // see that a request has come in. 720 throwables.add(t); 721 resourceMethodResolutionExceptionHolder.set(t); 722 resourceMethodHolder.set(null); 723 } 724 725 startRequestHandling.run(); 726 727 // Retain generated file bodies until the interceptor chooses its final response. 728 // The list allocates storage only for responses that actually own a channel. 729 List<MarshaledResponse> pendingResponseBodies = new ArrayList<>(0); 730 try { 731 AtomicBoolean didInvokeMarshaledResponseConsumer = new AtomicBoolean(false); 732 733 requestInterceptor.interceptRequest(serverType, requestHolder.get(), resourceMethodHolder.get(), (interceptorRequest) -> { 734 requestHolder.set(interceptorRequest); 735 736 try { 737 if (resourceMethodResolutionExceptionHolder.get() != null) 738 throw resourceMethodResolutionExceptionHolder.get(); 739 740 HttpRequestResult requestResult = toHttpRequestResult(requestHolder.get(), resourceMethodHolder.get(), serverType); 741 requestResultHolder.set(requestResult); 742 743 // The interceptor chooses the response before CORS, postprocessing and HEAD projection. 744 MarshaledResponse updatedMarshaledResponse = requestResult.getMarshaledResponse(); 745 return ResponseBodyCleanup.track(pendingResponseBodies, updatedMarshaledResponse); 746 } catch (Throwable t) { 747 requestHandlingFailureHolder.compareAndSet(null, t); 748 requestResultHolder.updateAndGet(result -> result == null ? null 749 : result.copy().headResponseCompressionBody(null).headResponseBodyLength(null).finish()); 750 if (!sameInstance(t, resourceMethodResolutionExceptionHolder.get())) { 751 throwables.add(t); 752 753 safelyLog.accept(LogEvent.with(LogEventType.REQUEST_PROCESSING_FAILED, 754 "An exception occurred while processing request") 755 .throwable(t) 756 .request(requestHolder.get()) 757 .resourceMethod(resourceMethodHolder.get()) 758 .build()); 759 } 760 761 // Unhappy path. Try to use configuration's exception response marshaler... 762 try { 763 MarshaledResponse marshaledResponse = responseMarshaler.forThrowable(requestHolder.get(), t, resourceMethodHolder.get()); 764 marshaledResponseHolder.set(marshaledResponse); 765 766 return ResponseBodyCleanup.track(pendingResponseBodies, marshaledResponse); 767 } catch (Throwable t2) { 768 throwables.add(t2); 769 770 safelyLog.accept(LogEvent.with(LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED, 771 "An exception occurred while trying to write an exception response") 772 .throwable(t2) 773 .request(requestHolder.get()) 774 .resourceMethod(resourceMethodHolder.get()) 775 .build()); 776 777 // The configuration's exception response marshaler failed - provide a failsafe response to recover 778 return ResponseBodyCleanup.track(pendingResponseBodies, provideFailsafeMarshaledResponse(requestHolder.get(), t2)); 779 } 780 } 781 }, (interceptorMarshaledResponse) -> { 782 requireNonNull(interceptorMarshaledResponse); 783 ResponseBodyCleanup.track(pendingResponseBodies, interceptorMarshaledResponse); 784 enforceMarshaledResponseStatusCode(interceptorMarshaledResponse, false); 785 didInvokeMarshaledResponseConsumer.set(true); 786 HttpRequestResult requestResult = requestResultHolder.get(); 787 if (serverType == ServerType.SSE && requestResult != null 788 && requestResult.getSseHandshakeResult().orElse(null) instanceof SseHandshakeResult.Accepted 789 && !isCompatibleAcceptedSseResponse(interceptorMarshaledResponse)) { 790 requestResult = requestResult.copy().sseHandshakeResult(null).finish(); 791 requestResultHolder.set(requestResult); 792 } 793 if (requestResult != null && !requestResult.getMarshaledResponse().getStatusCode() 794 .equals(interceptorMarshaledResponse.getStatusCode())) 795 requestResultHolder.set(requestResult.copy().headResponseCompressionBody(null).headResponseBodyLength(null).finish()); 796 marshaledResponseHolder.set(interceptorMarshaledResponse); 797 }); 798 799 if (!didInvokeMarshaledResponseConsumer.get()) { 800 requestResultHolder.set(null); 801 throw new IllegalStateException(format("%s::interceptRequest must call responseWriter", RequestInterceptor.class.getSimpleName())); 802 } 803 } catch (Throwable t) { 804 requestHandlingFailureHolder.compareAndSet(null, t); 805 throwables.add(t); 806 requestResultHolder.updateAndGet(result -> result == null ? null 807 : result.copy().headResponseCompressionBody(null).headResponseBodyLength(null).finish()); 808 809 try { 810 // In the event that an error occurs during processing of a RequestInterceptor method, for example 811 safelyLog.accept(LogEvent.with(LogEventType.REQUEST_INTERCEPTOR_INTERCEPT_REQUEST_FAILED, 812 format("An exception occurred while invoking %s::interceptRequest", RequestInterceptor.class.getSimpleName())) 813 .throwable(t) 814 .request(requestHolder.get()) 815 .resourceMethod(resourceMethodHolder.get()) 816 .build()); 817 818 MarshaledResponse marshaledResponse = responseMarshaler.forThrowable(requestHolder.get(), t, resourceMethodHolder.get()); 819 marshaledResponseHolder.set(marshaledResponse); 820 } catch (Throwable t2) { 821 throwables.add(t2); 822 823 safelyLog.accept(LogEvent.with(LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED, 824 format("An exception occurred while invoking %s::forThrowable when trying to write an exception response", ResponseMarshaler.class.getSimpleName())) 825 .throwable(t2) 826 .request(requestHolder.get()) 827 .resourceMethod(resourceMethodHolder.get()) 828 .build()); 829 830 marshaledResponseHolder.set(provideFailsafeMarshaledResponse(requestHolder.get(), t2)); 831 } 832 } finally { 833 // A broken exception marshaler must not turn missing response selection 834 // into a transport write failure or expose null responses to observers. 835 if (marshaledResponseHolder.get() == null) { 836 Throwable failure = requestHandlingFailureHolder.get(); 837 if (failure == null) 838 failure = new IllegalStateException("Request handling did not select a response"); 839 marshaledResponseHolder.set(provideFailsafeMarshaledResponse( 840 requestHolder.get() == null ? request : requestHolder.get(), failure)); 841 } 842 MarshaledResponse selectedResponse = marshaledResponseHolder.get(); 843 if (selectedResponse != null && requiresPostProcessing(selectedResponse, postProcessingToken)) { 844 try { 845 HttpRequestResult selectedResult = requestResultHolder.get(); 846 boolean acceptedSse = serverType == ServerType.SSE && selectedResult != null 847 && selectedResult.getSseHandshakeResult().orElse(null) instanceof SseHandshakeResult.Accepted; 848 MarshaledResponse finalized = ResponseBodyCleanup.transform(selectedResponse, 849 response -> applyCommonPropertiesToMarshaledResponse(requestHolder.get(), response, acceptedSse, captureHeadRepresentation, postProcessingToken)); 850 try { 851 if (acceptedSse) isCompatibleAcceptedSseResponse(finalized); 852 } catch (RuntimeException | Error failure) { 853 ResponseBodyCleanup.discard(finalized, null, failure); 854 throw failure; 855 } 856 marshaledResponseHolder.set(finalized); 857 } catch (Throwable failure) { 858 throwables.add(failure); 859 requestHandlingFailureHolder.compareAndSet(null, failure); 860 requestResultHolder.updateAndGet(result -> result == null ? null 861 : result.copy().headResponseCompressionBody(null).headResponseBodyLength(null).finish()); 862 safelyLog.accept(LogEvent.with(LogEventType.REQUEST_PROCESSING_FAILED, 863 "An exception occurred while finalizing response").throwable(failure) 864 .request(requestHolder.get()).resourceMethod(resourceMethodHolder.get()).build()); 865 try { 866 MarshaledResponse recovery = responseMarshaler.forThrowable(requestHolder.get(), failure, resourceMethodHolder.get()); 867 recovery = ResponseBodyCleanup.transform(recovery, response -> applyCommonPropertiesToMarshaledResponse( 868 requestHolder.get(), response, false, ignored -> {}, postProcessingToken, false)); 869 marshaledResponseHolder.set(recovery); 870 } catch (Throwable recoveryFailure) { 871 throwables.add(recoveryFailure); 872 safelyLog.accept(LogEvent.with(LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED, 873 "Unable to finalize exception response").throwable(recoveryFailure) 874 .request(requestHolder.get()).resourceMethod(resourceMethodHolder.get()).build()); 875 marshaledResponseHolder.set(provideFailsafeMarshaledResponse(requestHolder.get(), recoveryFailure)); 876 } 877 } 878 } 879 for (MarshaledResponse pendingResponse : pendingResponseBodies) { 880 try { 881 MarshaledResponse selected = marshaledResponseHolder.get(); 882 if (selected == null) pendingResponse.close(); 883 else marshaledResponseHolder.set(ResponseBodyCleanup.retain(pendingResponse, selected)); 884 } catch (Throwable cleanupFailure) { 885 throwables.add(cleanupFailure); 886 safelyLog.accept(LogEvent.with(LogEventType.REQUEST_PROCESSING_FAILED, 887 "Unable to release an abandoned response body") 888 .throwable(cleanupFailure).request(requestHolder.get()) 889 .resourceMethod(resourceMethodHolder.get()).build()); 890 } 891 } 892 pendingResponseBodies.clear(); 893 try { 894 try { 895 lifecycleObserver.willWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get()); 896 } catch (Throwable t) { 897 throwables.add(t); 898 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_WILL_WRITE_RESPONSE_FAILED, 899 format("An exception occurred while invoking %s::willWriteResponse", LifecycleObserver.class.getSimpleName())) 900 .throwable(t).request(requestHolder.get()).resourceMethod(resourceMethodHolder.get()) 901 .marshaledResponse(marshaledResponseHolder.get()).build()); 902 } finally { 903 willStartResponseWritingCompleted.set(true); 904 } 905 906 safelyCollectMetrics.accept( 907 format("An exception occurred while invoking %s::willWriteResponse", MetricsCollector.class.getSimpleName()), 908 (metricsInvocation) -> metricsInvocation.willWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get())); 909 910 Instant responseWriteStarted = Instant.now(); 911 912 try { 913 HttpRequestResult requestResult = requestResultHolder.get(); 914 915 if (requestResult != null) 916 transportResultConsumer.accept(requestResult.copy() 917 .marshaledResponse(requireNonNull(marshaledResponseHolder.get())).finish()); 918 else 919 transportResultConsumer.accept(HttpRequestResult.withMarshaledResponse(marshaledResponseHolder.get()) 920 .resourceMethod(resourceMethodHolder.get()) 921 .build()); 922 923 Instant responseWriteFinished = Instant.now(); 924 Duration responseWriteDuration = Duration.between(responseWriteStarted, responseWriteFinished); 925 926 try { 927 lifecycleObserver.didWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), responseWriteDuration); 928 } catch (Throwable t) { 929 throwables.add(t); 930 931 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_DID_WRITE_RESPONSE_FAILED, 932 format("An exception occurred while invoking %s::didWriteResponse", 933 LifecycleObserver.class.getSimpleName())) 934 .throwable(t) 935 .request(requestHolder.get()) 936 .resourceMethod(resourceMethodHolder.get()) 937 .marshaledResponse(marshaledResponseHolder.get()) 938 .build()); 939 } finally { 940 didFinishResponseWritingCompleted.set(true); 941 } 942 943 safelyCollectMetrics.accept( 944 format("An exception occurred while invoking %s::didWriteResponse", MetricsCollector.class.getSimpleName()), 945 (metricsInvocation) -> metricsInvocation.didWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), 946 marshaledResponseHolder.get(), responseWriteDuration)); 947 } catch (Throwable t) { 948 didFinishResponseWritingCompleted.set(true); 949 throwables.add(t); 950 951 Instant responseWriteFinished = Instant.now(); 952 Duration responseWriteDuration = Duration.between(responseWriteStarted, responseWriteFinished); 953 954 try { 955 lifecycleObserver.didFailToWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), responseWriteDuration, t); 956 } catch (Throwable t2) { 957 throwables.add(t2); 958 959 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_DID_WRITE_RESPONSE_FAILED, 960 format("An exception occurred while invoking %s::didFailToWriteResponse", 961 LifecycleObserver.class.getSimpleName())) 962 .throwable(t2) 963 .request(requestHolder.get()) 964 .resourceMethod(resourceMethodHolder.get()) 965 .marshaledResponse(marshaledResponseHolder.get()) 966 .build()); 967 } 968 969 safelyCollectMetrics.accept( 970 format("An exception occurred while invoking %s::didFailToWriteResponse", MetricsCollector.class.getSimpleName()), 971 (metricsInvocation) -> metricsInvocation.didFailToWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), 972 marshaledResponseHolder.get(), responseWriteDuration, t)); 973 } 974 } finally { 975 Duration processingDuration = Duration.ofNanos(Math.max(0L, 976 System.nanoTime() - processingStartedNanos)); 977 978 safelyCollectMetrics.accept( 979 format("An exception occurred while invoking %s::didFinishRequestHandling", MetricsCollector.class.getSimpleName()), 980 (metricsInvocation) -> metricsInvocation.didFinishRequestHandling(serverType, request, resourceMethodHolder.get(), marshaledResponseHolder.get(), processingDuration, Collections.unmodifiableList(throwables))); 981 982 MarshaledResponse finishedResponse = marshaledResponseHolder.get(); 983 streamObservation.completeHandling(serverType == ServerType.HTTP && finishedResponse != null && finishedResponse.isStreaming()); 984 985 try { 986 lifecycleObserver.didFinishRequestHandling(serverType, request, resourceMethodHolder.get(), marshaledResponseHolder.get(), processingDuration, Collections.unmodifiableList(throwables)); 987 } catch (Throwable t) { 988 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_DID_FINISH_REQUEST_HANDLING_FAILED, 989 format("An exception occurred while invoking %s::didFinishRequestHandling", 990 LifecycleObserver.class.getSimpleName())) 991 .throwable(t) 992 .request(requestHolder.get()) 993 .resourceMethod(resourceMethodHolder.get()) 994 .marshaledResponse(marshaledResponseHolder.get()) 995 .build()); 996 } finally { 997 didFinishRequestHandlingCompleted.set(true); 998 } 999 } 1000 } 1001 }); 1002 } finally { 1003 wrapRequestScopeOpen.set(false); 1004 } 1005 1006 if (!didInvokeWrapRequestConsumer.get()) 1007 throw new IllegalStateException(format("%s::wrapRequest must call requestProcessor", RequestInterceptor.class.getSimpleName())); 1008 } catch (Throwable t) { 1009 requestHandlingFailureHolder.compareAndSet(null, t); 1010 if (!didFinishRequestHandlingCompleted.get()) 1011 throwables.add(t); 1012 // If an error occurred during request wrapping, it's possible a response was never written/communicated back to LifecycleObserver. 1013 // Detect that here and inform LifecycleObserver accordingly. 1014 safelyLog.accept(LogEvent.with(LogEventType.REQUEST_INTERCEPTOR_WRAP_REQUEST_FAILED, 1015 format("An exception occurred while invoking %s::wrapRequest", 1016 RequestInterceptor.class.getSimpleName())) 1017 .throwable(t) 1018 .request(requestHolder.get()) 1019 .resourceMethod(resourceMethodHolder.get()) 1020 .marshaledResponse(marshaledResponseHolder.get()) 1021 .build()); 1022 1023 // Wrapping can fail before dispatch; keep start/finish callbacks paired for observers and metrics. 1024 startRequestHandling.run(); 1025 1026 requestResultHolder.updateAndGet(result -> result == null ? null 1027 : result.copy().headResponseCompressionBody(null).headResponseBodyLength(null).finish()); 1028 1029 // If we don't have a response, let the marshaler try to make one for the exception. 1030 // If that fails, use the failsafe. 1031 if (marshaledResponseHolder.get() == null) { 1032 try { 1033 MarshaledResponse marshaledResponse = responseMarshaler.forThrowable(requestHolder.get(), t, resourceMethodHolder.get()); 1034 marshaledResponse = ResponseBodyCleanup.transform(marshaledResponse, 1035 response -> applyCommonPropertiesToMarshaledResponse(requestHolder.get(), response, false, ignored -> {}, postProcessingToken, false)); 1036 marshaledResponseHolder.set(marshaledResponse); 1037 } catch (Throwable t2) { 1038 throwables.add(t2); 1039 1040 safelyLog.accept(LogEvent.with(LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED, 1041 format("An exception occurred during request wrapping while invoking %s::forThrowable", 1042 ResponseMarshaler.class.getSimpleName())) 1043 .throwable(t2) 1044 .request(requestHolder.get()) 1045 .resourceMethod(resourceMethodHolder.get()) 1046 .marshaledResponse(marshaledResponseHolder.get()) 1047 .build()); 1048 1049 marshaledResponseHolder.set(provideFailsafeMarshaledResponse(requestHolder.get(), t)); 1050 } 1051 } 1052 1053 if (!willStartResponseWritingCompleted.get()) { 1054 try { 1055 lifecycleObserver.willWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get()); 1056 } catch (Throwable t2) { 1057 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_WILL_WRITE_RESPONSE_FAILED, 1058 format("An exception occurred while invoking %s::willWriteResponse", 1059 LifecycleObserver.class.getSimpleName())) 1060 .throwable(t2) 1061 .request(requestHolder.get()) 1062 .resourceMethod(resourceMethodHolder.get()) 1063 .marshaledResponse(marshaledResponseHolder.get()) 1064 .build()); 1065 } 1066 1067 safelyCollectMetrics.accept( 1068 format("An exception occurred while invoking %s::willWriteResponse", MetricsCollector.class.getSimpleName()), 1069 (metricsInvocation) -> metricsInvocation.willWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get())); 1070 } 1071 1072 try { 1073 Instant responseWriteStarted = Instant.now(); 1074 1075 if (!didFinishResponseWritingCompleted.get()) { 1076 try { 1077 HttpRequestResult requestResult = requestResultHolder.get(); 1078 1079 if (requestResult != null) 1080 transportResultConsumer.accept(requestResult.copy() 1081 .marshaledResponse(requireNonNull(marshaledResponseHolder.get())).finish()); 1082 else 1083 transportResultConsumer.accept(HttpRequestResult.withMarshaledResponse(marshaledResponseHolder.get()) 1084 .resourceMethod(resourceMethodHolder.get()) 1085 .build()); 1086 1087 Instant responseWriteFinished = Instant.now(); 1088 Duration responseWriteDuration = Duration.between(responseWriteStarted, responseWriteFinished); 1089 1090 try { 1091 lifecycleObserver.didWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), responseWriteDuration); 1092 } catch (Throwable t2) { 1093 throwables.add(t2); 1094 1095 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_DID_WRITE_RESPONSE_FAILED, 1096 format("An exception occurred while invoking %s::didWriteResponse", 1097 LifecycleObserver.class.getSimpleName())) 1098 .throwable(t2) 1099 .request(requestHolder.get()) 1100 .resourceMethod(resourceMethodHolder.get()) 1101 .marshaledResponse(marshaledResponseHolder.get()) 1102 .build()); 1103 } 1104 1105 safelyCollectMetrics.accept( 1106 format("An exception occurred while invoking %s::didWriteResponse", MetricsCollector.class.getSimpleName()), 1107 (metricsInvocation) -> metricsInvocation.didWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), 1108 marshaledResponseHolder.get(), responseWriteDuration)); 1109 } catch (Throwable t2) { 1110 throwables.add(t2); 1111 1112 Instant responseWriteFinished = Instant.now(); 1113 Duration responseWriteDuration = Duration.between(responseWriteStarted, responseWriteFinished); 1114 1115 try { 1116 lifecycleObserver.didFailToWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), responseWriteDuration, t2); 1117 } catch (Throwable t3) { 1118 throwables.add(t3); 1119 1120 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_DID_WRITE_RESPONSE_FAILED, 1121 format("An exception occurred while invoking %s::didFailToWriteResponse", 1122 LifecycleObserver.class.getSimpleName())) 1123 .throwable(t3) 1124 .request(requestHolder.get()) 1125 .resourceMethod(resourceMethodHolder.get()) 1126 .marshaledResponse(marshaledResponseHolder.get()) 1127 .build()); 1128 } 1129 1130 safelyCollectMetrics.accept( 1131 format("An exception occurred while invoking %s::didFailToWriteResponse", MetricsCollector.class.getSimpleName()), 1132 (metricsInvocation) -> metricsInvocation.didFailToWriteResponse(serverType, requestHolder.get(), resourceMethodHolder.get(), 1133 marshaledResponseHolder.get(), responseWriteDuration, t2)); 1134 } 1135 } 1136 } finally { 1137 if (!didFinishRequestHandlingCompleted.get()) { 1138 Duration processingDuration = Duration.ofNanos(Math.max(0L, 1139 System.nanoTime() - processingStartedNanos)); 1140 1141 safelyCollectMetrics.accept( 1142 format("An exception occurred while invoking %s::didFinishRequestHandling", MetricsCollector.class.getSimpleName()), 1143 (metricsInvocation) -> metricsInvocation.didFinishRequestHandling(serverType, request, resourceMethodHolder.get(), marshaledResponseHolder.get(), processingDuration, Collections.unmodifiableList(throwables))); 1144 1145 MarshaledResponse finishedResponse = marshaledResponseHolder.get(); 1146 streamObservation.completeHandling(serverType == ServerType.HTTP && finishedResponse != null && finishedResponse.isStreaming()); 1147 1148 try { 1149 lifecycleObserver.didFinishRequestHandling(serverType, request, resourceMethodHolder.get(), marshaledResponseHolder.get(), processingDuration, Collections.unmodifiableList(throwables)); 1150 } catch (Throwable t2) { 1151 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_DID_FINISH_REQUEST_HANDLING_FAILED, 1152 format("An exception occurred while invoking %s::didFinishRequestHandling", 1153 LifecycleObserver.class.getSimpleName())) 1154 .throwable(t2) 1155 .request(requestHolder.get()) 1156 .resourceMethod(resourceMethodHolder.get()) 1157 .marshaledResponse(marshaledResponseHolder.get()) 1158 .build()); 1159 } 1160 } 1161 } 1162 } 1163 } 1164 1165 @NonNull 1166 HttpRequestResult toHttpRequestResult(@NonNull Request request, 1167 @Nullable ResourceMethod resourceMethod, 1168 @NonNull ServerType serverType) throws Throwable { 1169 requireNonNull(request); 1170 requireNonNull(serverType); 1171 1172 ResourceMethodParameterProvider resourceMethodParameterProvider = getSokletConfig().getResourceMethodParameterProvider(); 1173 InstanceProvider instanceProvider = getSokletConfig().getInstanceProvider(); 1174 CorsAuthorizer corsAuthorizer = getSokletConfig().getCorsAuthorizer(); 1175 ResourceMethodResolver resourceMethodResolver = getSokletConfig().getResourceMethodResolver(); 1176 ResponseMarshaler responseMarshaler = getSokletConfig().getResponseMarshaler(); 1177 CorsPreflight corsPreflight = request.getCorsPreflight().orElse(null); 1178 1179 // Special short-circuit for big requests 1180 if (request.isContentTooLarge()) { 1181 ResourceMethod contentTooLargeResourceMethod = 1182 resolveResourceMethodForRequest(request, resourceMethodResolver, serverType); 1183 return HttpRequestResult.withMarshaledResponse(responseMarshaler.forContentTooLarge(request, contentTooLargeResourceMethod)) 1184 .resourceMethod(resourceMethod) 1185 .build(); 1186 } 1187 1188 // Special short-circuit for OPTIONS * 1189 if (isOptionsSplat(request.getResourcePath())) 1190 return HttpRequestResult.withMarshaledResponse(responseMarshaler.forOptionsSplat(request)).build(); 1191 1192 // CORS preflight authorization precedes explicit OPTIONS handlers too. 1193 if (resourceMethod == null || corsPreflight != null) { 1194 // If this was an OPTIONS request, do special processing. 1195 // If not, figure out if we should return a 404 or 405. 1196 if (request.getHttpMethod() == HttpMethod.OPTIONS) { 1197 // See what methods are available to us for this request's path 1198 Map<HttpMethod, ResourceMethod> matchingResourceMethodsByHttpMethod = resolveMatchingResourceMethodsByHttpMethod(request, resourceMethodResolver, serverType); 1199 1200 // Special handling for CORS preflight requests, if needed 1201 if (corsPreflight != null) { 1202 // Let configuration function determine if we should authorize this request. 1203 // Discard any OPTIONS references - see https://stackoverflow.com/a/68529748 1204 Map<HttpMethod, ResourceMethod> nonOptionsMatchingResourceMethodsByHttpMethod = matchingResourceMethodsByHttpMethod.entrySet().stream() 1205 .filter(entry -> entry.getKey() != HttpMethod.OPTIONS) 1206 .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); 1207 1208 // Validate client-supplied reflection candidates before application 1209 // authorization, including custom authorizers that reflect them verbatim. 1210 CorsPreflightResponse corsPreflightResponse = corsPreflight.isSafeToReflect() 1211 ? corsAuthorizer.authorizePreflight(request, corsPreflight, nonOptionsMatchingResourceMethodsByHttpMethod).orElse(null) 1212 : null; 1213 1214 // Allow or reject CORS depending on what the function said to do 1215 if (corsPreflightResponse != null) { 1216 // Allow 1217 MarshaledResponse marshaledResponse = responseMarshaler.forCorsPreflightAllowed(request, corsPreflight, corsPreflightResponse); 1218 1219 return HttpRequestResult.withMarshaledResponse(marshaledResponse) 1220 .corsPreflightResponse(corsPreflightResponse) 1221 .build(); 1222 } 1223 1224 // Reject 1225 return HttpRequestResult.withMarshaledResponse(responseMarshaler.forCorsPreflightRejected(request, corsPreflight)) 1226 .build(); 1227 } else { 1228 // Just a normal OPTIONS response (non-CORS-preflight). 1229 if (matchingResourceMethodsByHttpMethod.isEmpty()) 1230 return HttpRequestResult.withMarshaledResponse(responseMarshaler.forNotFound(request)).build(); 1231 1232 // If there's a matching OPTIONS resource method for this OPTIONS request, then invoke it. 1233 ResourceMethod optionsResourceMethod = matchingResourceMethodsByHttpMethod.get(HttpMethod.OPTIONS); 1234 1235 if (optionsResourceMethod != null) { 1236 resourceMethod = optionsResourceMethod; 1237 } else { 1238 Set<HttpMethod> allowedHttpMethods = allowedHttpMethodsForResponse(matchingResourceMethodsByHttpMethod, true, serverType); 1239 1240 return HttpRequestResult.withMarshaledResponse(responseMarshaler.forOptions(request, allowedHttpMethods)) 1241 .build(); 1242 } 1243 } 1244 } else if (request.getHttpMethod() == HttpMethod.HEAD && serverType == ServerType.HTTP) { 1245 // If there's a matching GET resource method for this HEAD request, then invoke it 1246 Request headGetRequest = request.copy().httpMethod(HttpMethod.GET).finish(); 1247 ResourceMethod headGetResourceMethod = 1248 validateResolvedResourceMethod(resourceMethodResolver 1249 .resourceMethodForRequest(headGetRequest, serverType) 1250 .orElse(null)); 1251 1252 if (headGetResourceMethod != null) 1253 resourceMethod = headGetResourceMethod; 1254 else { 1255 Map<HttpMethod, ResourceMethod> otherMethods = resolveMatchingResourceMethodsByHttpMethod( 1256 request, resourceMethodResolver, serverType); 1257 boolean resourceExists = otherMethods.keySet().stream().anyMatch(method -> method != HttpMethod.OPTIONS); 1258 return HttpRequestResult.withMarshaledResponse(resourceExists 1259 ? responseMarshaler.forMethodNotAllowed(request, allowedHttpMethodsForResponse(otherMethods, true, serverType)) 1260 : responseMarshaler.forNotFound(request)).build(); 1261 } 1262 } else { 1263 // Not an OPTIONS request, so it's possible we have a 405. See if other HTTP methods match... 1264 Map<HttpMethod, ResourceMethod> otherMatchingResourceMethodsByHttpMethod = resolveMatchingResourceMethodsByHttpMethod(request, resourceMethodResolver, serverType); 1265 1266 Set<HttpMethod> matchingNonOptionsHttpMethods = otherMatchingResourceMethodsByHttpMethod.keySet().stream() 1267 .filter(httpMethod -> httpMethod != HttpMethod.OPTIONS) 1268 .collect(Collectors.toSet()); 1269 1270 if (matchingNonOptionsHttpMethods.size() > 0) { 1271 // ...if some do, it's a 405 1272 Set<HttpMethod> allowedHttpMethods = allowedHttpMethodsForResponse(otherMatchingResourceMethodsByHttpMethod, true, serverType); 1273 return HttpRequestResult.withMarshaledResponse(responseMarshaler.forMethodNotAllowed(request, allowedHttpMethods)) 1274 .build(); 1275 } else { 1276 // no matching resource method found, it's a 404 1277 return HttpRequestResult.withMarshaledResponse(responseMarshaler.forNotFound(request)) 1278 .build(); 1279 } 1280 } 1281 } 1282 1283 // Found a resource method - happy path. 1284 // 1. Get an instance of the resource class 1285 // 2. Get values to pass to the resource method on the resource class 1286 // 3. Invoke the resource method and use its return value to drive a response 1287 SokletFrameworkSetup.validateConfiguredResourceMethod(getSokletConfig(), resourceMethod); 1288 Class<?> resourceClass = resourceMethod.getMethod().getDeclaringClass(); 1289 Object resourceClassInstance; 1290 1291 try { 1292 resourceClassInstance = instanceProvider.provide(resourceClass); 1293 } catch (Exception e) { 1294 throw new IllegalArgumentException(format("Unable to acquire an instance of %s", resourceClass.getName()), e); 1295 } 1296 1297 List<Object> parameterValues = resourceMethodParameterProvider.parameterValuesForResourceMethod(request, resourceMethod); 1298 1299 Object responseObject; 1300 1301 try { 1302 responseObject = resourceMethod.getMethod().invoke(resourceClassInstance, parameterValues.toArray()); 1303 } catch (InvocationTargetException e) { 1304 if (e.getTargetException() != null) 1305 throw e.getTargetException(); 1306 1307 throw e; 1308 } 1309 1310 // Unwrap the Optional<T>, if one exists. We do not recurse deeper than one level 1311 if (responseObject instanceof Optional<?>) 1312 responseObject = ((Optional<?>) responseObject).orElse(null); 1313 1314 Response response; 1315 SseHandshakeResult sseHandshakeResult = null; 1316 1317 // If null/void return, it's a 204 1318 // If it's a MarshaledResponse object, no marshaling + return it immediately - caller knows exactly what it wants to write. 1319 // If it's a Response object, use as is. 1320 // If it's a non-Response type of object, assume it's the response body and wrap in a Response. 1321 if (responseObject == null) { 1322 response = Response.withStatusCode(204).build(); 1323 } else if (responseObject instanceof MarshaledResponse) { 1324 MarshaledResponse marshaledResponse = normalizeEmptyResetContent((MarshaledResponse) responseObject); 1325 enforceMarshaledResponseStatusCode(marshaledResponse, true); 1326 1327 return HttpRequestResult.withMarshaledResponse(marshaledResponse) 1328 .resourceMethod(resourceMethod) 1329 .build(); 1330 } else if (responseObject instanceof Response) { 1331 response = (Response) responseObject; 1332 } else if (responseObject instanceof SseHandshakeResult.Accepted accepted) { // SSE "accepted" handshake 1333 return HttpRequestResult.withMarshaledResponse(toMarshaledResponse(accepted)) 1334 .resourceMethod(resourceMethod) 1335 .sseHandshakeResult(accepted) 1336 .build(); 1337 } else if (responseObject instanceof SseHandshakeResult.Rejected rejected) { // SSE "rejected" handshake 1338 response = rejected.getResponse(); 1339 sseHandshakeResult = rejected; 1340 } else { 1341 response = Response.withStatusCode(200).body(responseObject).build(); 1342 } 1343 1344 if (response.getStatusCode() == 205) { 1345 Object body = response.getBody().orElse(null); 1346 if ((body instanceof String text && text.isEmpty()) || (body instanceof byte[] bytes && bytes.length == 0)) 1347 response = response.copy().body(null).finish(); 1348 } 1349 enforceBodylessStatusCode(response.getStatusCode(), response.getBody().isPresent()); 1350 1351 MarshaledResponse marshaledResponse = normalizeEmptyResetContent(responseMarshaler.forResourceMethod(request, response, resourceMethod)); 1352 1353 enforceMarshaledResponseStatusCode(marshaledResponse, true); 1354 1355 return HttpRequestResult.withMarshaledResponse(marshaledResponse) 1356 .response(response) 1357 .resourceMethod(resourceMethod) 1358 .sseHandshakeResult(sseHandshakeResult) 1359 .build(); 1360 } 1361 1362 @NonNull 1363 private MarshaledResponse toMarshaledResponse(SseHandshakeResult.@NonNull Accepted accepted) { 1364 requireNonNull(accepted); 1365 1366 Map<String, List<String>> headers = accepted.getHeaders(); 1367 Set<String> forbiddenHeaderNames = Set.of("content-length", "proxy-connection", "te", "trailer", "transfer-encoding", "upgrade"); 1368 for (String headerName : headers.keySet()) 1369 if (forbiddenHeaderNames.contains(headerName.toLowerCase(java.util.Locale.ENGLISH))) 1370 throw new IllegalArgumentException("Unsupported framing header for an accepted SSE handshake"); 1371 LinkedCaseInsensitiveMap<List<String>> finalHeaders = new LinkedCaseInsensitiveMap<>(DEFAULT_ACCEPTED_HANDSHAKE_HEADERS.size() + headers.size()); 1372 1373 // Start with defaults 1374 for (Map.Entry<String, List<String>> e : DEFAULT_ACCEPTED_HANDSHAKE_HEADERS.entrySet()) 1375 finalHeaders.put(e.getKey(), e.getValue()); // values already unmodifiable 1376 1377 // Overlay user-supplied headers (prefer user values on key collision) 1378 for (Map.Entry<String, List<String>> e : headers.entrySet()) { 1379 String headerName = requireNonNull(e.getKey()); 1380 if (headerName.equalsIgnoreCase("Connection") 1381 || headerName.equalsIgnoreCase("Keep-Alive")) 1382 continue; 1383 // Defensively copy so callers can't mutate after construction 1384 List<String> values = e.getValue() == null ? List.of() : List.copyOf(e.getValue()); 1385 finalHeaders.put(headerName, values); 1386 } 1387 List<String> contentTypes = finalHeaders.getOrDefault("Content-Type", List.of()); 1388 if (contentTypes == null || contentTypes.size() != 1 1389 || !contentTypes.get(0).split(";", 2)[0].trim().equalsIgnoreCase("text/event-stream")) 1390 throw new IllegalArgumentException("An accepted SSE handshake requires exactly one text/event-stream Content-Type"); 1391 1392 return MarshaledResponse.withStatusCode(200) 1393 .headers(finalHeaders) 1394 .cookies(accepted.getCookies()) 1395 .build(); 1396 } 1397 1398 private static boolean isCompatibleAcceptedSseResponse(@NonNull MarshaledResponse response) { 1399 if (response.getStatusCode() != 200 || response.isStreaming() || response.getBody().isPresent()) 1400 return false; 1401 List<String> contentTypes = response.getHeaders().getOrDefault("Content-Type", List.of()); 1402 boolean compatible = contentTypes.size() == 1 1403 && contentTypes.get(0).split(";", 2)[0].trim().equalsIgnoreCase("text/event-stream"); 1404 if (compatible) { 1405 Set<String> forbidden = Set.of("content-length", "proxy-connection", "te", "trailer", "transfer-encoding", "upgrade"); 1406 for (String headerName : response.getHeaders().keySet()) 1407 if (forbidden.contains(headerName.toLowerCase(java.util.Locale.ENGLISH))) 1408 throw new IllegalArgumentException("Unsupported framing header for an accepted SSE handshake"); 1409 } 1410 return compatible; 1411 } 1412 1413 @NonNull 1414 private static MarshaledResponse normalizeEmptyResetContent(@NonNull MarshaledResponse response) { 1415 if (response.getStatusCode() == 205 && !response.isStreaming() && response.getBody().isPresent() 1416 && response.getBodyLength() == 0L) 1417 return response.copy().body((MarshaledResponseBody) null).finish(); 1418 return response; 1419 } 1420 1421 private static void enforceBodylessStatusCode(@NonNull Integer statusCode, 1422 @NonNull Boolean hasBody) { 1423 requireNonNull(statusCode); 1424 requireNonNull(hasBody); 1425 enforceFinalResponseStatusCode(statusCode); 1426 1427 if (hasBody && isBodylessStatusCode(statusCode)) 1428 throw new IllegalStateException(format("HTTP status code %d must not include a response body", statusCode)); 1429 } 1430 1431 private static void enforceMarshaledResponseStatusCode(@NonNull MarshaledResponse response, 1432 boolean resourceMethodResponse) { 1433 try { 1434 if (resourceMethodResponse) 1435 enforceBodylessStatusCode(response.getStatusCode(), 1436 response.getBody().isPresent() || response.getStreamingResponseBody().isPresent()); 1437 else 1438 enforceFinalResponseStatusCode(response.getStatusCode()); 1439 } catch (RuntimeException | Error failure) { 1440 // This response will never reach transport cleanup. Release only an 1441 // explicitly owned finite body and keep validation as the primary failure. 1442 try { 1443 response.close(); 1444 } catch (RuntimeException | Error cleanupFailure) { 1445 if (!sameInstance(cleanupFailure, failure)) 1446 failure.addSuppressed(cleanupFailure); 1447 } 1448 throw failure; 1449 } 1450 } 1451 1452 private static void enforceFinalResponseStatusCode(@NonNull Integer statusCode) { 1453 requireNonNull(statusCode); 1454 if (statusCode < 200 || statusCode > 599) 1455 throw new IllegalStateException("Final HTTP response status code must be between 200 and 599."); 1456 } 1457 1458 private static boolean isBodylessStatusCode(@NonNull Integer statusCode) { 1459 requireNonNull(statusCode); 1460 return (statusCode >= 100 && statusCode < 200) || statusCode == 204 || statusCode == 205 || statusCode == 304; 1461 } 1462 1463 @NonNull 1464 MarshaledResponse applyHeadResponseIfApplicable(@NonNull Request request, 1465 @NonNull MarshaledResponse marshaledResponse) { 1466 if (request.getHttpMethod() != HttpMethod.HEAD) 1467 return marshaledResponse; 1468 1469 return getSokletConfig().getResponseMarshaler().forHead(request, marshaledResponse); 1470 } 1471 1472 // Hat tip to Aslan Parçası and GrayStar 1473 @NonNull 1474 MarshaledResponse applyCommonPropertiesToMarshaledResponse(@NonNull Request request, 1475 @NonNull MarshaledResponse marshaledResponse) { 1476 requireNonNull(request); 1477 requireNonNull(marshaledResponse); 1478 1479 return applyCommonPropertiesToMarshaledResponse(request, marshaledResponse, false); 1480 } 1481 1482 @NonNull 1483 MarshaledResponse applyCommonPropertiesToMarshaledResponse(@NonNull Request request, 1484 @NonNull MarshaledResponse marshaledResponse, 1485 @NonNull Boolean suppressContentLength) { 1486 return applyCommonPropertiesToMarshaledResponse(request, marshaledResponse, suppressContentLength, ignored -> {}, newPostProcessingToken()); 1487 } 1488 1489 @NonNull 1490 private static final class ResponseFinalizationToken { 1491 private boolean postProcessingStarted; 1492 } 1493 1494 private @NonNull ResponseFinalizationToken newPostProcessingToken() { 1495 // Recovery must not repeat a processor that completed or failed before a later hook failed. 1496 return new ResponseFinalizationToken(); 1497 } 1498 1499 private boolean requiresPostProcessing(@NonNull MarshaledResponse response, @Nullable ResponseFinalizationToken token) { 1500 return token != null && !response.isPostProcessedFor(token); 1501 } 1502 1503 @NonNull 1504 private MarshaledResponse applyCommonPropertiesToMarshaledResponse(@NonNull Request request, 1505 @NonNull MarshaledResponse response, boolean suppressContentLength, 1506 @NonNull Consumer<@NonNull MarshaledResponse> headRepresentationConsumer, @Nullable ResponseFinalizationToken postProcessingToken) { 1507 return applyCommonPropertiesToMarshaledResponse(request, response, suppressContentLength, 1508 headRepresentationConsumer, postProcessingToken, true); 1509 } 1510 1511 @NonNull 1512 private MarshaledResponse applyCommonPropertiesToMarshaledResponse(@NonNull Request request, 1513 @NonNull MarshaledResponse response, boolean suppressContentLength, 1514 @NonNull Consumer<@NonNull MarshaledResponse> headRepresentationConsumer, @Nullable ResponseFinalizationToken postProcessingToken, boolean applyHead) { 1515 requireNonNull(request); 1516 requireNonNull(response); 1517 boolean head = applyHead && request.getHttpMethod() == HttpMethod.HEAD; 1518 response = normalizeBodylessMarshaledResponse(request, response); 1519 response = ResponseBodyCleanup.transform(response, current -> applyCorsResponseIfApplicable(request, current)); 1520 if (requiresPostProcessing(response, postProcessingToken)) { 1521 ResponseFinalizationToken token = requireNonNull(postProcessingToken); 1522 if (!token.postProcessingStarted) { 1523 token.postProcessingStarted = true; 1524 ResponseMarshaler marshaler = getSokletConfig().getResponseMarshaler(); 1525 response = ResponseBodyCleanup.transform(response, marshaler::postProcess); 1526 } 1527 response = response.markPostProcessed(token); 1528 } 1529 @Nullable ResponseFinalizationToken completedPostProcessingToken = 1530 postProcessingToken != null && response.isPostProcessedFor(postProcessingToken) 1531 ? postProcessingToken : null; 1532 MarshaledResponse headRepresentation = response; 1533 if (head) { 1534 response = ResponseBodyCleanup.transform(response, current -> applyHeadResponseIfApplicable(request, current)); 1535 // Only a projection of this representation may borrow its discarded body. 1536 // A fresh descriptor or changed representation metadata belongs to the HEAD handler. 1537 List<String> projectedLength = response.getHeaders().get("Content-Length"); 1538 boolean preservesRepresentation = completedPostProcessingToken != null 1539 && response.isPostProcessedFor(completedPostProcessingToken) 1540 && response.getStatusCode().equals(headRepresentation.getStatusCode()) 1541 && Objects.equals(response.getHeaders().get("Content-Encoding"), headRepresentation.getHeaders().get("Content-Encoding")) 1542 && (projectedLength == null || headRepresentation.getBody().isEmpty() 1543 || projectedLength.equals(List.of(Long.toString(headRepresentation.getBodyLength())))); 1544 if (!preservesRepresentation || response.getBody().isPresent() || response.isStreaming()) 1545 headRepresentation = response; 1546 } 1547 headRepresentationConsumer.accept(headRepresentation); 1548 if (completedPostProcessingToken != null) 1549 response = response.markPostProcessed(completedPostProcessingToken); 1550 response = normalizeBodylessMarshaledResponse(request, response); 1551 if (!suppressContentLength) response = applyContentLengthIfApplicable(request, response); 1552 if (!response.getHeaders().containsKey("Date")) 1553 response = response.copy().headers(headers -> headers.put("Date", List.of(HttpDate.currentSecondHeaderValue()))).finish(); 1554 return response; 1555 } 1556 1557 @NonNull 1558 private static MarshaledResponse normalizeBodylessMarshaledResponse(@NonNull Request request, 1559 @NonNull MarshaledResponse marshaledResponse) { 1560 enforceMarshaledResponseStatusCode(marshaledResponse, false); 1561 if (isBodylessStatusCode(marshaledResponse.getStatusCode())) { 1562 int statusCode = marshaledResponse.getStatusCode(); 1563 marshaledResponse = marshaledResponse.copy().withoutBody().withoutStreamingResponseBody().headers(headers -> { 1564 headers.remove("Transfer-Encoding"); 1565 if (statusCode == 205) 1566 headers.put("Content-Length", List.of("0")); 1567 else if (statusCode == 204 || request.getHttpMethod() != HttpMethod.HEAD) 1568 headers.remove("Content-Length"); 1569 }).finish(); 1570 } 1571 return marshaledResponse; 1572 } 1573 1574 @NonNull 1575 MarshaledResponse applyContentLengthIfApplicable(@NonNull Request request, 1576 @NonNull MarshaledResponse marshaledResponse) { 1577 requireNonNull(request); 1578 requireNonNull(marshaledResponse); 1579 1580 if (marshaledResponse.isStreaming()) 1581 return marshaledResponse; 1582 1583 Set<String> normalizedHeaderNames = marshaledResponse.getHeaders().keySet().stream() 1584 .map(headerName -> headerName.toLowerCase(Locale.US)) 1585 .collect(Collectors.toSet()); 1586 1587 // If Content-Length is already specified, don't do anything 1588 if (normalizedHeaderNames.contains("content-length") || normalizedHeaderNames.contains("transfer-encoding")) 1589 return marshaledResponse; 1590 1591 if (shouldOmitAutomaticContentLength(request, marshaledResponse)) 1592 return marshaledResponse; 1593 1594 // If Content-Length is not specified, specify as the number of bytes in the body 1595 return marshaledResponse.copy() 1596 .headers((mutableHeaders) -> { 1597 String contentLengthHeaderValue = String.valueOf(marshaledResponse.getBodyLength()); 1598 mutableHeaders.put("Content-Length", List.of(contentLengthHeaderValue)); 1599 }).finish(); 1600 } 1601 1602 private boolean shouldOmitAutomaticContentLength(@NonNull Request request, 1603 @NonNull MarshaledResponse marshaledResponse) { 1604 requireNonNull(request); 1605 requireNonNull(marshaledResponse); 1606 1607 int statusCode = marshaledResponse.getStatusCode(); 1608 1609 if ((statusCode >= 100 && statusCode < 200) || statusCode == 204 || statusCode == 304) 1610 return true; 1611 1612 return statusCode != 205 && request.getHttpMethod() == HttpMethod.HEAD && marshaledResponse.getBodyLength() == 0L; 1613 } 1614 1615 @NonNull 1616 MarshaledResponse applyCorsResponseIfApplicable(@NonNull Request request, 1617 @NonNull MarshaledResponse marshaledResponse) { 1618 requireNonNull(request); 1619 requireNonNull(marshaledResponse); 1620 1621 CorsAuthorizer corsAuthorizer = getSokletConfig().getCorsAuthorizer(); 1622 // Include every cache variant, even when this request omits Origin or is denied. 1623 if (!sameInstance(corsAuthorizer, CorsAuthorizer.rejectAllInstance()) && request.getCorsPreflight().isEmpty()) 1624 marshaledResponse = marshaledResponse.copy().headers(headers -> Utilities.addVaryHeader(headers, "Origin")).finish(); 1625 1626 Cors cors = request.getCors().orElse(null); 1627 1628 // If non-CORS request, nothing further to do (note that CORS preflight was handled earlier) 1629 if (cors == null || !Cors.isSafeToReflectOrigin(cors.getOrigin())) 1630 return marshaledResponse; 1631 1632 // Does the authorizer say we are authorized? 1633 CorsResponse corsResponse = corsAuthorizer.authorize(request, cors).orElse(null); 1634 1635 // Not authorized - don't apply CORS headers to the response 1636 if (corsResponse == null) 1637 return marshaledResponse; 1638 1639 // Authorized - OK, let's apply the headers to the response 1640 return getSokletConfig().getResponseMarshaler().forCorsAllowed(request, cors, corsResponse, marshaledResponse); 1641 } 1642 1643 @NonNull 1644 Map<@NonNull HttpMethod, @NonNull ResourceMethod> resolveMatchingResourceMethodsByHttpMethod(@NonNull Request request, 1645 @NonNull ResourceMethodResolver resourceMethodResolver, 1646 @NonNull ServerType serverType) { 1647 requireNonNull(request); 1648 requireNonNull(resourceMethodResolver); 1649 requireNonNull(serverType); 1650 1651 // Special handling for OPTIONS * 1652 if (isOptionsSplat(request.getResourcePath())) 1653 return new LinkedHashMap<>(); 1654 1655 Map<HttpMethod, ResourceMethod> matchingResourceMethodsByHttpMethod = new LinkedHashMap<>(HttpMethod.values().length); 1656 1657 for (HttpMethod httpMethod : HttpMethod.values()) { 1658 // Make a quick copy of the request to see if other paths match 1659 Request otherRequest = request.copy().httpMethod(httpMethod).finish(); 1660 ResourceMethod resourceMethod = 1661 validateResolvedResourceMethod(resourceMethodResolver 1662 .resourceMethodForRequest(otherRequest, serverType) 1663 .orElse(null)); 1664 1665 if (resourceMethod != null) 1666 matchingResourceMethodsByHttpMethod.put(httpMethod, resourceMethod); 1667 } 1668 1669 return matchingResourceMethodsByHttpMethod; 1670 } 1671 1672 @Nullable 1673 private static ResourceMethod resolveResourceMethodForRequest(@NonNull Request request, 1674 @NonNull ResourceMethodResolver resourceMethodResolver, @NonNull ServerType serverType) { 1675 ResourceMethod resourceMethod = validateResolvedResourceMethod(resourceMethodResolver 1676 .resourceMethodForRequest(request, serverType).orElse(null)); 1677 if (resourceMethod == null && request.getHttpMethod() == HttpMethod.HEAD && serverType == ServerType.HTTP) { 1678 // Resolve the GET fallback before observers/interceptors see routing metadata. 1679 // Only the lookup copy becomes GET; the application still receives HEAD. 1680 Request getRequest = request.copy().httpMethod(HttpMethod.GET).finish(); 1681 resourceMethod = validateResolvedResourceMethod(resourceMethodResolver 1682 .resourceMethodForRequest(getRequest, serverType).orElse(null)); 1683 } 1684 return resourceMethod; 1685 } 1686 1687 @Nullable 1688 private static ResourceMethod validateResolvedResourceMethod( 1689 @Nullable ResourceMethod resourceMethod) { 1690 if (resourceMethod != null) 1691 SokletFrameworkSetup.validateNoRemovedHttpServerInjection( 1692 resourceMethod); 1693 return resourceMethod; 1694 } 1695 1696 @SuppressWarnings("ReferenceEquality") 1697 private static @NonNull Boolean isOptionsSplat(@NonNull ResourcePath resourcePath) { 1698 requireNonNull(resourcePath); 1699 return resourcePath == ResourcePath.OPTIONS_SPLAT_RESOURCE_PATH; 1700 } 1701 1702 @NonNull 1703 private static Set<@NonNull HttpMethod> allowedHttpMethodsForResponse(@NonNull Map<@NonNull HttpMethod, @NonNull ResourceMethod> matchingResourceMethodsByHttpMethod, 1704 @NonNull Boolean includeOptions, @NonNull ServerType serverType) { 1705 requireNonNull(matchingResourceMethodsByHttpMethod); 1706 requireNonNull(includeOptions); 1707 1708 Set<HttpMethod> allowedHttpMethods = EnumSet.noneOf(HttpMethod.class); 1709 allowedHttpMethods.addAll(matchingResourceMethodsByHttpMethod.keySet()); 1710 1711 if (includeOptions) 1712 allowedHttpMethods.add(HttpMethod.OPTIONS); 1713 1714 if ((serverType == ServerType.HTTP && matchingResourceMethodsByHttpMethod.containsKey(HttpMethod.GET)) 1715 || matchingResourceMethodsByHttpMethod.containsKey(HttpMethod.HEAD)) 1716 allowedHttpMethods.add(HttpMethod.HEAD); 1717 1718 return allowedHttpMethods; 1719 } 1720 1721 @NonNull 1722 MarshaledResponse provideFailsafeMarshaledResponse(@NonNull Request request, 1723 @NonNull Throwable throwable) { 1724 requireNonNull(request); 1725 requireNonNull(throwable); 1726 1727 Integer statusCode = 500; 1728 Charset charset = StandardCharsets.UTF_8; 1729 1730 return MarshaledResponse.withStatusCode(statusCode) 1731 .headers(Map.of("Content-Type", List.of(format("text/plain; charset=%s", charset.name())))) 1732 .body(format("HTTP %s: %s", statusCode, StatusCode.fromStatusCode(statusCode).get().getReasonPhrase()).getBytes(charset)) 1733 .build(); 1734 } 1735 1736 /** 1737 * Publishes shutdown intent, joins the lifecycle uninterruptibly, restores 1738 * the caller's interrupt status, and applies the terminal result. 1739 * 1740 * @throws SokletShutdownIncompleteException if complete termination cannot be proven 1741 * @throws SokletUnexpectedTerminationException if a lifecycle component terminated 1742 * unexpectedly after readiness 1743 * @throws IllegalStateException as a best-effort diagnostic when the current 1744 * thread is contributing to the unpublished shutdown barrier 1745 */ 1746 void close() { 1747 shutdown(); 1748 boolean interrupted = false; 1749 ShutdownResult result; 1750 1751 try { 1752 for (;;) { 1753 try { 1754 result = awaitCoreShutdown(); 1755 break; 1756 } catch (InterruptedException exception) { 1757 interrupted = true; 1758 } 1759 } 1760 1761 this.directLifecycle.throwIfUnsuccessfulShutdown(result); 1762 } finally { 1763 if (interrupted) 1764 Thread.currentThread().interrupt(); 1765 } 1766 } 1767 1768 /** 1769 * Returns a racy diagnostic snapshot of this one-shot lifecycle. 1770 * 1771 * @return current lifecycle status 1772 */ 1773 @NonNull 1774 SokletStatus getStatus() { 1775 return this.directLifecycle.publicStatus(); 1776 } 1777 1778 /** 1779 * Returns the immutable terminal result after it has been published. 1780 * 1781 * @return terminal result, otherwise empty 1782 */ 1783 @NonNull 1784 Optional<@NonNull ShutdownResult> getShutdownResult() { 1785 return this.directLifecycle.publicResult(); 1786 } 1787 1788 void initializeForSimulator(@NonNull StartupContext startupContext, 1789 @NonNull DeadlineWaiter waiter) { 1790 this.directLifecycle.initializeForSimulator(startupContext, waiter); 1791 } 1792 1793 @NonNull 1794 SokletDirectLifecycle getDirectLifecycle() { 1795 return this.directLifecycle; 1796 } 1797 1798 @NonNull 1799 SokletConfig getSokletConfig() { 1800 return this.sokletConfig; 1801 } 1802 1803 /** 1804 * Returns the stable compatibility lock retained from earlier Soklet 1805 * versions. The one-shot lifecycle does not use this caller-obtainable lock 1806 * for its internal state transitions, so holding it cannot delay lifecycle 1807 * progress. 1808 * 1809 * @return passive compatibility lock 1810 */ 1811 @NonNull 1812 ReentrantLock getLock() { 1813 return this.lock; 1814 } 1815 1816 /** 1817 * Returns the stable compatibility reference initially containing the latch 1818 * released when this one-shot lifecycle publishes its terminal result. 1819 * Soklet retains that original latch directly, so replacing the exposed 1820 * reference value cannot redirect lifecycle publication. 1821 * 1822 * @return one-shot shutdown-latch reference 1823 */ 1824 @NonNull 1825 AtomicReference<@NonNull CountDownLatch> 1826 getAwaitShutdownLatchReference() { 1827 return this.awaitShutdownLatchReference; 1828 } 1829 1830 void releaseAwaitShutdownLatch() { 1831 this.awaitShutdownLatch.countDown(); 1832 } 1833 1834 @ThreadSafe 1835 static class DefaultSimulator implements Simulator { 1836 @Nullable 1837 private MockHttpServer server; 1838 @Nullable 1839 private MockSseServer sseServer; 1840 @NonNull 1841 private final SimulatorOptions simulatorOptions; 1842 private final @Nullable DefaultMcpServer mcpServer; 1843 private final @Nullable SimulatorScopeDispatchGate scopeDispatchGate; 1844 private final @Nullable Object executionOwnerToken; 1845 @NonNull 1846 private final Object mcpSimulationLock; 1847 @NonNull 1848 private final Object scopeStateLock; 1849 private volatile @Nullable SimulationSession mcpSimulationSession; 1850 private volatile @Nullable SimulationSession rejectedMcpSimulationSession; 1851 private volatile @Nullable StreamLifecycleCoordinator streamLifecycleCoordinator; 1852 private @Nullable Supplier<@NonNull StreamLifecycleCoordinator> streamLifecycleCoordinatorFactoryForTests; 1853 private volatile @Nullable StreamLifecycleCoordinator sseLifecycleCoordinator; 1854 private @Nullable Supplier<@NonNull StreamLifecycleCoordinator> sseLifecycleCoordinatorFactoryForTests; 1855 @NonNull 1856 private final AtomicBoolean closed; 1857 1858 public DefaultSimulator(@Nullable MockHttpServer server, 1859 @Nullable MockSseServer sseServer) { 1860 this(server, sseServer, SimulatorOptions.defaultInstance(), null, null); 1861 } 1862 1863 public DefaultSimulator(@Nullable MockHttpServer server, 1864 @Nullable MockSseServer sseServer, 1865 @NonNull SimulatorOptions simulatorOptions) { 1866 this(server, sseServer, simulatorOptions, null, null); 1867 } 1868 1869 DefaultSimulator(@Nullable MockHttpServer server, 1870 @Nullable MockSseServer sseServer, 1871 @NonNull SimulatorOptions simulatorOptions, 1872 @Nullable DefaultMcpServer mcpServer) { 1873 this(server, sseServer, simulatorOptions, mcpServer, null); 1874 } 1875 1876 DefaultSimulator(@Nullable MockHttpServer server, 1877 @Nullable MockSseServer sseServer, 1878 @NonNull SimulatorOptions simulatorOptions, 1879 @Nullable DefaultMcpServer mcpServer, 1880 @Nullable SimulatorScopeDispatchGate scopeDispatchGate) { 1881 this(server, sseServer, simulatorOptions, mcpServer, scopeDispatchGate, null); 1882 } 1883 1884 DefaultSimulator(@Nullable MockHttpServer server, @Nullable MockSseServer sseServer, 1885 @NonNull SimulatorOptions simulatorOptions, @Nullable DefaultMcpServer mcpServer, 1886 @Nullable SimulatorScopeDispatchGate scopeDispatchGate, @Nullable Object executionOwnerToken) { 1887 this.server = server; 1888 this.sseServer = sseServer; 1889 this.simulatorOptions = requireNonNull(simulatorOptions); 1890 this.mcpServer = mcpServer; 1891 this.scopeDispatchGate = scopeDispatchGate; 1892 this.executionOwnerToken = executionOwnerToken; 1893 this.mcpSimulationLock = new Object(); 1894 this.scopeStateLock = new Object(); 1895 this.closed = new AtomicBoolean(); 1896 } 1897 1898 private void runStreamCallback(@NonNull Runnable callback) { 1899 Object token = this.executionOwnerToken; 1900 try (LifecycleExecutionContext.Scope ignored = token == null ? null : LifecycleExecutionContext.enter(token)) { 1901 callback.run(); 1902 } 1903 } 1904 1905 @Override 1906 @NonNull 1907 public McpSimulation startMcpRequest(@NonNull Request request) { 1908 return startMcpRequest(requireNonNull(request), 1909 McpSimulationOptions.defaultInstance()); 1910 } 1911 1912 @Override 1913 @NonNull 1914 public McpSimulation startMcpRequest(@NonNull Request request, 1915 @NonNull McpSimulationOptions options) { 1916 Runnable releaseScope = enterScope(InternalLifecycleComponentType.MCP); 1917 try { 1918 synchronized (this.mcpSimulationLock) { 1919 if (this.mcpServer == null) 1920 throw new IllegalStateException( 1921 "You must specify a McpServer in your SokletConfig to simulate MCP requests"); 1922 if (this.mcpSimulationSession == null 1923 && this.scopeDispatchGate == null) 1924 this.mcpSimulationSession = this.mcpServer 1925 .openSimulationSession(); 1926 if (this.mcpSimulationSession == null) 1927 throw new IllegalStateException( 1928 "The MCP simulator participant is not ready"); 1929 return this.mcpSimulationSession.start(requireNonNull(request), 1930 requireNonNull(options)); 1931 } 1932 } finally { 1933 releaseScope.run(); 1934 } 1935 } 1936 1937 void openMcpScope() { 1938 synchronized (this.mcpSimulationLock) { 1939 requireScopeOpenWhileLocked(); 1940 if (this.mcpServer == null) 1941 throw new IllegalStateException( 1942 "The simulator scope has no MCP participant"); 1943 if (this.mcpSimulationSession != null) 1944 throw new IllegalStateException( 1945 "The MCP simulator participant was already started"); 1946 this.mcpServer.openSimulationSession(session -> { 1947 synchronized (this.scopeStateLock) { 1948 if (this.mcpSimulationSession != null 1949 || this.rejectedMcpSimulationSession != null) 1950 throw new IllegalStateException( 1951 "The MCP simulator participant was already started"); 1952 SimulationSession exactSession = requireNonNull(session); 1953 if (this.closed.get()) { 1954 // The runtime will reject and roll back this unclaimed session, 1955 // but custom execution or registration cleanup may finish later. 1956 // Retain its proof handle without exposing it as an active session. 1957 this.rejectedMcpSimulationSession = exactSession; 1958 requireScopeOpenWhileLocked(); 1959 } 1960 this.mcpSimulationSession = exactSession; 1961 } 1962 }); 1963 } 1964 } 1965 1966 boolean sealScope() { 1967 // Close dispatch admission without waiting for a startup call that may be 1968 // inside the MCP session lock. A request racing shutdown must lose at the 1969 // owner gate, and startup cancellation must remain deadline-bounded. 1970 synchronized (this.scopeStateLock) { 1971 if (!this.closed.compareAndSet(false, true)) 1972 return false; 1973 if (this.scopeDispatchGate != null) 1974 this.scopeDispatchGate.seal(); 1975 return true; 1976 } 1977 } 1978 1979 void quiesceMcpScope() { 1980 SimulationSession session = mcpSimulationSession(); 1981 if (session != null) 1982 session.quiesce(); 1983 } 1984 1985 void quiesceHttpScope() { 1986 StreamLifecycleCoordinator coordinator = this.streamLifecycleCoordinator; 1987 if (coordinator != null) 1988 coordinator.requestGracefulShutdown(); 1989 } 1990 1991 void forceHttpScope() { 1992 StreamLifecycleCoordinator coordinator = this.streamLifecycleCoordinator; 1993 if (coordinator != null) 1994 coordinator.force(); 1995 } 1996 1997 boolean awaitHttpScopeTermination(long absoluteDeadlineNanos, 1998 @NonNull NanoClock clock) throws InterruptedException { 1999 StreamLifecycleCoordinator coordinator = this.streamLifecycleCoordinator; 2000 if (coordinator == null) 2001 return true; 2002 long remaining = absoluteDeadlineNanos - requireNonNull(clock).nanoTime(); 2003 return coordinator.awaitTermination(System.nanoTime() + Math.max(0L, remaining)); 2004 } 2005 2006 boolean httpScopeTerminationProven() { 2007 StreamLifecycleCoordinator coordinator = this.streamLifecycleCoordinator; 2008 return coordinator == null || coordinator.isTerminated(); 2009 } 2010 2011 @NonNull 2012 Set<@NonNull InternalResidualActivityType> httpScopeResidualActivity() { 2013 StreamLifecycleCoordinator coordinator = this.streamLifecycleCoordinator; 2014 if (coordinator == null || coordinator.isTerminated()) 2015 return Set.of(); 2016 StreamLifecycleCoordinator.Snapshot snapshot = coordinator.snapshot(); 2017 EnumSet<InternalResidualActivityType> residual = EnumSet.noneOf(InternalResidualActivityType.class); 2018 if (snapshot.reservations() > 0) 2019 residual.add(InternalResidualActivityType.STREAM); 2020 if (snapshot.queuedProducers() > 0 || snapshot.runningProducers() > 0 || snapshot.reservations() == 0) 2021 residual.add(InternalResidualActivityType.EXECUTOR_TASK); 2022 if (snapshot.callbacks() > 0 || snapshot.diagnostics() > 0 || snapshot.publisherLifetimes() > 0) 2023 residual.add(InternalResidualActivityType.CALLBACK); 2024 return Collections.unmodifiableSet(residual); 2025 } 2026 2027 void setStreamLifecycleCoordinatorFactoryForTests(@NonNull Supplier<@NonNull StreamLifecycleCoordinator> factory) { 2028 synchronized (this.scopeStateLock) { 2029 requireScopeOpenWhileLocked(); 2030 if (this.streamLifecycleCoordinator != null) 2031 throw new IllegalStateException("Streaming lifecycle coordinator was already created"); 2032 this.streamLifecycleCoordinatorFactoryForTests = requireNonNull(factory); 2033 } 2034 } 2035 2036 @NonNull 2037 Optional<@NonNull StreamLifecycleCoordinator> getStreamLifecycleCoordinatorForTests() { 2038 return Optional.ofNullable(this.streamLifecycleCoordinator); 2039 } 2040 2041 private StreamLifecycleCoordinator.@Nullable Reservation reserveHttpStream() { 2042 synchronized (this.scopeStateLock) { 2043 if (this.closed.get()) 2044 return null; 2045 if (this.streamLifecycleCoordinator == null) { 2046 MockHttpServer server = requireNonNull(this.server); 2047 LifecycleObserver observer = server.getSokletConfig().orElseThrow().getAggregateLifecycleObserver(); 2048 this.streamLifecycleCoordinator = this.streamLifecycleCoordinatorFactoryForTests == null 2049 ? StreamLifecycleCoordinator.withCallbackExecution(server.streamingLifecycleCapacity, 2050 server.streamingCallbackConcurrency, server.streamingCleanupTimeout, throwable -> { 2051 try { 2052 observer.didReceiveLogEvent(LogEvent.with(LogEventType.SERVER_INTERNAL_ERROR, 2053 "A simulated streaming lifecycle supervision operation failed or exceeded its deadline") 2054 .throwable(throwable).build()); 2055 } catch (Throwable observerFailure) { 2056 LifecycleObserverLogFallback.report(observerFailure); 2057 } 2058 }, this::runStreamCallback) 2059 : requireNonNull(this.streamLifecycleCoordinatorFactoryForTests.get()); 2060 } 2061 return this.streamLifecycleCoordinator.tryReserve(); 2062 } 2063 } 2064 2065 void quiesceSseScope() { 2066 MockSseServer server = this.sseServer; 2067 if (server != null) 2068 server.stop(); 2069 StreamLifecycleCoordinator coordinator = this.sseLifecycleCoordinator; 2070 if (coordinator != null) 2071 coordinator.force(); 2072 } 2073 2074 void forceSseScope() { 2075 quiesceSseScope(); 2076 } 2077 2078 boolean awaitSseScopeTermination(long absoluteDeadlineNanos, 2079 @NonNull NanoClock clock) throws InterruptedException { 2080 StreamLifecycleCoordinator coordinator = this.sseLifecycleCoordinator; 2081 return coordinator == null || coordinator.awaitTermination(System.nanoTime() 2082 + Math.max(0L, absoluteDeadlineNanos - requireNonNull(clock).nanoTime())); 2083 } 2084 2085 boolean sseScopeTerminationProven() { 2086 StreamLifecycleCoordinator coordinator = this.sseLifecycleCoordinator; 2087 return coordinator == null || coordinator.isTerminated(); 2088 } 2089 2090 @NonNull 2091 Set<@NonNull InternalResidualActivityType> sseScopeResidualActivity() { 2092 StreamLifecycleCoordinator coordinator = this.sseLifecycleCoordinator; 2093 if (coordinator == null || coordinator.isTerminated()) 2094 return Set.of(); 2095 StreamLifecycleCoordinator.Snapshot snapshot = coordinator.snapshot(); 2096 EnumSet<InternalResidualActivityType> residual = EnumSet.noneOf(InternalResidualActivityType.class); 2097 if (snapshot.reservations() > 0) 2098 residual.add(InternalResidualActivityType.STREAM); 2099 if (snapshot.queuedProducers() > 0 || snapshot.runningProducers() > 0 || snapshot.reservations() == 0) 2100 residual.add(InternalResidualActivityType.EXECUTOR_TASK); 2101 if (snapshot.callbacks() > 0 || snapshot.diagnostics() > 0 || snapshot.retainedWork() > 0) 2102 residual.add(InternalResidualActivityType.CALLBACK); 2103 return Collections.unmodifiableSet(residual); 2104 } 2105 2106 void setSseLifecycleCoordinatorFactoryForTests(@NonNull Supplier<@NonNull StreamLifecycleCoordinator> factory) { 2107 synchronized (this.scopeStateLock) { 2108 requireScopeOpenWhileLocked(); 2109 if (this.sseLifecycleCoordinator != null) 2110 throw new IllegalStateException("SSE lifecycle coordinator was already created"); 2111 this.sseLifecycleCoordinatorFactoryForTests = requireNonNull(factory); 2112 } 2113 } 2114 2115 @NonNull 2116 Optional<@NonNull StreamLifecycleCoordinator> getSseLifecycleCoordinatorForTests() { 2117 return Optional.ofNullable(this.sseLifecycleCoordinator); 2118 } 2119 2120 private StreamLifecycleCoordinator.@Nullable Reservation reserveSseStream() { 2121 synchronized (this.scopeStateLock) { 2122 if (this.closed.get()) 2123 return null; 2124 return sseCoordinatorWhileLocked().tryReserve(); 2125 } 2126 } 2127 2128 private @NonNull StreamLifecycleCoordinator sseCoordinatorWhileLocked() { 2129 if (this.sseLifecycleCoordinator == null) { 2130 MockSseServer server = requireNonNull(this.sseServer); 2131 LifecycleObserver observer = server.getSokletConfig().orElseThrow().getAggregateLifecycleObserver(); 2132 this.sseLifecycleCoordinator = this.sseLifecycleCoordinatorFactoryForTests == null 2133 ? StreamLifecycleCoordinator.withCallbackExecution(server.streamingLifecycleCapacity, 2134 DefaultSseServer.STREAMING_COORDINATOR_CALLBACK_CONCURRENCY, 2135 DefaultSseServer.STREAMING_COORDINATOR_CLEANUP_GRACE, throwable -> { 2136 try { 2137 observer.didReceiveLogEvent(LogEvent.with(LogEventType.SSE_SERVER_INTERNAL_ERROR, 2138 "A simulated SSE cleanup operation failed or exceeded its deadline") 2139 .throwable(throwable).build()); 2140 } catch (Throwable observerFailure) { 2141 LifecycleObserverLogFallback.report(observerFailure); 2142 } 2143 }, this::runStreamCallback) : requireNonNull(this.sseLifecycleCoordinatorFactoryForTests.get()); 2144 } 2145 return this.sseLifecycleCoordinator; 2146 } 2147 2148 void forceMcpScope() { 2149 SimulationSession session = mcpSimulationSession(); 2150 if (session != null) 2151 session.force(); 2152 } 2153 2154 boolean awaitMcpScopeTermination(long absoluteDeadlineNanos, 2155 @NonNull NanoClock clock) throws InterruptedException { 2156 SimulationSession session = mcpEvidenceSession(); 2157 return session == null 2158 || session.awaitTermination(absoluteDeadlineNanos, 2159 requireNonNull(clock)::nanoTime); 2160 } 2161 2162 boolean mcpScopeTerminationProven() { 2163 SimulationSession session = mcpEvidenceSession(); 2164 return session == null || session.terminationProven(); 2165 } 2166 2167 @NonNull 2168 Set<@NonNull InternalResidualActivityType> mcpScopeResidualActivity() { 2169 SimulationSession session = mcpEvidenceSession(); 2170 if (session == null) 2171 return Set.of(); 2172 com.soklet.internal.mcp.protocol.McpServerRuntimeBridge.LifecycleEvidence 2173 evidence = session.lifecycleEvidence(); 2174 EnumSet<InternalResidualActivityType> residual = 2175 EnumSet.noneOf(InternalResidualActivityType.class); 2176 if (evidence.executorTask() || evidence.subscriptionRegistration()) 2177 residual.add(InternalResidualActivityType.EXECUTOR_TASK); 2178 if (evidence.stream()) 2179 residual.add(InternalResidualActivityType.STREAM); 2180 if (evidence.callback()) 2181 residual.add(InternalResidualActivityType.CALLBACK); 2182 return Collections.unmodifiableSet(residual); 2183 } 2184 2185 void releaseMcpScopeEvidence() { 2186 SimulationSession activeSession = mcpSimulationSession(); 2187 SimulationSession rejectedSession = rejectedMcpSimulationSession(); 2188 if (activeSession != null) 2189 activeSession.releaseLifecycleEvidence(); 2190 if (rejectedSession != null && rejectedSession != activeSession) 2191 rejectedSession.releaseLifecycleEvidence(); 2192 clearMcpSimulationSession(activeSession); 2193 clearRejectedMcpSimulationSession(rejectedSession); 2194 } 2195 2196 void releaseHttpAndSseScopeState() { 2197 MockHttpServer httpServer = this.server; 2198 MockSseServer serverSentEventsServer = this.sseServer; 2199 if (httpServer != null) 2200 httpServer.releaseSimulationScopeState(); 2201 if (serverSentEventsServer != null) 2202 serverSentEventsServer.releaseSimulationScopeState(); 2203 } 2204 2205 @Nullable 2206 private SimulationSession mcpSimulationSession() { 2207 return this.mcpSimulationSession; 2208 } 2209 2210 @Nullable 2211 private SimulationSession rejectedMcpSimulationSession() { 2212 return this.rejectedMcpSimulationSession; 2213 } 2214 2215 @Nullable 2216 private SimulationSession mcpEvidenceSession() { 2217 SimulationSession activeSession = mcpSimulationSession(); 2218 return activeSession == null 2219 ? rejectedMcpSimulationSession() : activeSession; 2220 } 2221 2222 private void clearMcpSimulationSession(@Nullable SimulationSession session) { 2223 synchronized (this.mcpSimulationLock) { 2224 if (this.mcpSimulationSession == session) 2225 this.mcpSimulationSession = null; 2226 } 2227 } 2228 2229 private void clearRejectedMcpSimulationSession( 2230 @Nullable SimulationSession session) { 2231 synchronized (this.scopeStateLock) { 2232 if (this.rejectedMcpSimulationSession == session) 2233 this.rejectedMcpSimulationSession = null; 2234 } 2235 } 2236 2237 @NonNull 2238 @Override 2239 public HttpRequestResult performHttpRequest(@NonNull Request request) { 2240 Runnable releaseScope = enterScope(InternalLifecycleComponentType.HTTP); 2241 try (HttpTransportResponseFeedback feedback = HttpTransportResponseFeedback.create()) { 2242 // A caller may concurrently reuse an immutable Request. Each simulated 2243 // dispatch needs its own identity, shared by handling and stream callbacks. 2244 return performHttpRequestWhileAdmitted(requireNonNull(request).copy().finish(), feedback); 2245 } finally { 2246 releaseScope.run(); 2247 } 2248 } 2249 2250 @NonNull 2251 private HttpRequestResult performHttpRequestWhileAdmitted( 2252 @NonNull Request request, @NonNull HttpTransportResponseFeedback feedback) { 2253 MockHttpServer server = this.server; 2254 2255 if (server == null) 2256 throw new IllegalStateException(format("You must specify a %s in your %s to simulate requests", 2257 HttpServer.class.getSimpleName(), SokletConfig.class.getSimpleName())); 2258 2259 AtomicReference<@Nullable HttpRequestResult> requestResultHolder = new AtomicReference<>(); 2260 HttpServer.RequestHandler requestHandler = server.getRequestHandler().orElse(null); 2261 2262 if (requestHandler == null) 2263 throw new IllegalStateException("You must register a request handler prior to simulating requests"); 2264 2265 AtomicReference<StreamLifecycleCoordinator.@Nullable Reservation> reservationHolder = new AtomicReference<>(); 2266 Throwable primaryFailure = null; 2267 Throwable terminalCleanupFailure = null; 2268 HttpRequestResult completedResult; 2269 try { 2270 requestHandler.handleRequest(request, HttpTransportResponseFeedback.fromWriter(feedback, requestResult -> { 2271 // Simulated responses do not run transport compression or retain its private HEAD input. 2272 HttpRequestResult result = projectSimulatedHttpResponse(request, requestResult); 2273 if (result.getMarshaledResponse().isStreaming()) { 2274 Instant streamEstablishedAt = Instant.now(); 2275 long streamEstablishedNanos = System.nanoTime(); 2276 StreamLifecycleCoordinator.Reservation reservation = reserveHttpStream(); 2277 if (reservation == null) { 2278 result.getMarshaledResponse().close(); 2279 MarshaledResponse unavailable = DefaultHttpServer.provideDefaultStreamingAdmissionRejection(); 2280 requestResultHolder.set(result.copy().marshaledResponse(unavailable).response(null).finish()); 2281 Throwable rejection = new RejectedExecutionException( 2282 "Streaming lifecycle capacity is unavailable."); 2283 StreamLifecycleCoordinator coordinator = this.streamLifecycleCoordinator; 2284 StreamTerminationReason reason = this.closed.get() || coordinator == null || !coordinator.snapshot().accepting() 2285 ? StreamTerminationReason.SERVER_STOPPING : StreamTerminationReason.BACKPRESSURE; 2286 Runnable notification = () -> notifyDidTerminateSimulatorResponseStream(request, result, Instant.now(), 2287 Duration.ZERO, reason, rejection); 2288 if ((coordinator == null || !coordinator.dispatchRejectionObserver(notification)) 2289 && reason != StreamTerminationReason.SERVER_STOPPING) { 2290 try { 2291 server.getSokletConfig().orElseThrow().getAggregateLifecycleObserver().didReceiveLogEvent( 2292 LogEvent.with(LogEventType.RESPONSE_STREAM_CANCELED, 2293 "Unadmitted stream rejection observer capacity was unavailable.").request(request).build()); 2294 } catch (Throwable observerFailure) { 2295 LifecycleObserverLogFallback.report(observerFailure); 2296 } 2297 } 2298 // Synchronous replacement keeps didWrite/finish callbacks and metrics finite. 2299 return HttpTransportResponseOutcome.replaced(unavailable, rejection, null); 2300 } 2301 reservationHolder.set(reservation); 2302 HttpResponseStreamObservation observation = result.getResponseStreamObservation(); 2303 if (observation != null) { 2304 SokletConfig config = server.getSokletConfig().orElseThrow(); 2305 LifecycleObserver observer = config.getAggregateLifecycleObserver(); 2306 observation.prepare(new DefaultStreamingResponseHandle(ServerType.HTTP, observation.getRequest(), 2307 result.getResourceMethod().orElse(null), result.getMarshaledResponse(), streamEstablishedAt), 2308 streamEstablishedNanos, config.getMetricsCollector(), observer, logEvent -> { 2309 try { observer.didReceiveLogEvent(logEvent); } 2310 catch (Throwable observerFailure) { LifecycleObserverLogFallback.report(observerFailure); } 2311 }); 2312 } 2313 } 2314 requestResultHolder.set(result); 2315 return HttpTransportResponseOutcome.accepted(); 2316 })); 2317 completedResult = materializeStreamingResponse(request, requestResultHolder.get(), reservationHolder.get()); 2318 if (!completedResult.getMarshaledResponse().isStreaming()) 2319 completedResult = materializeFiniteSimulationResult(completedResult); 2320 } catch (RuntimeException | Error failure) { 2321 primaryFailure = failure; 2322 throw failure; 2323 } finally { 2324 // Also retire a reservation if handling fails before inline producer entry. 2325 StreamLifecycleCoordinator.Reservation reservation = reservationHolder.get(); 2326 try { 2327 HttpRequestResult result = requestResultHolder.get(); 2328 if (result != null) { 2329 try { result.getMarshaledResponse().close(); } 2330 catch (RuntimeException | Error cleanupFailure) { 2331 if (primaryFailure != null && !sameInstance(primaryFailure, cleanupFailure)) primaryFailure.addSuppressed(cleanupFailure); 2332 if (reservation != null) reservation.reportCleanupFailure(cleanupFailure); 2333 else if (primaryFailure == null) terminalCleanupFailure = cleanupFailure; 2334 } 2335 } 2336 } finally { 2337 if (reservation != null) reservation.complete(); 2338 } 2339 } 2340 if (terminalCleanupFailure instanceof RuntimeException runtime) throw runtime; 2341 if (terminalCleanupFailure instanceof Error error) throw error; 2342 return completedResult; 2343 } 2344 2345 private static @NonNull HttpRequestResult materializeFiniteSimulationResult(@NonNull HttpRequestResult result) { 2346 MarshaledResponse response = result.getMarshaledResponse(); 2347 Throwable primary = null; 2348 try { 2349 byte[] bytes = response.bodyBytesOrNull(); 2350 MarshaledResponse snapshot = MarshaledResponse.withStatusCode(response.getStatusCode()) 2351 .headers(response.getHeaders()).cookies(response.getCookies()).body(bytes).build(); 2352 return result.copy().marshaledResponse(snapshot).finish(); 2353 } catch (RuntimeException | Error failure) { 2354 primary = failure; 2355 throw failure; 2356 } finally { 2357 MarshaledResponse.closeChannels(response.ownedChannels(), primary); 2358 } 2359 } 2360 2361 private @NonNull HttpRequestResult materializeAcceptedSseSimulationResult(@NonNull HttpRequestResult result) { 2362 MarshaledResponse response = result.getMarshaledResponse(); 2363 Object token = this.executionOwnerToken; 2364 try (LifecycleExecutionContext.Scope ignored = token == null ? null : LifecycleExecutionContext.enter(token)) { 2365 Throwable primary = null; 2366 try { 2367 // Accepted handshakes have no body. Keep their metadata without retaining 2368 // channel ownership after the headers have been materialized. 2369 MarshaledResponse snapshot = MarshaledResponse.withStatusCode(response.getStatusCode()) 2370 .headers(response.getHeaders()).cookies(response.getCookies()).build(); 2371 return result.copy().marshaledResponse(snapshot).finish(); 2372 } catch (RuntimeException | Error failure) { 2373 primary = failure; 2374 throw failure; 2375 } finally { 2376 MarshaledResponse.closeChannels(response.ownedChannels(), primary); 2377 } 2378 } 2379 } 2380 2381 private static @NonNull HttpRequestResult projectSimulatedHttpResponse(@NonNull Request request, @NonNull HttpRequestResult result) { 2382 MarshaledResponse response = normalizeBodylessMarshaledResponse(request, result.getMarshaledResponse()); 2383 boolean head = request.getHttpMethod() == HttpMethod.HEAD; 2384 if (!response.isStreaming()) 2385 response = response.copy().headers(DefaultHttpServer.simulatedFiniteResponseHeaders(request, response, 2386 result.getHeadResponseBodyLength().orElse(null))).finish(); 2387 2388 // The transport suppresses HEAD bytes even when an interceptor or custom 2389 // HeadHandler leaves a body. Never execute a suppressed stream producer. 2390 if (head) 2391 response = response.copy().withoutBody().withoutStreamingResponseBody().finish(); 2392 return result.copy().marshaledResponse(response).headResponseCompressionBody(null).finish(); 2393 } 2394 2395 private static @NonNull HttpRequestResult projectSimulatedSseRejection(@NonNull Request request, @NonNull HttpRequestResult result) { 2396 MarshaledResponse response = normalizeBodylessMarshaledResponse(request, result.getMarshaledResponse()); 2397 boolean head = request.getHttpMethod() == HttpMethod.HEAD; 2398 if (head) 2399 response = response.copy().withoutBody().withoutStreamingResponseBody().finish(); 2400 int status = response.getStatusCode(); 2401 long length = response.getBodyLength(); 2402 Set<String> transportHeaders = new HashSet<>(Set.of("connection", "keep-alive", "content-length", 2403 "transfer-encoding", "te", "trailer", "upgrade", "proxy-connection")); 2404 for (String value : response.getHeaders().getOrDefault("Connection", List.of())) 2405 for (String name : value.split(",", -1)) 2406 transportHeaders.add(name.trim().toLowerCase(Locale.ROOT)); 2407 response = response.copy().headers(headers -> { 2408 headers.keySet().removeIf(name -> transportHeaders.contains(name.toLowerCase(Locale.ROOT))); 2409 if (!head && status != 204 && status != 304) 2410 headers.put("Content-Length", List.of(Long.toString(length))); 2411 headers.put("Connection", List.of("close")); 2412 headers.putIfAbsent("Date", List.of(HttpDate.currentSecondHeaderValue())); 2413 }).finish(); 2414 return result.copy().marshaledResponse(response).headResponseCompressionBody(null).headResponseBodyLength(null).finish(); 2415 } 2416 2417 @NonNull 2418 private HttpRequestResult materializeStreamingResponse(@NonNull Request request, 2419 @Nullable HttpRequestResult requestResult, StreamLifecycleCoordinator.@Nullable Reservation reservedLifetime) { 2420 requireNonNull(request); 2421 2422 if (requestResult == null) 2423 throw new IllegalStateException("No HTTP request result was produced by the simulator"); 2424 2425 StreamingResponseBody stream = requestResult.getMarshaledResponse().getStreamingResponseBody().orElse(null); 2426 2427 if (stream == null) 2428 return requestResult; 2429 2430 StreamLifecycleCoordinator.Reservation reservation = requireNonNull(reservedLifetime); 2431 AtomicReference<@Nullable HttpRequestResult> result = new AtomicReference<>(); 2432 AtomicReference<@Nullable Throwable> failure = new AtomicReference<>(); 2433 LifecycleObserver cleanupObserver = requireNonNull(this.server).getSokletConfig().orElseThrow() 2434 .getAggregateLifecycleObserver(); 2435 reservation.bindCleanupFailureObserver(throwable -> { 2436 try { 2437 cleanupObserver.didReceiveLogEvent(LogEvent.with(LogEventType.RESPONSE_STREAM_CLOSE_FAILED, 2438 "A simulated streaming response cleanup operation failed") 2439 .throwable(throwable).request(request) 2440 .resourceMethod(requestResult.getResourceMethod().orElse(null)) 2441 .marshaledResponse(requestResult.getMarshaledResponse()).build()); 2442 } catch (Throwable observerFailure) { 2443 LifecycleObserverLogFallback.report(observerFailure); 2444 } 2445 }); 2446 boolean entered = reservation.executeInline(() -> { 2447 try { 2448 result.set(materializeStreamingResponseWhileReserved(request, requestResult, stream, reservation)); 2449 } catch (Throwable throwable) { 2450 failure.set(throwable); 2451 } finally { 2452 reservation.complete(); 2453 } 2454 }); 2455 if (!entered) { 2456 StreamTerminationReason reason = reservation.reason().orElse(StreamTerminationReason.SERVER_STOPPING); 2457 Throwable cause = reservation.cause().orElse(null); 2458 try { 2459 notifyDidTerminateSimulatorResponseStream(reservation, request, requestResult, Instant.now(), 2460 Duration.ZERO, reason, cause); 2461 } finally { 2462 reservation.complete(); 2463 } 2464 throw new IllegalStateException("Simulated streaming response was canceled: " + reason.name(), 2465 new StreamingResponseCanceledException(reason, cause)); 2466 } 2467 Throwable throwable = failure.get(); 2468 if (throwable instanceof RuntimeException exception) 2469 throw exception; 2470 if (throwable instanceof Error error) 2471 throw error; 2472 if (throwable != null) 2473 throw new IllegalStateException("Simulated streaming response failed.", throwable); 2474 return requireNonNull(result.get()); 2475 } 2476 2477 @NonNull 2478 private HttpRequestResult materializeStreamingResponseWhileReserved(@NonNull Request request, 2479 @NonNull HttpRequestResult requestResult, @NonNull StreamingResponseBody stream, 2480 StreamLifecycleCoordinator.@NonNull Reservation reservation) { 2481 2482 byte[] bytes; 2483 java.util.concurrent.atomic.AtomicLong acceptedBytes = new java.util.concurrent.atomic.AtomicLong(); 2484 Instant streamStarted = Instant.now(); 2485 2486 try { 2487 bytes = materializeStreamingResponseBody(request, requestResult, stream, reservation, acceptedBytes); 2488 if (!reservation.completeTransport()) 2489 throw new StreamingResponseCanceledException(reservation.reason().orElse(StreamTerminationReason.SERVER_STOPPING), 2490 reservation.cause().orElse(null)); 2491 notifyDidTerminateSimulatorResponseStream(reservation, request, requestResult, streamStarted, 2492 Duration.between(streamStarted, Instant.now()), null, null, acceptedBytes.get()); 2493 } catch (StreamingResponseCanceledException e) { 2494 StreamTerminationReason cancelationReason = e.getCancelationReason(); 2495 Throwable cause = e.getCancelationCause().orElse(null); 2496 notifyDidTerminateSimulatorResponseStream(reservation, request, requestResult, streamStarted, 2497 Duration.between(streamStarted, Instant.now()), cancelationReason, cause, acceptedBytes.get()); 2498 throw new IllegalStateException("Simulated streaming response was canceled: " + cancelationReason.name(), e); 2499 } catch (InterruptedException e) { 2500 Thread.currentThread().interrupt(); 2501 notifyDidTerminateSimulatorResponseStream(reservation, request, requestResult, streamStarted, 2502 Duration.between(streamStarted, Instant.now()), StreamTerminationReason.APPLICATION_CANCELED, e, acceptedBytes.get()); 2503 throw new IllegalStateException("Simulated streaming response was canceled: APPLICATION_CANCELED", e); 2504 } catch (Throwable t) { 2505 notifyDidTerminateSimulatorResponseStream(reservation, request, requestResult, streamStarted, 2506 Duration.between(streamStarted, Instant.now()), StreamTerminationReason.PRODUCER_FAILED, t, acceptedBytes.get()); 2507 2508 if (t instanceof Error error) 2509 throw error; 2510 2511 throw new IllegalStateException("Simulated streaming response failed.", t); 2512 } 2513 2514 try { requestResult.getMarshaledResponse().close(); } 2515 catch (RuntimeException | Error cleanupFailure) { reservation.reportCleanupFailure(cleanupFailure); } 2516 MarshaledResponse marshaledResponse = requestResult.getMarshaledResponse().copy() 2517 .withoutStreamingResponseBody() 2518 .body(bytes) 2519 .finish(); 2520 2521 return requestResult.copy() 2522 .marshaledResponse(marshaledResponse) 2523 .finish(); 2524 } 2525 2526 private void notifyDidTerminateSimulatorResponseStream(StreamLifecycleCoordinator.@NonNull Reservation reservation, 2527 @NonNull Request request, @NonNull HttpRequestResult requestResult, 2528 @NonNull Instant establishedAt, @NonNull Duration streamDuration, 2529 @Nullable StreamTerminationReason cancelationReason, @Nullable Throwable throwable) { 2530 notifyDidTerminateSimulatorResponseStream(reservation, request, requestResult, establishedAt, 2531 streamDuration, cancelationReason, throwable, 0L); 2532 } 2533 2534 private void notifyDidTerminateSimulatorResponseStream(StreamLifecycleCoordinator.@NonNull Reservation reservation, 2535 @NonNull Request request, @NonNull HttpRequestResult requestResult, 2536 @NonNull Instant establishedAt, @NonNull Duration streamDuration, 2537 @Nullable StreamTerminationReason cancelationReason, @Nullable Throwable throwable, long bodyBytes) { 2538 long terminalNanos = reservation.transportTerminatedNanos(); 2539 CountDownLatch delivered = new CountDownLatch(1); 2540 reservation.dispatchTermination(() -> { 2541 try { 2542 notifyDidTerminateSimulatorResponseStream(request, requestResult, establishedAt, 2543 streamDuration, cancelationReason, throwable, terminalNanos, bodyBytes); 2544 } finally { 2545 delivered.countDown(); 2546 } 2547 }); 2548 boolean interrupted = false; 2549 try { 2550 for (;;) { 2551 try { 2552 delivered.await(); 2553 return; 2554 } catch (InterruptedException ignored) { 2555 interrupted = true; 2556 } 2557 } 2558 } finally { 2559 if (interrupted) 2560 Thread.currentThread().interrupt(); 2561 } 2562 } 2563 2564 private void notifyDidTerminateSimulatorResponseStream(@NonNull Request request, 2565 @NonNull HttpRequestResult requestResult, 2566 @NonNull Instant establishedAt, 2567 @NonNull Duration streamDuration, 2568 @Nullable StreamTerminationReason cancelationReason, 2569 @Nullable Throwable throwable) { 2570 notifyDidTerminateSimulatorResponseStream(request, requestResult, establishedAt, streamDuration, 2571 cancelationReason, throwable, 0L, 0L); 2572 } 2573 2574 private void notifyDidTerminateSimulatorResponseStream(@NonNull Request request, 2575 @NonNull HttpRequestResult requestResult, 2576 @NonNull Instant establishedAt, 2577 @NonNull Duration streamDuration, 2578 @Nullable StreamTerminationReason cancelationReason, 2579 @Nullable Throwable throwable, long terminalNanos, long bodyBytes) { 2580 requireNonNull(request); 2581 requireNonNull(requestResult); 2582 requireNonNull(establishedAt); 2583 requireNonNull(streamDuration); 2584 2585 MockHttpServer server = this.server; 2586 SokletConfig sokletConfig = server == null ? null : server.getSokletConfig().orElse(null); 2587 2588 if (sokletConfig == null) 2589 return; 2590 2591 MarshaledResponse marshaledResponse = requestResult.getMarshaledResponse(); 2592 ResourceMethod resourceMethod = requestResult.getResourceMethod().orElse(null); 2593 LifecycleObserver lifecycleObserver = sokletConfig.getAggregateLifecycleObserver(); 2594 HttpResponseStreamObservation observation = requestResult.getResponseStreamObservation(); 2595 StreamingResponseHandle streamingResponse = new DefaultStreamingResponseHandle(ServerType.HTTP, 2596 observation == null ? request : observation.getRequest(), resourceMethod, marshaledResponse, establishedAt); 2597 StreamTermination termination = StreamTermination 2598 .with(cancelationReason == null ? StreamTerminationReason.COMPLETED : cancelationReason, streamDuration) 2599 .cause(throwable) 2600 .build(); 2601 2602 if (observation != null && terminalNanos != 0L) { 2603 HttpResponseStreamObservation.Delivery delivery = observation.deliver(streamingResponse, termination, terminalNanos, bodyBytes, 2604 sokletConfig.getMetricsCollector(), logEvent -> { 2605 try { lifecycleObserver.didReceiveLogEvent(logEvent); } 2606 catch (Throwable failure) { LifecycleObserverLogFallback.report(failure); } 2607 }); 2608 streamingResponse = delivery.handle(); 2609 termination = delivery.termination(); 2610 } 2611 if (cancelationReason != null && cancelationReason != StreamTerminationReason.CLIENT_DISCONNECTED 2612 && cancelationReason != StreamTerminationReason.SERVER_STOPPING) { 2613 LogEventType type = cancelationReason == StreamTerminationReason.PRODUCER_FAILED 2614 ? LogEventType.RESPONSE_STREAM_FAILED : LogEventType.RESPONSE_STREAM_CANCELED; 2615 try { 2616 lifecycleObserver.didReceiveLogEvent(LogEvent.with(type, 2617 format("Streaming response terminated: %s", cancelationReason.name())) 2618 .throwable(throwable).request(request).resourceMethod(resourceMethod) 2619 .marshaledResponse(marshaledResponse).build()); 2620 } catch (Throwable observerFailure) { 2621 LifecycleObserverLogFallback.report(observerFailure); 2622 } 2623 } 2624 2625 try { 2626 lifecycleObserver.willTerminateResponseStream(streamingResponse, termination); 2627 } catch (Throwable t) { 2628 try { 2629 lifecycleObserver.didReceiveLogEvent(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_WILL_TERMINATE_RESPONSE_STREAM_FAILED, 2630 format("An exception occurred while invoking %s::willTerminateResponseStream", LifecycleObserver.class.getSimpleName())) 2631 .throwable(t) 2632 .request(request) 2633 .resourceMethod(resourceMethod) 2634 .marshaledResponse(marshaledResponse) 2635 .build()); 2636 } catch (Throwable observerFailure) { 2637 LifecycleObserverLogFallback.report(observerFailure); 2638 } 2639 } 2640 2641 try { 2642 lifecycleObserver.didTerminateResponseStream(streamingResponse, termination); 2643 } catch (Throwable t) { 2644 try { 2645 lifecycleObserver.didReceiveLogEvent(LogEvent.with(LogEventType.LIFECYCLE_OBSERVER_DID_TERMINATE_RESPONSE_STREAM_FAILED, 2646 format("An exception occurred while invoking %s::didTerminateResponseStream", LifecycleObserver.class.getSimpleName())) 2647 .throwable(t) 2648 .request(request) 2649 .resourceMethod(resourceMethod) 2650 .marshaledResponse(marshaledResponse) 2651 .build()); 2652 } catch (Throwable observerFailure) { 2653 LifecycleObserverLogFallback.report(observerFailure); 2654 } 2655 } 2656 } 2657 2658 private void notifyDidReceiveSimulatorStreamCancelationCallbackFailure(@NonNull Request request, 2659 @NonNull HttpRequestResult requestResult, 2660 @NonNull Throwable throwable) { 2661 requireNonNull(request); 2662 requireNonNull(requestResult); 2663 requireNonNull(throwable); 2664 2665 MockHttpServer server = this.server; 2666 SokletConfig sokletConfig = server == null ? null : server.getSokletConfig().orElse(null); 2667 2668 if (sokletConfig == null) 2669 return; 2670 2671 LifecycleObserver lifecycleObserver = sokletConfig.getAggregateLifecycleObserver(); 2672 ResourceMethod resourceMethod = requestResult.getResourceMethod().orElse(null); 2673 MarshaledResponse marshaledResponse = requestResult.getMarshaledResponse(); 2674 2675 try { 2676 lifecycleObserver.didReceiveLogEvent(LogEvent.with(LogEventType.RESPONSE_STREAM_CANCELATION_CALLBACK_FAILED, 2677 "An exception occurred while invoking a streaming response cancelation callback") 2678 .throwable(throwable) 2679 .request(request) 2680 .resourceMethod(resourceMethod) 2681 .marshaledResponse(marshaledResponse) 2682 .build()); 2683 } catch (Throwable observerFailure) { 2684 LifecycleObserverLogFallback.report(observerFailure); 2685 } 2686 } 2687 2688 private byte @NonNull [] materializeStreamingResponseBody(@NonNull Request request, 2689 @NonNull HttpRequestResult requestResult, 2690 @NonNull StreamingResponseBody stream, 2691 StreamLifecycleCoordinator.@NonNull Reservation reservation, java.util.concurrent.atomic.@NonNull AtomicLong acceptedBytes) throws Exception { 2692 requireNonNull(request); 2693 requireNonNull(requestResult); 2694 requireNonNull(stream); 2695 2696 Consumer<Throwable> cleanupFailureConsumer = throwable -> 2697 notifyDidReceiveSimulatorStreamCancelationCallbackFailure(request, requestResult, throwable); 2698 SimulatorCancelationToken cancelationToken = new SimulatorCancelationToken(cleanupFailureConsumer, reservation); 2699 reservation.bindTermination(cancelationToken::deliverCancelation); 2700 SimulatorResponseOutput output = new SimulatorResponseOutput(cancelationToken, 2701 getSimulatorOptions().getStreamingResponseBodyLimitInBytes(), acceptedBytes); 2702 2703 try { 2704 cancelationToken.throwIfCanceled(); 2705 if (stream instanceof StreamingResponseBody.PublisherBody publisherBody) { 2706 materializePublisher(publisherBody, output, cancelationToken, reservation); 2707 } else { 2708 ManagedResponseStream managedResponseStream = new ManagedResponseStream(request, cancelationToken, 2709 reservation::isGracefulShutdownRequested, null, null, output, reservation::beginCleanup, 2710 throwable -> cancelSimulatorStream(cancelationToken, throwable), reservation::reportCleanupFailure); 2711 managedResponseStream.run(responseStream -> { 2712 if (stream instanceof StreamingResponseBody.WriterBody writerBody) { 2713 writerBody.getWriter().writeTo(responseStream); 2714 } else if (stream instanceof StreamingResponseBody.InputStreamBody inputStreamBody) { 2715 java.io.InputStream inputStream = responseStream.open(inputStreamBody.getInputStreamFactory()); 2716 byte[] buffer = new byte[inputStreamBody.getBufferSizeInBytes()]; 2717 int read; 2718 2719 while ((read = inputStream.read(buffer)) >= 0) { 2720 responseStream.getCancelationToken().throwIfCanceled(); 2721 if (read > 0) 2722 responseStream.write(ByteBuffer.wrap(buffer, 0, read)); 2723 } 2724 } else if (stream instanceof StreamingResponseBody.ReaderBody readerBody) { 2725 Reader reader = responseStream.open(readerBody.getReaderFactory()); 2726 materializeReader(readerBody, reader, responseStream); 2727 } else { 2728 throw new IllegalStateException(format("Unsupported streaming response body type: %s", stream.getClass().getName())); 2729 } 2730 }); 2731 } 2732 } catch (Throwable t) { 2733 cancelSimulatorStream(cancelationToken, t); 2734 StreamTerminationReason reason = cancelationToken.getCancelationReason() 2735 .orElse(StreamTerminationReason.PRODUCER_FAILED); 2736 if (reason != StreamTerminationReason.PRODUCER_FAILED 2737 && !(t instanceof InterruptedException && reason == StreamTerminationReason.APPLICATION_CANCELED) 2738 && !(t instanceof StreamingResponseCanceledException canceledException 2739 && canceledException.getCancelationReason() == reason)) { 2740 Throwable cause = cancelationToken.getCancelationCause().orElse(null); 2741 StreamingResponseCanceledException canceledException = new StreamingResponseCanceledException(reason, cause); 2742 if (!sameInstance(t, cause) && !(t instanceof InterruptedException)) 2743 canceledException.addSuppressed(t); 2744 throw canceledException; 2745 } 2746 2747 if (t instanceof Exception exception) 2748 throw exception; 2749 2750 if (t instanceof Error error) 2751 throw error; 2752 2753 throw new RuntimeException(t); 2754 } 2755 2756 byte[] bytes = output.toByteArray(); 2757 if (!reservation.completeProduction()) 2758 cancelationToken.throwIfCanceled(); 2759 cancelationToken.complete(); 2760 return bytes; 2761 } 2762 2763 private void cancelSimulatorStream(@NonNull SimulatorCancelationToken cancelationToken, 2764 @NonNull Throwable throwable) { 2765 if (throwable instanceof StreamingResponseCanceledException canceledException) { 2766 cancelationToken.cancel(canceledException.getCancelationReason(), canceledException.getCancelationCause().orElse(null)); 2767 } else if (throwable instanceof InterruptedException) { 2768 Thread.currentThread().interrupt(); 2769 cancelationToken.cancel(cancelationToken.getCancelationReason() 2770 .orElse(StreamTerminationReason.APPLICATION_CANCELED), throwable); 2771 } else { 2772 cancelationToken.cancel(StreamTerminationReason.PRODUCER_FAILED, throwable); 2773 } 2774 } 2775 2776 private void materializeReader(com.soklet.StreamingResponseBody.@NonNull ReaderBody readerBody, 2777 @NonNull Reader reader, 2778 @NonNull ResponseStream responseStream) throws IOException, InterruptedException, StreamingResponseCanceledException, CharacterCodingException { 2779 requireNonNull(readerBody); 2780 requireNonNull(reader); 2781 requireNonNull(responseStream); 2782 2783 CharsetEncoder encoder = readerBody.newEncoder(); 2784 int readSize = readerBody.getBufferSizeInCharacters(); 2785 // Preserve single-character reads while leaving room for a carried high surrogate. 2786 CharBuffer charBuffer = CharBuffer.allocate(Math.max(2, readSize)); 2787 ByteBuffer byteBuffer = ByteBuffer.allocate(Math.max(128, (int) Math.ceil(readSize * encoder.maxBytesPerChar()))); 2788 2789 while (true) { 2790 charBuffer.limit(charBuffer.position() + Math.min(readSize, charBuffer.remaining())); 2791 int read = reader.read(charBuffer); 2792 charBuffer.limit(charBuffer.capacity()); 2793 if (read < 0) 2794 break; 2795 responseStream.getCancelationToken().throwIfCanceled(); 2796 charBuffer.flip(); 2797 encodeCharsForSimulator(encoder, charBuffer, byteBuffer, false, responseStream); 2798 charBuffer.compact(); 2799 } 2800 2801 charBuffer.flip(); 2802 encodeCharsForSimulator(encoder, charBuffer, byteBuffer, true, responseStream); 2803 2804 CoderResult result; 2805 do { 2806 result = encoder.flush(byteBuffer); 2807 writeEncodedBytesForSimulator(byteBuffer, responseStream); 2808 if (result.isError()) 2809 result.throwException(); 2810 } while (result.isOverflow()); 2811 } 2812 2813 private void encodeCharsForSimulator(@NonNull CharsetEncoder encoder, 2814 @NonNull CharBuffer charBuffer, 2815 @NonNull ByteBuffer byteBuffer, 2816 boolean endOfInput, 2817 @NonNull ResponseStream output) throws IOException, InterruptedException, StreamingResponseCanceledException, CharacterCodingException { 2818 CoderResult result; 2819 2820 do { 2821 result = encoder.encode(charBuffer, byteBuffer, endOfInput); 2822 writeEncodedBytesForSimulator(byteBuffer, output); 2823 2824 if (result.isError()) 2825 result.throwException(); 2826 } while (result.isOverflow()); 2827 } 2828 2829 private void writeEncodedBytesForSimulator(@NonNull ByteBuffer byteBuffer, 2830 @NonNull ResponseStream output) throws IOException, InterruptedException, StreamingResponseCanceledException { 2831 byteBuffer.flip(); 2832 if (byteBuffer.hasRemaining()) 2833 output.write(byteBuffer); 2834 byteBuffer.clear(); 2835 } 2836 2837 private void materializePublisher(com.soklet.StreamingResponseBody.@NonNull PublisherBody publisherBody, 2838 @NonNull SimulatorResponseOutput output, 2839 @NonNull SimulatorCancelationToken cancelationToken, 2840 StreamLifecycleCoordinator.@NonNull Reservation reservation) throws Exception { 2841 PublisherResponseStream.copy(publisherBody, cancelationToken, output, reservation, 2842 reservation::beginCleanup, throwable -> cancelSimulatorStream(cancelationToken, throwable), 2843 reservation::reportCleanupFailure); 2844 } 2845 2846 @NonNull 2847 @Override 2848 public SseRequestResult performSseRequest(@NonNull Request request) { 2849 Runnable releaseScope = enterScope(InternalLifecycleComponentType.SSE); 2850 try (HttpTransportResponseFeedback feedback = HttpTransportResponseFeedback.create()) { 2851 return performSseRequestWhileAdmitted(requireNonNull(request).copy().finish(), feedback); 2852 } finally { 2853 releaseScope.run(); 2854 } 2855 } 2856 2857 @NonNull 2858 private SseRequestResult performSseRequestWhileAdmitted( 2859 @NonNull Request request, @NonNull HttpTransportResponseFeedback feedback) { 2860 MockSseServer sseServer = this.sseServer; 2861 2862 if (sseServer == null) 2863 throw new IllegalStateException(format("You must specify a %s in your %s to simulate Server-Sent Event requests", 2864 SseServer.class.getSimpleName(), SokletConfig.class.getSimpleName())); 2865 2866 AtomicReference<@Nullable HttpRequestResult> requestResultHolder = new AtomicReference<>(); 2867 SseServer.RequestHandler requestHandler = sseServer.getRequestHandler().orElse(null); 2868 AtomicReference<StreamLifecycleCoordinator.@Nullable Reservation> reservationHolder = new AtomicReference<>(); 2869 AtomicReference<SseConnection.@Nullable HandshakeFailureReason> replacementFailureReason = new AtomicReference<>(); 2870 AtomicReference<@Nullable Throwable> replacementFailureCause = new AtomicReference<>(); 2871 2872 if (requestHandler == null) 2873 throw new IllegalStateException("You must register a request handler prior to simulating SSE Event Source requests"); 2874 if (request.getHttpMethod() == HttpMethod.GET) { 2875 try { 2876 DefaultSseServer.validateSseRequestBody(request); 2877 } catch (IllegalRequestException failure) { 2878 return rejectSseRequestBeforeHandling(request, sseServer, failure); 2879 } 2880 } 2881 2882 requestHandler.handleRequest(request, HttpTransportResponseFeedback.fromWriter(feedback, requestResult -> { 2883 HttpRequestResult effectiveResult = requestResult; 2884 Throwable preparationFailure = null; 2885 if (requestResult.getSseHandshakeResult().orElse(null) instanceof SseHandshakeResult.Accepted) { 2886 StreamLifecycleCoordinator.Reservation reservation = reserveSseStream(); 2887 if (reservation == null) { 2888 replacementFailureReason.set(SseConnection.HandshakeFailureReason.CAPACITY_EXCEEDED); 2889 MarshaledResponse unavailable; 2890 try { 2891 ResponseMarshaler marshaler = sseServer.getSokletConfig().orElseThrow().getResponseMarshaler(); 2892 unavailable = ResponseBodyCleanup.transform( 2893 marshaler.forServiceUnavailable(request, requestResult.getResourceMethod().orElse(null)), 2894 marshaler::postProcess); 2895 } catch (Throwable failure) { 2896 preparationFailure = failure; 2897 unavailable = DefaultSseServer.describeFailsafeHandshakeResponse(StatusCode.HTTP_500, 2898 request.getHttpMethod() == HttpMethod.HEAD); 2899 } 2900 effectiveResult = requestResult.copy().marshaledResponse(unavailable).response(null) 2901 .sseHandshakeResult(null).finish(); 2902 } else { 2903 reservationHolder.set(reservation); 2904 } 2905 } 2906 if (!(effectiveResult.getSseHandshakeResult().orElse(null) instanceof SseHandshakeResult.Accepted) 2907 && effectiveResult.getMarshaledResponse().isStreaming()) { 2908 preparationFailure = new IllegalArgumentException("SSE handshake rejection responses require a finite body"); 2909 effectiveResult = effectiveResult.copy().sseHandshakeResult(null).response(null) 2910 .marshaledResponse(DefaultSseServer.describeFailsafeHandshakeResponse(StatusCode.HTTP_500, 2911 request.getHttpMethod() == HttpMethod.HEAD)).finish(); 2912 } 2913 if (preparationFailure != null) { 2914 replacementFailureReason.set(SseConnection.HandshakeFailureReason.INTERNAL_ERROR); 2915 replacementFailureCause.set(preparationFailure); 2916 sseServer.safelyLog(LogEvent.with(LogEventType.SSE_SERVER_INTERNAL_ERROR, 2917 "Unable to generate SSE handshake response").throwable(preparationFailure).request(request) 2918 .resourceMethod(requestResult.getResourceMethod().orElse(null)).build()); 2919 } 2920 if (!sameInstance(effectiveResult, requestResult)) 2921 effectiveResult = effectiveResult.copy().marshaledResponse(ResponseBodyCleanup.retain( 2922 requestResult.getMarshaledResponse(), effectiveResult.getMarshaledResponse())).finish(); 2923 HttpRequestResult logicalResult = effectiveResult; 2924 if (!(effectiveResult.getSseHandshakeResult().orElse(null) instanceof SseHandshakeResult.Accepted)) 2925 effectiveResult = projectSimulatedSseRejection(request, effectiveResult); 2926 requestResultHolder.set(effectiveResult); 2927 if (!sameInstance(logicalResult, requestResult)) { 2928 return HttpTransportResponseOutcome.replaced(logicalResult.getMarshaledResponse(), preparationFailure, null); 2929 } 2930 return HttpTransportResponseOutcome.accepted(); 2931 })); 2932 2933 HttpRequestResult requestResult = requestResultHolder.get(); 2934 if (requestResult == null) 2935 throw new IllegalStateException("SSE request handler did not provide a request result"); 2936 2937 SseHandshakeResult sseHandshakeResult = requestResult.getSseHandshakeResult().orElse(null); 2938 if (!(sseHandshakeResult instanceof SseHandshakeResult.Accepted)) 2939 requestResult = materializeFiniteSimulationResult(requestResult); 2940 2941 if (sseHandshakeResult == null) { 2942 Throwable cause = replacementFailureCause.get(); 2943 if (cause == null) 2944 cause = requestResult.getRequestHandlingFailure().orElse(null); 2945 SseConnection.HandshakeFailureReason reason = replacementFailureReason.get(); 2946 if (reason == null) 2947 reason = cause == null ? SseConnection.HandshakeFailureReason.HANDSHAKE_REJECTED 2948 : SseConnection.HandshakeFailureReason.INTERNAL_ERROR; 2949 notifySseHandshakeFailure(request, requestResult, reason, cause); 2950 return new SseRequestResult.RequestFailed(requestResult); 2951 } 2952 2953 if (sseHandshakeResult instanceof SseHandshakeResult.Accepted acceptedHandshake) { 2954 SseClientInitializer clientInitializer = acceptedHandshake.getClientInitializer().orElse(null); 2955 StreamLifecycleCoordinator.Reservation reservation = requireNonNull(reservationHolder.get()); 2956 2957 try { 2958 requestResult = materializeAcceptedSseSimulationResult(requestResult); 2959 // Create a synthetic logical response using values from the accepted handshake. 2960 if (requestResult.getResponse().isEmpty()) 2961 requestResult = requestResult.copy() 2962 .response(Response.withStatusCode(200) 2963 .headers(acceptedHandshake.getHeaders()) 2964 .cookies(acceptedHandshake.getCookies()).build()).finish(); 2965 HandshakeAccepted handshakeAccepted = new HandshakeAccepted(acceptedHandshake, request, 2966 requestResult, sseServer, reservation); 2967 if (!handshakeAccepted.initialize(clientInitializer)) 2968 throw new IllegalStateException("The simulated SSE connection terminated before activation"); 2969 return handshakeAccepted; 2970 } catch (Throwable failure) { 2971 // An initializer already elected PRODUCER_FAILED; construction/activation 2972 // failures need the same reservation cleanup without replacing that winner. 2973 reservation.cancel(StreamTerminationReason.INTERNAL_ERROR, failure); 2974 reservation.complete(); 2975 if (failure instanceof RuntimeException runtimeException) 2976 throw runtimeException; 2977 if (failure instanceof Error error) 2978 throw error; 2979 throw new IllegalStateException("The simulated SSE client initializer failed", failure); 2980 } 2981 } 2982 2983 if (sseHandshakeResult instanceof SseHandshakeResult.Rejected rejectedHandshake) { 2984 notifySseHandshakeFailure(request, requestResult, SseConnection.HandshakeFailureReason.HANDSHAKE_REJECTED, null); 2985 return new HandshakeRejected(rejectedHandshake, requestResult); 2986 } 2987 2988 throw new IllegalStateException(format("Encountered unexpected %s: %s", SseHandshakeResult.class.getSimpleName(), sseHandshakeResult)); 2989 } 2990 2991 @NonNull 2992 private SseRequestResult rejectSseRequestBeforeHandling(@NonNull Request request, 2993 @NonNull MockSseServer server, @NonNull IllegalRequestException failure) { 2994 Object token = this.executionOwnerToken; 2995 try (LifecycleExecutionContext.Scope ignored = token == null ? null : LifecycleExecutionContext.enter(token)) { 2996 HttpRequestResult rejected = HttpRequestResult.withMarshaledResponse(MarshaledResponse.fromStatusCode(400)) 2997 .requestHandlingFailure(failure).effectiveSseRequest(request).build(); 2998 // Live admission reports rejection even when its custom error marshaler fails. 2999 notifySseHandshakeFailure(request, rejected, SseConnection.HandshakeFailureReason.HANDSHAKE_REJECTED, failure); 3000 ResponseMarshaler marshaler = server.getSokletConfig().orElseThrow().getResponseMarshaler(); 3001 MarshaledResponse original = marshaler.forThrowable(request, failure, null); 3002 MarshaledResponse response; 3003 try { 3004 response = ResponseBodyCleanup.transform(original, marshaler::postProcess); 3005 } catch (Throwable preparationFailure) { 3006 // The transform releases unchecked failures; JVM callbacks can also 3007 // emit checked failures that still need their owned channels closed. 3008 if (!(preparationFailure instanceof RuntimeException) && !(preparationFailure instanceof Error)) 3009 MarshaledResponse.closeChannels(original.ownedChannels(), preparationFailure); 3010 throw preparationFailure; 3011 } 3012 boolean materializationOwnsCleanup = false; 3013 try { 3014 enforceFinalResponseStatusCode(response.getStatusCode()); 3015 if (response.isStreaming()) 3016 throw new IllegalArgumentException("SSE handshake rejection responses require a finite body"); 3017 HttpRequestResult result = projectSimulatedSseRejection(request, 3018 rejected.copy().marshaledResponse(response).finish()); 3019 // The materializer releases every owned channel, including when reading fails. 3020 materializationOwnsCleanup = true; 3021 return new SseRequestResult.RequestFailed(materializeFiniteSimulationResult(result)); 3022 } catch (Throwable preparationFailure) { 3023 if (!materializationOwnsCleanup) 3024 MarshaledResponse.closeChannels(response.ownedChannels(), preparationFailure); 3025 throw preparationFailure; 3026 } 3027 } 3028 } 3029 3030 private void notifySseHandshakeFailure(@NonNull Request request, @NonNull HttpRequestResult requestResult, 3031 SseConnection.@NonNull HandshakeFailureReason reason, @Nullable Throwable cause) { 3032 MockSseServer server = requireNonNull(this.sseServer); 3033 StreamLifecycleCoordinator coordinator; 3034 synchronized (this.scopeStateLock) { 3035 if (this.closed.get()) 3036 return; 3037 coordinator = sseCoordinatorWhileLocked(); 3038 } 3039 Request effectiveRequest = requestResult.getEffectiveSseRequest().orElse(request); 3040 ResourceMethod resourceMethod = requestResult.getResourceMethod().orElse(null); 3041 if (!coordinator.dispatchRejectionObserver(() -> server.notifyConnectionLifecycle(effectiveRequest, resourceMethod, 3042 LogEventType.LIFECYCLE_OBSERVER_DID_ESTABLISH_SSE_CONNECTION_FAILED, "didFailToEstablishSseConnection", 3043 observer -> observer.didFailToEstablishSseConnection(effectiveRequest, resourceMethod, reason, cause), 3044 metrics -> metrics.didFailToEstablishSseConnection(effectiveRequest, resourceMethod, reason, cause)))) { 3045 server.safelyLog(LogEvent.with(LogEventType.SSE_SERVER_CONNECTION_REJECTED, 3046 "Simulated SSE handshake-failure observer capacity was unavailable.").request(request).build()); 3047 } 3048 } 3049 3050 @NonNull 3051 @Override 3052 public Simulator onBroadcastError(@Nullable Consumer<@NonNull Throwable> onBroadcastError) { 3053 Runnable releaseScope = enterScope(InternalLifecycleComponentType.SSE); 3054 try { 3055 MockSseServer sseServer = this.sseServer; 3056 if (sseServer != null) 3057 sseServer.onBroadcastError(onBroadcastError); 3058 return this; 3059 } finally { 3060 releaseScope.run(); 3061 } 3062 } 3063 3064 @NonNull 3065 @Override 3066 public Simulator onUnicastError(@Nullable Consumer<@NonNull Throwable> onUnicastError) { 3067 Runnable releaseScope = enterScope(InternalLifecycleComponentType.SSE); 3068 try { 3069 MockSseServer sseServer = this.sseServer; 3070 if (sseServer != null) 3071 sseServer.onUnicastError(onUnicastError); 3072 return this; 3073 } finally { 3074 releaseScope.run(); 3075 } 3076 } 3077 3078 @NonNull 3079 @Override 3080 public Optional<@NonNull HttpServer> getHttpServer() { 3081 requireScopeOpen(); 3082 return Optional.ofNullable(this.server); 3083 } 3084 3085 @NonNull 3086 @Override 3087 public Optional<@NonNull SseServer> getSseServer() { 3088 requireScopeOpen(); 3089 return Optional.ofNullable(this.sseServer); 3090 } 3091 3092 @NonNull 3093 @Override 3094 public Optional<@NonNull McpServer> getMcpServer() { 3095 requireScopeOpen(); 3096 return Optional.ofNullable(this.mcpServer); 3097 } 3098 3099 @NonNull 3100 private Runnable enterScope( 3101 @NonNull InternalLifecycleComponentType kind) { 3102 requireScopeOpen(); 3103 return this.scopeDispatchGate == null 3104 ? () -> { 3105 } 3106 : this.scopeDispatchGate.enter(requireNonNull(kind)); 3107 } 3108 3109 private void requireScopeOpen() { 3110 requireScopeOpenWhileLocked(); 3111 } 3112 3113 private void requireScopeOpenWhileLocked() { 3114 if (this.closed.get()) 3115 throw new IllegalStateException("The simulator scope is closed."); 3116 } 3117 3118 @NonNull 3119 SimulatorOptions getSimulatorOptions() { 3120 return this.simulatorOptions; 3121 } 3122 } 3123 3124 @NotThreadSafe 3125 private static final class SimulatorResponseOutput implements ManagedResponseStream.Output { 3126 @NonNull 3127 private final ByteArrayOutputStream byteArrayOutputStream; 3128 @NonNull 3129 private final Integer limitInBytes; 3130 @NonNull 3131 private final SimulatorCancelationToken cancelationToken; 3132 private boolean closed; 3133 private final java.util.concurrent.atomic.@NonNull AtomicLong acceptedBytes; 3134 3135 private SimulatorResponseOutput(@NonNull SimulatorCancelationToken cancelationToken, 3136 @NonNull Integer limitInBytes, java.util.concurrent.atomic.@NonNull AtomicLong acceptedBytes) { 3137 this.acceptedBytes = acceptedBytes; 3138 this.byteArrayOutputStream = new ByteArrayOutputStream(); 3139 this.limitInBytes = requireNonNull(limitInBytes); 3140 this.cancelationToken = requireNonNull(cancelationToken); 3141 } 3142 3143 @Override 3144 public void write(@NonNull ByteBuffer byteBuffer) throws IOException, StreamingResponseCanceledException { 3145 requireNonNull(byteBuffer); 3146 this.cancelationToken.throwIfCanceled(); 3147 3148 if (this.closed) 3149 throw new StreamingResponseCanceledException(StreamTerminationReason.APPLICATION_CANCELED); 3150 3151 ByteBuffer source = byteBuffer.asReadOnlyBuffer(); 3152 int bytesToWrite = source.remaining(); 3153 3154 if ((long) this.byteArrayOutputStream.size() + bytesToWrite > this.limitInBytes) { 3155 this.cancelationToken.cancel(StreamTerminationReason.SIMULATOR_LIMIT_EXCEEDED, null); 3156 throw new StreamingResponseCanceledException(StreamTerminationReason.SIMULATOR_LIMIT_EXCEEDED); 3157 } 3158 3159 byte[] bytes = new byte[bytesToWrite]; 3160 source.get(bytes); 3161 Runnable append = () -> { 3162 this.byteArrayOutputStream.writeBytes(bytes); 3163 this.acceptedBytes.addAndGet(bytesToWrite); 3164 byteBuffer.position(byteBuffer.position() + bytesToWrite); 3165 }; 3166 StreamLifecycleCoordinator.Reservation reservation = this.cancelationToken.reservation; 3167 if (reservation == null) append.run(); 3168 else if (!reservation.acceptTransportOutput(append)) this.cancelationToken.throwIfCanceled(); 3169 } 3170 3171 @Override 3172 public int stagingCapacityInBytes() { 3173 return Math.max(1, Math.min(8_192, this.limitInBytes)); 3174 } 3175 3176 @Override 3177 public void flush() throws StreamingResponseCanceledException { 3178 this.cancelationToken.throwIfCanceled(); 3179 } 3180 3181 @Override 3182 public boolean isOpen() { 3183 return !this.closed && !this.cancelationToken.isCanceled(); 3184 } 3185 3186 private byte @NonNull [] toByteArray() { 3187 this.closed = true; 3188 return this.byteArrayOutputStream.toByteArray(); 3189 } 3190 } 3191 3192 @ThreadSafe 3193 private static final class SimulatorCancelationToken implements CancelationToken { 3194 private static final @NonNull Runnable NO_CALLBACKS = () -> {}; 3195 private final @NonNull Object lock = new Object(); 3196 private boolean canceled; 3197 private boolean completed; 3198 private final StreamLifecycleCoordinator.@Nullable Reservation reservation; 3199 @Nullable 3200 private Set<@NonNull CancelationCallbackRegistration> callbacks; 3201 @NonNull 3202 private final Consumer<@NonNull Throwable> callbackFailureConsumer; 3203 @Nullable 3204 private StreamTerminationReason reason; 3205 @Nullable 3206 private Throwable cause; 3207 3208 private SimulatorCancelationToken(@NonNull Consumer<@NonNull Throwable> callbackFailureConsumer, 3209 StreamLifecycleCoordinator.@Nullable Reservation reservation) { 3210 this.callbackFailureConsumer = requireNonNull(callbackFailureConsumer); 3211 this.reservation = reservation; 3212 } 3213 3214 @Override 3215 @NonNull 3216 public Boolean isCanceled() { 3217 synchronized (this.lock) { 3218 boolean canceled = this.canceled || this.reservation != null && this.reservation.isCanceled(); 3219 return !isCompleted() && canceled; 3220 } 3221 } 3222 3223 @Override 3224 @NonNull 3225 public Optional<@NonNull StreamTerminationReason> getCancelationReason() { 3226 synchronized (this.lock) { 3227 Optional<StreamTerminationReason> reason = this.reason == null && this.reservation != null 3228 ? this.reservation.reason() : Optional.ofNullable(this.reason); 3229 return isCompleted() ? Optional.empty() : reason; 3230 } 3231 } 3232 3233 @Override 3234 @NonNull 3235 public Optional<@NonNull Throwable> getCancelationCause() { 3236 synchronized (this.lock) { 3237 Optional<Throwable> cause = this.reason == null && this.reservation != null 3238 ? this.reservation.cause() : Optional.ofNullable(this.cause); 3239 return isCompleted() ? Optional.empty() : cause; 3240 } 3241 } 3242 3243 @Override 3244 @NonNull 3245 public CallbackRegistration onCancel(@NonNull Runnable callback) { 3246 requireNonNull(callback); 3247 CancelationCallbackRegistration registration = new CancelationCallbackRegistration(callback); 3248 boolean runImmediately; 3249 3250 synchronized (this.lock) { 3251 if (isCompleted()) { 3252 registration.callback = null; 3253 return registration; 3254 } 3255 runImmediately = this.canceled; 3256 if (!runImmediately) { 3257 if (this.callbacks == null) 3258 this.callbacks = new LinkedHashSet<>(); 3259 this.callbacks.add(registration); 3260 } 3261 } 3262 if (runImmediately) 3263 registration.invoke(); 3264 return registration; 3265 } 3266 3267 private boolean cancel(@NonNull StreamTerminationReason reason, 3268 @Nullable Throwable cause) { 3269 requireNonNull(reason); 3270 if (reason == StreamTerminationReason.COMPLETED) 3271 throw new IllegalArgumentException("Cancelation reason cannot be COMPLETED"); 3272 if (this.reservation != null) 3273 return this.reservation.cancel(reason, cause); 3274 Runnable callbacks = reserveCancelation(reason, cause); 3275 if (callbacks == null) 3276 return false; 3277 callbacks.run(); 3278 return true; 3279 } 3280 3281 private void deliverCancelation(@NonNull StreamTerminationReason reason, @Nullable Throwable cause) { 3282 Runnable callbacks = reserveCancelation(reason, cause); 3283 if (callbacks != null && !sameInstance(callbacks, NO_CALLBACKS)) 3284 requireNonNull(this.reservation).dispatchCallbacks(callbacks); 3285 } 3286 3287 @Nullable 3288 private Runnable reserveCancelation(@NonNull StreamTerminationReason reason, @Nullable Throwable cause) { 3289 3290 Set<CancelationCallbackRegistration> callbacksToRun; 3291 synchronized (this.lock) { 3292 if (this.canceled || isCompleted()) 3293 return null; 3294 this.reason = reason; 3295 this.cause = cause; 3296 this.canceled = true; 3297 callbacksToRun = this.callbacks; 3298 this.callbacks = null; 3299 } 3300 if (callbacksToRun == null || callbacksToRun.isEmpty()) 3301 return NO_CALLBACKS; 3302 return () -> { 3303 for (CancelationCallbackRegistration registration : callbacksToRun) 3304 registration.invoke(); 3305 callbacksToRun.clear(); 3306 }; 3307 } 3308 3309 private void complete() { 3310 Set<CancelationCallbackRegistration> completedCallbacks; 3311 synchronized (this.lock) { 3312 if (this.canceled || this.completed || this.reservation != null 3313 && !this.reservation.isProductionComplete() && this.reservation.isCanceled()) 3314 return; 3315 this.completed = true; 3316 completedCallbacks = this.callbacks; 3317 this.callbacks = null; 3318 } 3319 // Completion may release many application registrations. Keep traversal 3320 // outside the token monitor used by framework cancellation signals. 3321 if (completedCallbacks != null) { 3322 for (CancelationCallbackRegistration registration : completedCallbacks) 3323 registration.callback = null; 3324 completedCallbacks.clear(); 3325 } 3326 } 3327 3328 private boolean isCompleted() { 3329 synchronized (this.lock) { 3330 return this.completed || this.reservation != null && this.reservation.isProductionComplete(); 3331 } 3332 } 3333 3334 private void runCallback(@NonNull Runnable callback) { 3335 try { 3336 callback.run(); 3337 } catch (Throwable throwable) { 3338 try { 3339 this.callbackFailureConsumer.accept(throwable); 3340 } catch (Throwable ignored) { 3341 // Diagnostic observers cannot suppress remaining callbacks. 3342 } 3343 } 3344 } 3345 3346 private final class CancelationCallbackRegistration implements CallbackRegistration { 3347 @Nullable 3348 private volatile Runnable callback; 3349 3350 private CancelationCallbackRegistration(@NonNull Runnable callback) { 3351 this.callback = callback; 3352 } 3353 3354 @Override 3355 public void close() { 3356 synchronized (SimulatorCancelationToken.this.lock) { 3357 this.callback = null; 3358 if (SimulatorCancelationToken.this.callbacks != null) 3359 SimulatorCancelationToken.this.callbacks.remove(this); 3360 } 3361 } 3362 3363 private void invoke() { 3364 Runnable callback; 3365 synchronized (SimulatorCancelationToken.this.lock) { 3366 callback = this.callback; 3367 this.callback = null; 3368 } 3369 if (callback != null) 3370 runCallback(callback); 3371 } 3372 } 3373 } 3374 3375 /** 3376 * Mock server that doesn't touch the network at all, useful for testing. 3377 * 3378 * @author <a href="https://www.revetkn.com">Mark Allen</a> 3379 */ 3380 @ThreadSafe 3381 static class MockHttpServer implements HttpServer { 3382 @NonNull 3383 private final TransportIdentity transportIdentity = TransportIdentity.create(); 3384 private final int streamingLifecycleCapacity; 3385 private final int streamingCallbackConcurrency; 3386 @NonNull 3387 private final Duration streamingCleanupTimeout; 3388 @Nullable 3389 private volatile SokletConfig sokletConfig; 3390 private volatile HttpServer.@Nullable RequestHandler requestHandler; 3391 3392 MockHttpServer() { 3393 this(null); 3394 } 3395 3396 MockHttpServer(@Nullable HttpServer sourceHttpServer) { 3397 if (sourceHttpServer instanceof DefaultHttpServer defaultHttpServer) { 3398 this.streamingLifecycleCapacity = defaultHttpServer.getStreamingLifecycleCapacity(); 3399 this.streamingCallbackConcurrency = defaultHttpServer.getStreamingCallbackConcurrency(); 3400 this.streamingCleanupTimeout = defaultHttpServer.getStreamingCleanupTimeout(); 3401 } else if (sourceHttpServer instanceof MockHttpServer mockHttpServer) { 3402 this.streamingLifecycleCapacity = mockHttpServer.streamingLifecycleCapacity; 3403 this.streamingCallbackConcurrency = mockHttpServer.streamingCallbackConcurrency; 3404 this.streamingCleanupTimeout = mockHttpServer.streamingCleanupTimeout; 3405 } else { 3406 this.streamingLifecycleCapacity = DefaultHttpServer.DEFAULT_STREAMING_LIFECYCLE_CAPACITY; 3407 this.streamingCallbackConcurrency = DefaultHttpServer.DEFAULT_STREAMING_CALLBACK_CONCURRENCY; 3408 this.streamingCleanupTimeout = DefaultHttpServer.DEFAULT_STREAMING_CLEANUP_TIMEOUT; 3409 } 3410 } 3411 3412 @NonNull 3413 @Override 3414 public TransportIdentity getTransportIdentity() { 3415 return this.transportIdentity; 3416 } 3417 3418 @NonNull 3419 @Override 3420 public TransportRuntime attach( 3421 @NonNull HttpTransportAttachmentContext attachmentContext, 3422 @NonNull StartupContext startupContext) { 3423 requireNonNull(startupContext); 3424 HttpTransportAttachmentContext exactContext = requireNonNull( 3425 attachmentContext); 3426 initialize(exactContext.getSokletConfig(), 3427 exactContext.getAdmissionFencedRequestHandler()); 3428 TransportTerminationSignal signal = exactContext.getTransportTerminationSignal(); 3429 return new TransportRuntime() { 3430 @Override 3431 public void start(@NonNull StartupContext context) { 3432 requireNonNull(context); 3433 } 3434 3435 @Override 3436 public void shutdownGracefully(@NonNull ShutdownContext context) { 3437 requireNonNull(context); 3438 signal.signalTerminated(); 3439 } 3440 3441 @Override 3442 public void shutdownForcibly(@NonNull ShutdownContext context) { 3443 requireNonNull(context); 3444 signal.signalTerminated(); 3445 } 3446 }; 3447 } 3448 3449 void start() { 3450 // No-op 3451 } 3452 3453 public void stop() { 3454 // No-op 3455 } 3456 3457 @NonNull 3458 public Boolean isStarted() { 3459 return true; 3460 } 3461 3462 public synchronized void initialize(@NonNull SokletConfig sokletConfig, 3463 @NonNull RequestHandler requestHandler) { 3464 requireNonNull(sokletConfig); 3465 requireNonNull(requestHandler); 3466 3467 this.sokletConfig = sokletConfig; 3468 this.requestHandler = requestHandler; 3469 } 3470 3471 synchronized void releaseSimulationScopeState() { 3472 this.sokletConfig = null; 3473 this.requestHandler = null; 3474 } 3475 3476 @NonNull 3477 synchronized Optional<@NonNull SokletConfig> getSokletConfig() { 3478 return Optional.ofNullable(this.sokletConfig); 3479 } 3480 3481 @NonNull 3482 synchronized Optional<@NonNull RequestHandler> getRequestHandler() { 3483 return Optional.ofNullable(this.requestHandler); 3484 } 3485 } 3486 3487 3488 /** 3489 * Mock Server-Sent Event unicaster that doesn't touch the network at all, useful for testing. 3490 */ 3491 @ThreadSafe 3492 static class MockSseUnicaster implements SseUnicaster { 3493 private final @NonNull Request request; 3494 private final @NonNull Consumer<@NonNull SseEvent> eventConsumer; 3495 private final @NonNull Consumer<@NonNull SseComment> commentConsumer; 3496 private final @NonNull Object initializerLock = new Object(); 3497 private boolean initializing; 3498 3499 MockSseUnicaster(@NonNull Request request, 3500 @NonNull Consumer<@NonNull SseEvent> eventConsumer, @NonNull Consumer<@NonNull SseComment> commentConsumer) { 3501 this.request = requireNonNull(request); 3502 this.eventConsumer = requireNonNull(eventConsumer); 3503 this.commentConsumer = requireNonNull(commentConsumer); 3504 } 3505 3506 void beginInitializer() { 3507 synchronized (this.initializerLock) { this.initializing = true; } 3508 } 3509 void finishInitializer() { 3510 synchronized (this.initializerLock) { this.initializing = false; } 3511 } 3512 3513 @Override public void unicastEvent(@NonNull SseEvent event) { 3514 synchronized (this.initializerLock) { 3515 if (!this.initializing) 3516 throw new IllegalStateException("The SSE unicaster is available only during client initialization"); 3517 this.eventConsumer.accept(requireNonNull(event)); 3518 } 3519 } 3520 @Override public void unicastComment(@NonNull SseComment comment) { 3521 synchronized (this.initializerLock) { 3522 if (!this.initializing) 3523 throw new IllegalStateException("The SSE unicaster is available only during client initialization"); 3524 this.commentConsumer.accept(requireNonNull(comment)); 3525 } 3526 } 3527 @Override public @NonNull ResourcePath getResourcePath() { return this.request.getResourcePath(); } 3528 } 3529 3530 /** 3531 * Mock Server-Sent Event broadcaster that doesn't touch the network at all, useful for testing. 3532 */ 3533 @ThreadSafe 3534 static class MockSseBroadcaster implements SseBroadcaster { 3535 // ConcurrentHashMap doesn't allow null values, so we use a sentinel if context is null 3536 private static final @NonNull Object NULL_CONTEXT_SENTINEL; 3537 3538 static { 3539 NULL_CONTEXT_SENTINEL = new Object(); 3540 } 3541 3542 @NonNull 3543 private final ResourcePath resourcePath; 3544 // Maps the Consumer (Listener) to its Context object (e.g. Locale) 3545 @NonNull 3546 private final Map<@NonNull Consumer<@NonNull SseEvent>, @NonNull Object> eventConsumers; 3547 // Same goes for comments 3548 @NonNull 3549 private final Map<@NonNull Consumer<@NonNull SseComment>, @NonNull Object> commentConsumers; 3550 private final @NonNull Set<@NonNull HandshakeAccepted> connections = ConcurrentHashMap.newKeySet(); 3551 @NonNull 3552 private final AtomicReference<@Nullable Consumer<@NonNull Throwable>> broadcastErrorHandler; 3553 @NonNull 3554 private final Consumer<@NonNull LogEvent> logEventConsumer; 3555 3556 public MockSseBroadcaster(@NonNull ResourcePath resourcePath, 3557 @NonNull AtomicReference<@Nullable Consumer<@NonNull Throwable>> broadcastErrorHandler, 3558 @NonNull Consumer<@NonNull LogEvent> logEventConsumer) { 3559 requireNonNull(resourcePath); 3560 requireNonNull(broadcastErrorHandler); 3561 requireNonNull(logEventConsumer); 3562 3563 this.resourcePath = resourcePath; 3564 this.eventConsumers = new ConcurrentHashMap<>(); 3565 this.commentConsumers = new ConcurrentHashMap<>(); 3566 this.broadcastErrorHandler = broadcastErrorHandler; 3567 this.logEventConsumer = logEventConsumer; 3568 } 3569 3570 @NonNull 3571 @Override 3572 public ResourcePath getResourcePath() { 3573 return this.resourcePath; 3574 } 3575 3576 @NonNull 3577 @Override 3578 public synchronized Long getClientCount() { 3579 return Long.valueOf(getEventConsumers().size() + getCommentConsumers().size() - this.connections.size()); 3580 } 3581 3582 @Override 3583 public void broadcastEvent(@NonNull SseEvent sseEvent) { 3584 requireNonNull(sseEvent); 3585 3586 for (Consumer<SseEvent> eventConsumer : getEventConsumers().keySet()) { 3587 try { 3588 eventConsumer.accept(sseEvent); 3589 } catch (Throwable throwable) { 3590 handleBroadcastError(throwable); 3591 } 3592 } 3593 } 3594 3595 @Override 3596 public void broadcastComment(@NonNull SseComment sseComment) { 3597 requireNonNull(sseComment); 3598 3599 for (Consumer<SseComment> commentConsumer : getCommentConsumers().keySet()) { 3600 try { 3601 commentConsumer.accept(sseComment); 3602 } catch (Throwable throwable) { 3603 handleBroadcastError(throwable); 3604 } 3605 } 3606 } 3607 3608 @Override 3609 public <T> void broadcastEvent(@NonNull Function<@Nullable Object, @Nullable T> keySelector, 3610 @NonNull Function<@Nullable T, @NonNull SseEvent> eventProvider) { 3611 requireNonNull(keySelector); 3612 requireNonNull(eventProvider); 3613 Map<T, SseBroadcastPayload<SseEvent>> payloadCache = new HashMap<>(); 3614 3615 getEventConsumers().forEach((consumer, context) -> { 3616 try { 3617 Object clientContext = sameInstance(context, NULL_CONTEXT_SENTINEL) ? null : context; 3618 T key = keySelector.apply(clientContext); 3619 SseBroadcastPayload<SseEvent> generation = payloadCache.computeIfAbsent(key, 3620 cacheKey -> SseBroadcastPayload.fromSupplier(() -> eventProvider.apply(cacheKey))); 3621 SseEvent event = generation.getPayload(); 3622 if (event == null) { 3623 generation.recordFailedClient(); 3624 return; 3625 } 3626 // A failing consumer cannot invalidate the payload shared by other clients. 3627 consumer.accept(event); 3628 } catch (Throwable throwable) { 3629 handleBroadcastError(throwable); 3630 } 3631 }); 3632 payloadCache.values().forEach(generation -> handleBroadcastGenerationFailure(generation, false)); 3633 } 3634 3635 @Override 3636 public <T> void broadcastComment(@NonNull Function<@Nullable Object, @Nullable T> keySelector, 3637 @NonNull Function<@Nullable T, @NonNull SseComment> commentProvider) { 3638 requireNonNull(keySelector); 3639 requireNonNull(commentProvider); 3640 Map<T, SseBroadcastPayload<SseComment>> payloadCache = new HashMap<>(); 3641 3642 getCommentConsumers().forEach((consumer, context) -> { 3643 try { 3644 Object clientContext = sameInstance(context, NULL_CONTEXT_SENTINEL) ? null : context; 3645 T key = keySelector.apply(clientContext); 3646 SseBroadcastPayload<SseComment> generation = payloadCache.computeIfAbsent(key, 3647 cacheKey -> SseBroadcastPayload.fromSupplier(() -> commentProvider.apply(cacheKey))); 3648 SseComment comment = generation.getPayload(); 3649 if (comment == null) { 3650 generation.recordFailedClient(); 3651 return; 3652 } 3653 consumer.accept(comment); 3654 } catch (Throwable throwable) { 3655 handleBroadcastError(throwable); 3656 } 3657 }); 3658 payloadCache.values().forEach(generation -> handleBroadcastGenerationFailure(generation, true)); 3659 } 3660 3661 private void handleBroadcastGenerationFailure(@NonNull SseBroadcastPayload<?> generation, boolean comment) { 3662 generation.getFailure().ifPresent(failure -> handleBroadcastError(failure, 3663 LogEventType.SSE_SERVER_BROADCAST_GENERATION_FAILED, 3664 format("Failed to generate %s for %d connections on %s", 3665 comment ? "Server-Sent Event comment" : "Server-Sent Event", 3666 generation.getFailedClientCount(), getResourcePath()))); 3667 } 3668 3669 @NonNull 3670 public Boolean registerEventConsumer(@NonNull Consumer<@NonNull SseEvent> eventConsumer) { 3671 return registerEventConsumer(eventConsumer, null); 3672 } 3673 3674 /** 3675 * Registers a consumer with an associated context, simulating a client with specific traits. 3676 */ 3677 @NonNull 3678 public Boolean registerEventConsumer(@NonNull Consumer<@NonNull SseEvent> eventConsumer, @Nullable Object context) { 3679 requireNonNull(eventConsumer); 3680 // map.put returns null if the key was new, which conceptually matches "add" returning true 3681 return this.getEventConsumers().put(eventConsumer, context == null ? NULL_CONTEXT_SENTINEL : context) == null; 3682 } 3683 3684 @NonNull 3685 public Boolean unregisterEventConsumer(@NonNull Consumer<@NonNull SseEvent> eventConsumer) { 3686 requireNonNull(eventConsumer); 3687 return this.getEventConsumers().remove(eventConsumer) != null; 3688 } 3689 3690 @NonNull 3691 public Boolean registerCommentConsumer(@NonNull Consumer<@NonNull SseComment> commentConsumer) { 3692 return registerCommentConsumer(commentConsumer, null); 3693 } 3694 3695 /** 3696 * Registers a consumer with an associated context, simulating a client with specific traits. 3697 */ 3698 @NonNull 3699 public Boolean registerCommentConsumer(@NonNull Consumer<@NonNull SseComment> commentConsumer, @Nullable Object context) { 3700 requireNonNull(commentConsumer); 3701 return this.getCommentConsumers().put(commentConsumer, context == null ? NULL_CONTEXT_SENTINEL : context) == null; 3702 } 3703 3704 @NonNull 3705 public Boolean unregisterCommentConsumer(@NonNull Consumer<@NonNull SseComment> commentConsumer) { 3706 requireNonNull(commentConsumer); 3707 return this.getCommentConsumers().remove(commentConsumer) != null; 3708 } 3709 3710 @NonNull 3711 Map<@NonNull Consumer<@NonNull SseEvent>, @NonNull Object> getEventConsumers() { 3712 return this.eventConsumers; 3713 } 3714 3715 @NonNull 3716 Map<@NonNull Consumer<@NonNull SseComment>, @NonNull Object> getCommentConsumers() { 3717 return this.commentConsumers; 3718 } 3719 3720 synchronized void registerConnection(@NonNull HandshakeAccepted connection, @NonNull Consumer<@NonNull SseEvent> events, 3721 @NonNull Consumer<@NonNull SseComment> comments, @Nullable Object context) { 3722 this.connections.add(connection); 3723 registerEventConsumer(events, context); 3724 registerCommentConsumer(comments, context); 3725 } 3726 3727 synchronized void unregisterConnection(@NonNull HandshakeAccepted connection, @NonNull Consumer<@NonNull SseEvent> events, 3728 @NonNull Consumer<@NonNull SseComment> comments) { 3729 unregisterEventConsumer(events); 3730 unregisterCommentConsumer(comments); 3731 this.connections.remove(connection); 3732 } 3733 3734 synchronized void releaseSimulationScopeState() { 3735 this.connections.clear(); 3736 this.eventConsumers.clear(); 3737 this.commentConsumers.clear(); 3738 } 3739 3740 void handleBroadcastError(@NonNull Throwable throwable) { 3741 handleBroadcastError(throwable, LogEventType.SSE_SERVER_INTERNAL_ERROR, 3742 "SSE simulator broadcast consumer failed"); 3743 } 3744 3745 private void handleBroadcastError(@NonNull Throwable throwable, 3746 @NonNull LogEventType logEventType, @NonNull String message) { 3747 requireNonNull(throwable); 3748 Consumer<Throwable> handler = this.broadcastErrorHandler.get(); 3749 if (handler != null) { 3750 try { 3751 handler.accept(throwable); 3752 return; 3753 } catch (Throwable ignored) { 3754 // Fall through to default behavior. 3755 } 3756 } 3757 safelyLog(LogEvent.with(logEventType, message).throwable(throwable).build()); 3758 } 3759 3760 void safelyLog(@NonNull LogEvent logEvent) { 3761 requireNonNull(logEvent); 3762 3763 try { 3764 this.logEventConsumer.accept(logEvent); 3765 } catch (Throwable ignored) { 3766 // No safe fallback sink is available here. 3767 } 3768 } 3769 } 3770 3771 /** 3772 * Mock Server-Sent Event server that doesn't touch the network at all, useful for testing. 3773 * 3774 * @author <a href="https://www.revetkn.com">Mark Allen</a> 3775 */ 3776 @ThreadSafe 3777 static class MockSseServer implements SseServer { 3778 @NonNull 3779 private final TransportIdentity transportIdentity; 3780 final int streamingLifecycleCapacity; 3781 final int connectionQueueCapacity; 3782 @Nullable 3783 private volatile SokletConfig sokletConfig; 3784 private volatile SseServer.@Nullable RequestHandler requestHandler; 3785 @NonNull 3786 private volatile Set<@NonNull ResourcePathDeclaration> 3787 resourcePathDeclarations; 3788 private volatile boolean started; 3789 @NonNull 3790 private final ConcurrentHashMap<@NonNull ResourcePath, @NonNull MockSseBroadcaster> broadcastersByResourcePath; 3791 @NonNull 3792 private final AtomicReference<@Nullable Consumer<@NonNull Throwable>> broadcastErrorHandler; 3793 @NonNull 3794 private final AtomicReference<@Nullable Consumer<@NonNull Throwable>> unicastErrorHandler; 3795 3796 public MockSseServer() { this(null); } 3797 3798 MockSseServer(@Nullable SseServer sourceSseServer) { 3799 if (sourceSseServer instanceof DefaultSseServer server) { 3800 this.streamingLifecycleCapacity = server.getStreamingLifecycleCapacity(); 3801 this.connectionQueueCapacity = server.getConnectionQueueCapacity(); 3802 } else if (sourceSseServer instanceof MockSseServer server) { 3803 this.streamingLifecycleCapacity = server.streamingLifecycleCapacity; 3804 this.connectionQueueCapacity = server.connectionQueueCapacity; 3805 } else { 3806 this.streamingLifecycleCapacity = DefaultSseServer.DEFAULT_STREAMING_LIFECYCLE_CAPACITY; 3807 this.connectionQueueCapacity = 128; 3808 } 3809 this.transportIdentity = TransportIdentity.create(); 3810 this.broadcastersByResourcePath = new ConcurrentHashMap<>(); 3811 this.broadcastErrorHandler = new AtomicReference<>(); 3812 this.unicastErrorHandler = new AtomicReference<>(); 3813 this.resourcePathDeclarations = Set.of(); 3814 } 3815 3816 @NonNull 3817 @Override 3818 public TransportIdentity getTransportIdentity() { 3819 return this.transportIdentity; 3820 } 3821 3822 @NonNull 3823 @Override 3824 public TransportRuntime attach( 3825 @NonNull SseTransportAttachmentContext attachmentContext, 3826 @NonNull StartupContext startupContext) { 3827 requireNonNull(startupContext); 3828 SseTransportAttachmentContext exactContext = requireNonNull( 3829 attachmentContext); 3830 initialize(exactContext.getSokletConfig(), 3831 exactContext.getAdmissionFencedRequestHandler()); 3832 TransportTerminationSignal signal = exactContext.getTransportTerminationSignal(); 3833 return new TransportRuntime() { 3834 @Override 3835 public void start(@NonNull StartupContext context) { 3836 requireNonNull(context); 3837 MockSseServer.this.started = true; 3838 } 3839 3840 @Override 3841 public void shutdownGracefully(@NonNull ShutdownContext context) { 3842 requireNonNull(context); 3843 MockSseServer.this.started = false; 3844 signal.signalTerminated(); 3845 } 3846 3847 @Override 3848 public void shutdownForcibly(@NonNull ShutdownContext context) { 3849 requireNonNull(context); 3850 MockSseServer.this.started = false; 3851 signal.signalTerminated(); 3852 } 3853 }; 3854 } 3855 3856 void start() { 3857 this.started = true; 3858 } 3859 3860 public void stop() { 3861 this.started = false; 3862 } 3863 3864 @NonNull 3865 public Boolean isStarted() { 3866 return this.started; 3867 } 3868 3869 @NonNull 3870 @Override 3871 public synchronized Optional<? extends @NonNull SseBroadcaster> acquireBroadcaster( 3872 @Nullable ResourcePath resourcePath) { 3873 if (resourcePath == null || !this.started 3874 || this.sokletConfig == null) 3875 return Optional.empty(); 3876 if (this.resourcePathDeclarations.stream().noneMatch( 3877 declaration -> declaration.matches(resourcePath))) 3878 return Optional.empty(); 3879 3880 MockSseBroadcaster broadcaster = getBroadcastersByResourcePath() 3881 .computeIfAbsent(resourcePath, rp -> new MockSseBroadcaster(rp, broadcastErrorHandler, this::safelyLog)); 3882 3883 return Optional.of(broadcaster); 3884 } 3885 3886 void registerConnection(@NonNull HandshakeAccepted connection, @NonNull ResourcePath resourcePath, 3887 @NonNull Consumer<@NonNull SseEvent> events, @NonNull Consumer<@NonNull SseComment> comments, @Nullable Object context) { 3888 this.broadcastersByResourcePath.computeIfAbsent(resourcePath, 3889 rp -> new MockSseBroadcaster(rp, this.broadcastErrorHandler, this::safelyLog)) 3890 .registerConnection(connection, events, comments, context); 3891 } 3892 3893 void unregisterConnection(@NonNull HandshakeAccepted connection, @NonNull ResourcePath resourcePath, 3894 @NonNull Consumer<@NonNull SseEvent> events, @NonNull Consumer<@NonNull SseComment> comments) { 3895 MockSseBroadcaster broadcaster = this.broadcastersByResourcePath.get(resourcePath); 3896 if (broadcaster != null) 3897 broadcaster.unregisterConnection(connection, events, comments); 3898 } 3899 3900 public void registerEventConsumer(@NonNull ResourcePath resourcePath, 3901 @NonNull Consumer<@NonNull SseEvent> eventConsumer) { 3902 registerEventConsumer(resourcePath, eventConsumer, null); 3903 } 3904 3905 public void registerEventConsumer(@NonNull ResourcePath resourcePath, 3906 @NonNull Consumer<@NonNull SseEvent> eventConsumer, 3907 @Nullable Object context) { 3908 requireNonNull(resourcePath); 3909 requireNonNull(eventConsumer); 3910 3911 MockSseBroadcaster broadcaster = getBroadcastersByResourcePath() 3912 .computeIfAbsent(resourcePath, rp -> new MockSseBroadcaster(rp, broadcastErrorHandler, this::safelyLog)); 3913 3914 broadcaster.registerEventConsumer(eventConsumer, context); 3915 } 3916 3917 @NonNull 3918 public Boolean unregisterEventConsumer(@NonNull ResourcePath resourcePath, 3919 @NonNull Consumer<@NonNull SseEvent> eventConsumer) { 3920 requireNonNull(resourcePath); 3921 requireNonNull(eventConsumer); 3922 3923 MockSseBroadcaster broadcaster = getBroadcastersByResourcePath().get(resourcePath); 3924 3925 if (broadcaster == null) 3926 return false; 3927 3928 return broadcaster.unregisterEventConsumer(eventConsumer); 3929 } 3930 3931 public void registerCommentConsumer(@NonNull ResourcePath resourcePath, 3932 @NonNull Consumer<@NonNull SseComment> commentConsumer) { 3933 registerCommentConsumer(resourcePath, commentConsumer, null); 3934 } 3935 3936 public void registerCommentConsumer(@NonNull ResourcePath resourcePath, 3937 @NonNull Consumer<@NonNull SseComment> commentConsumer, 3938 @Nullable Object context) { 3939 requireNonNull(resourcePath); 3940 requireNonNull(commentConsumer); 3941 3942 MockSseBroadcaster broadcaster = getBroadcastersByResourcePath() 3943 .computeIfAbsent(resourcePath, rp -> new MockSseBroadcaster(rp, broadcastErrorHandler, this::safelyLog)); 3944 3945 broadcaster.registerCommentConsumer(commentConsumer, context); 3946 } 3947 3948 @NonNull 3949 public Boolean unregisterCommentConsumer(@NonNull ResourcePath resourcePath, 3950 @NonNull Consumer<@NonNull SseComment> commentConsumer) { 3951 requireNonNull(resourcePath); 3952 requireNonNull(commentConsumer); 3953 3954 MockSseBroadcaster broadcaster = getBroadcastersByResourcePath().get(resourcePath); 3955 3956 if (broadcaster == null) 3957 return false; 3958 3959 return broadcaster.unregisterCommentConsumer(commentConsumer); 3960 } 3961 3962 public synchronized void initialize(@NonNull SokletConfig sokletConfig, 3963 SseServer.@NonNull RequestHandler requestHandler) { 3964 requireNonNull(sokletConfig); 3965 requireNonNull(requestHandler); 3966 3967 this.sokletConfig = sokletConfig; 3968 this.requestHandler = requestHandler; 3969 this.resourcePathDeclarations = Set.copyOf(sokletConfig 3970 .getResourceMethodResolver().getResourceMethods().stream() 3971 .filter(ResourceMethod::isSseEventSource) 3972 .map(ResourceMethod::getResourcePathDeclaration) 3973 .toList()); 3974 } 3975 3976 synchronized void releaseSimulationScopeState() { 3977 this.started = false; 3978 this.sokletConfig = null; 3979 this.requestHandler = null; 3980 this.resourcePathDeclarations = Set.of(); 3981 for (MockSseBroadcaster broadcaster 3982 : this.broadcastersByResourcePath.values()) 3983 broadcaster.releaseSimulationScopeState(); 3984 this.broadcastersByResourcePath.clear(); 3985 this.broadcastErrorHandler.set(null); 3986 this.unicastErrorHandler.set(null); 3987 } 3988 3989 public void onBroadcastError(@Nullable Consumer<@NonNull Throwable> onBroadcastError) { 3990 this.broadcastErrorHandler.set(onBroadcastError); 3991 } 3992 3993 public void onUnicastError(@Nullable Consumer<@NonNull Throwable> onUnicastError) { 3994 this.unicastErrorHandler.set(onUnicastError); 3995 } 3996 3997 void notifyConnectionLifecycle(@NonNull Request request, @Nullable ResourceMethod resourceMethod, 3998 @NonNull LogEventType failureType, @NonNull String callbackName, 3999 @NonNull Consumer<@NonNull LifecycleObserver> observerNotification, 4000 @NonNull Consumer<@NonNull MetricsCollector> metricsNotification) { 4001 SokletConfig config = this.sokletConfig; 4002 if (config == null) 4003 return; 4004 try { 4005 observerNotification.accept(config.getAggregateLifecycleObserver()); 4006 } catch (Throwable failure) { 4007 safelyLog(LogEvent.with(failureType, "An exception occurred while invoking LifecycleObserver::" + callbackName) 4008 .request(request).resourceMethod(resourceMethod).throwable(failure).build()); 4009 } 4010 try { 4011 metricsNotification.accept(config.getMetricsCollector()); 4012 } catch (Throwable failure) { 4013 safelyLog(LogEvent.with(LogEventType.METRICS_COLLECTOR_FAILED, 4014 "An exception occurred while invoking MetricsCollector::" + callbackName) 4015 .request(request).resourceMethod(resourceMethod).throwable(failure).build()); 4016 } 4017 } 4018 4019 void safelyLog(@NonNull LogEvent logEvent) { 4020 requireNonNull(logEvent); 4021 4022 SokletConfig sokletConfig = this.sokletConfig; 4023 4024 if (sokletConfig == null) 4025 return; 4026 4027 try { 4028 sokletConfig.getAggregateLifecycleObserver().didReceiveLogEvent(logEvent); 4029 } catch (Throwable observerFailure) { 4030 LifecycleObserverLogFallback.report(observerFailure); 4031 } 4032 } 4033 4034 @NonNull 4035 synchronized Optional<@NonNull SokletConfig> getSokletConfig() { 4036 return Optional.ofNullable(this.sokletConfig); 4037 } 4038 4039 @NonNull 4040 synchronized Optional<SseServer.@NonNull RequestHandler> getRequestHandler() { 4041 return Optional.ofNullable(this.requestHandler); 4042 } 4043 4044 @NonNull 4045 ConcurrentHashMap<@NonNull ResourcePath, @NonNull MockSseBroadcaster> getBroadcastersByResourcePath() { 4046 return this.broadcastersByResourcePath; 4047 } 4048 4049 @NonNull 4050 AtomicReference<@Nullable Consumer<@NonNull Throwable>> getBroadcastErrorHandler() { 4051 return this.broadcastErrorHandler; 4052 } 4053 4054 @NonNull 4055 AtomicReference<@Nullable Consumer<@NonNull Throwable>> getUnicastErrorHandler() { 4056 return this.unicastErrorHandler; 4057 } 4058 } 4059 4060}