001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.reactive; 018 019import static jakarta.servlet.http.HttpServletResponse.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.io.*; 023import java.util.*; 024import java.util.concurrent.*; 025import java.util.concurrent.atomic.*; 026import java.util.function.*; 027import java.util.logging.*; 028 029import org.apache.juneau.commons.http.MediaRanges; 030import org.apache.juneau.http.response.*; 031import org.apache.juneau.marshall.marshaller.*; 032import org.apache.juneau.marshall.serializer.*; 033import org.apache.juneau.marshall.sse.*; 034import org.apache.juneau.rest.server.*; 035import org.apache.juneau.rest.server.logging.*; 036import org.apache.juneau.rest.server.processor.*; 037import org.apache.juneau.rest.server.util.*; 038 039import jakarta.servlet.*; 040import jakarta.servlet.http.*; 041 042/** 043 * Response processor that bridges <a class="doclink" href="https://www.reactive-streams.org/">Reactive Streams</a> 044 * return values from {@code @RestOp} handlers to Juneau's response pipeline. 045 * 046 * <h5 class='topic'>Activation (opt-in)</h5> 047 * <p> 048 * This processor ships in the opt-in {@code juneau-rest-server-reactive} module and is <b>not</b> wired 049 * into {@code DefaultConfig}. A bare {@code juneau-rest-server} has zero reactive behavior. When the 050 * {@code juneau-rest-server-reactive} jar is on the classpath, its 051 * {@code META-INF/services/ResponseProcessor} provider file is discovered 052 * by {@code RestContext} via {@link ServiceLoader} and this processor is front-loaded ahead of 053 * {@code AsyncResponseProcessor} in the chain — no {@code @Rest(responseProcessors=...)} entry required. 054 * 055 * <h5 class='topic'>What it handles</h5> 056 * <p> 057 * This is the single, shared spine for all reactive return-type support. It natively understands the 058 * JDK type {@link java.util.concurrent.Flow.Publisher Flow.Publisher<T>} (no external dependency 059 * required) and, through registered {@link ReactiveStreamsAdapter} providers, any third-party reactive 060 * type the opt-in {@code juneau-rest-server-reactive-reactor} module adapts to it (Project Reactor 061 * {@code Mono} / {@code Flux}, RxJava 3 {@code Single} / {@code Maybe} / {@code Flowable} / 062 * {@code Observable}, and the Reactive-Streams {@code org.reactivestreams.Publisher}). 063 * 064 * <p> 065 * Single-value reactive types (e.g. {@code Mono}, {@code Single}) are adapted to a {@link CompletionStage} 066 * and collapse onto the existing {@link AsyncResponseProcessor} async path, inheriting its timeout, 067 * completion-executor ({@code @Rest(asyncCompletionExecutor)}), and MDC bridging behavior for free. 068 * 069 * <h5 class='topic'>Response shapes for multi-value streams</h5> 070 * <p> 071 * A streaming publisher is rendered as one of three shapes, selected by the negotiated response media 072 * type (the handler's {@link RestResponse#setContentType(String) Content-Type}, then the request 073 * {@code Accept} header): 074 * <ul> 075 * <li><b>SSE</b> ({@code text/event-stream}) — each element is emitted as a Server-Sent-Events 076 * frame. {@link SseEvent} elements are written verbatim; any other element type is JSON-encoded 077 * into the {@code data:} field. 078 * <li><b>NDJSON</b> ({@code application/x-ndjson}, {@code application/jsonl}, {@code application/jsonlines}, 079 * {@code application/json5l}, {@code application/json5lines}, {@code application/json-seq}) — each 080 * element is JSON-encoded on its own line. Unless the handler set a Content-Type, the response echoes the 081 * highest-ranked NDJSON-family {@code Accept} range in canonical form ({@code jsonlines} becomes 082 * {@code jsonl}, {@code json5lines} becomes {@code json5l}, {@code json-seq} becomes {@code x-ndjson}); 083 * otherwise it is {@code application/x-ndjson}. See {@link #NDJSON_ALIASES}. 084 * <li><b>Buffer</b> (default, any other media type) — all elements are collected into a 085 * {@link List List} and serialized through the normal serializer chain (e.g. a JSON 086 * array). The collection is wrapped in a {@link CompletableFuture} and handed to the async path, 087 * so a slow producer never blocks the request thread. 088 * </ul> 089 * 090 * <h5 class='topic'>Backpressure</h5> 091 * <p> 092 * Streaming subscribers request one element at a time ({@code request(1)} on subscribe and again after 093 * each frame is written and flushed). Because writing to the servlet output stream blocks until the 094 * socket accepts the bytes, this gives natural backpressure — the producer is paced by the 095 * client's drain rate and the server-side buffer does not grow without bound. Buffer-shape subscribers 096 * request {@link Long#MAX_VALUE} since the collection is bounded by the publisher's own completion. 097 * 098 * <h5 class='topic'>Threading, executors, and MDC</h5> 099 * <p> 100 * Buffer-shape responses route through {@link AsyncResponseProcessor} and therefore honor 101 * {@code @Rest(asyncCompletionExecutor)} and the SLF4J MDC bridge. Streaming-shape 102 * frame writes happen on whichever thread the publisher emits on (the Reactor / RxJava scheduler); this 103 * processor does not impose a {@code subscribeOn(...)} so it never fights the library's scheduler model. 104 * When MDC propagation is enabled, the request-thread MDC snapshot is reinstalled around each 105 * {@code onNext} / terminal callback via {@link MdcAsyncListener} so log statements emitted while writing 106 * a frame see the request's diagnostic context. 107 * 108 * <h5 class='topic'>Synchronous fallback</h5> 109 * <p> 110 * In environments where {@link HttpServletRequest#startAsync()} is unsupported (notably Juneau's 111 * {@code MockServletRequest}), streaming subscribes synchronously and blocks the request thread until 112 * the publisher terminates (bounded by the configured async timeout), writing frames as they arrive. 113 * This keeps the unit-test surface working without a real servlet container. 114 * 115 * @see ReactiveStreamsAdapter 116 * @see AsyncResponseProcessor 117 * @since 10.0.0 118 */ 119@SuppressWarnings({ 120 "java:S1192", // Media-type literals are protocol tokens; the alias table reads best with them inline 121 "java:S2095", // Writer is response-owned; we never close it. 122 "resource" // Closeables here are framework-managed and not owned/closed by this class; not a real leak. 123}) 124public class ReactiveResponseProcessor implements ResponseProcessor { 125 126 private static final Logger LOG = Logger.getLogger(ReactiveResponseProcessor.class.getName()); 127 128 private static final AtomicReference<List<ReactiveStreamsAdapter>> ADAPTERS = new AtomicReference<>(); 129 130 private enum Shape { BUFFER, SSE, NDJSON } 131 132 /** 133 * NDJSON-family media types accepted from {@code Accept}, mapped to the Content-Type echoed on an NDJSON-shape 134 * response. {@code application/json-seq} maps to {@code application/x-ndjson} because this writer does not emit 135 * RFC 7464 record separators. 136 */ 137 static final Map<String,String> NDJSON_ALIASES = Map.of( 138 "application/jsonl", "application/jsonl", 139 "application/jsonlines", "application/jsonl", 140 "application/json5l", "application/json5l", 141 "application/json5lines", "application/json5l", 142 "application/x-ndjson", "application/x-ndjson", 143 "application/json-seq", "application/x-ndjson" 144 ); 145 146 /** 147 * Returns the canonical NDJSON-family type of the highest-ranked {@code Accept} range in that family. 148 * 149 * <p> 150 * Ranges are walked in {@link MediaRanges} order (q descending, then its own tie-break). Ranges with 151 * {@code q=0} are skipped; parameters other than {@code q} are ignored; matching is case-insensitive. 152 * 153 * @param accept The raw {@code Accept} header, possibly <jk>null</jk>. 154 * @return The canonical type, or <jk>null</jk> when no NDJSON-family range is acceptable. 155 */ 156 static String negotiatedNdjsonType(String accept) { 157 for (var r : MediaRanges.of(accept).toList()) { 158 if (r.getQValue() <= 0f) 159 continue; 160 var echoed = NDJSON_ALIASES.get((r.getType() + "/" + r.getSubType()).toLowerCase(Locale.ROOT)); 161 if (echoed != null) 162 return echoed; 163 } 164 return null; 165 } 166 167 @FunctionalInterface 168 private interface FrameEncoder { 169 void write(FinishablePrintWriter w, Object element) throws IOException, SerializeException; 170 } 171 172 @Override /* Overridden from ResponseProcessor */ 173 public int process(RestOpSession opSession) throws IOException, BasicHttpException { 174 var res = opSession.getResponse(); 175 var content = res.getContent().orElse(null); 176 177 if (content == null) 178 return NEXT; 179 180 if (content instanceof Flow.Publisher<?> content2) 181 return dispatch(opSession, content2); 182 183 var a = adapters(); 184 for (var adapter : a) { 185 if (adapter.canAdapt(content)) { 186 var adaptation = adapter.adapt(content); 187 if (adaptation.isStream()) 188 return dispatch(opSession, adaptation.stream()); 189 res.setContent(adaptation.single()); 190 return RESTART; 191 } 192 } 193 194 return NEXT; 195 } 196 197 private int dispatch(RestOpSession opSession, Flow.Publisher<?> pub) throws IOException { 198 var shape = resolveShape(opSession); 199 if (shape == Shape.BUFFER) 200 return handleBuffer(opSession, pub); 201 return handleStream(opSession, pub, shape); 202 } 203 204 // ----------------------------------------------------------------------------------------------------------------- 205 // Buffer shape — collect into a List, hand to the async path. 206 // ----------------------------------------------------------------------------------------------------------------- 207 208 private int handleBuffer(RestOpSession opSession, Flow.Publisher<?> pub) { 209 var cf = new CompletableFuture<List<Object>>(); 210 pub.subscribe(new CollectingSubscriber(cf)); 211 opSession.getResponse().setContent(cf); 212 return RESTART; 213 } 214 215 // ----------------------------------------------------------------------------------------------------------------- 216 // Streaming shapes — SSE / NDJSON. 217 // ----------------------------------------------------------------------------------------------------------------- 218 219 @SuppressWarnings({ 220 "java:S1141", // Nested try/catch cleanly separates startAsync recovery from the sync fallback. 221 "java:S3776" // Streaming setup is branchy by nature (content-type prep + async-vs-sync fallback). 222 }) 223 private int handleStream(RestOpSession opSession, Flow.Publisher<?> pub, Shape shape) throws IOException { 224 var res = opSession.getResponse(); 225 prepareStreamingHeaders(res, shape, opSession.getRequest().getHttpServletRequest().getHeader("Accept")); 226 227 var writer = res.getNegotiatedWriter(); 228 FrameEncoder encoder = shape == Shape.SSE 229 ? ReactiveResponseProcessor::writeSseFrame 230 : ReactiveResponseProcessor::writeNdjsonFrame; 231 232 var req = opSession.getRequest().getHttpServletRequest(); 233 var mdc = opSession.getRestContext().isMdcAsyncPropagation() 234 ? MdcAsyncListener.snapshot() 235 : null; 236 237 AsyncContext asyncCtx = null; 238 if (req.isAsyncSupported()) { 239 try { 240 asyncCtx = req.startAsync(); 241 } catch (IllegalStateException e) { 242 asyncCtx = null; // Already committed, or async not actually supported. 243 } 244 } 245 246 if (asyncCtx != null) { 247 // Publish async ownership + the resolved debug snapshot on the request thread BEFORE subscribing, so an 248 // inline-terminating publisher never observes a null snapshot and finish() reliably skips synchronous emit. 249 AsyncResponseProcessor.markAsyncOwnedAndSnapshot(opSession); 250 var ac = asyncCtx; 251 ac.setTimeout(0); // No artificial timeout — streams are paced by the producer / client disconnect. 252 253 // Single exactly-once completion gate shared by the subscriber terminal, the container AsyncListener 254 // fallback (error/timeout/complete), and a subscribe() that throws after handoff — so a client abort or 255 // container error can never silently skip the completion-path emit, and it can never fire twice. 256 var completed = new AtomicBoolean(false); 257 Consumer<Throwable> completion = t -> { 258 if (completed.compareAndSet(false, true)) 259 completeAsync(ac, opSession, t); 260 }; 261 ac.addListener(new ReactiveCompletionListener(completion)); 262 try { 263 pub.subscribe(new StreamingSubscriber(res, writer, encoder, mdc, completion)); 264 } catch (RuntimeException e) { 265 // subscribe() threw after the async handoff — route it through the same completion hook rather than 266 // leaking async ownership with no emit and no AsyncContext.complete(). 267 completion.accept(e); 268 } 269 return FINISHED; 270 } 271 272 // Synchronous fallback (MockServletRequest et al.): block until the publisher terminates. 273 var done = new CompletableFuture<Void>(); 274 var subscriber = new StreamingSubscriber(res, writer, encoder, mdc, 275 t -> { if (t == null) done.complete(null); else done.completeExceptionally(t); }); 276 pub.subscribe(subscriber); 277 if (! awaitSync(done, syncTimeoutMillis(opSession))) 278 // Timed out or interrupted before the publisher terminated — cancel the still-live subscription so it 279 // cannot emit late writes against a response we are about to finish. 280 subscriber.cancel(); 281 try { 282 writer.flush(); 283 res.flushBuffer(); 284 } catch (IOException e) { 285 LOG.log(Level.FINEST, e, () -> "Final flush of synchronous reactive stream failed: " + e.getMessage()); 286 } 287 return FINISHED; 288 } 289 290 private static void prepareStreamingHeaders(RestResponse res, Shape shape, String accept) { 291 var ct = res.getContentType(); 292 if (shape == Shape.SSE) { 293 if (ct == null || ! ct.contains("event-stream")) 294 res.setContentType(SseSerializer.MEDIA_TYPE); 295 res.setHeader("X-Content-Type-Options", "nosniff"); 296 } else if (ct == null) { 297 var echoed = negotiatedNdjsonType(accept); 298 res.setContentType(echoed == null ? "application/x-ndjson" : echoed); 299 } 300 res.setHeader("Cache-Control", "no-cache"); 301 res.setHeader("Content-Encoding", "identity"); 302 } 303 304 /** 305 * Blocks until the synchronous stream completes, times out, or is interrupted. 306 * 307 * @return <jk>true</jk> if the publisher terminated (normally or with an error) before the timeout; <jk>false</jk> 308 * if the wait timed out or was interrupted (in which case the caller must cancel the still-live subscription). 309 */ 310 @SuppressWarnings({ 311 "java:S2142" // Interrupt flag restored before returning. 312 }) 313 private static boolean awaitSync(CompletableFuture<Void> done, long timeoutMs) { 314 try { 315 done.get(timeoutMs, TimeUnit.MILLISECONDS); 316 } catch (InterruptedException e) { 317 Thread.currentThread().interrupt(); 318 return false; 319 } catch (TimeoutException e) { 320 LOG.log(Level.FINE, e, () -> "Synchronous reactive stream timed out: " + e.getMessage()); 321 return false; 322 } catch (ExecutionException e) { 323 // Publisher terminated with an error — the subscription is already finished, no orphan to cancel. 324 LOG.log(Level.FINE, e, () -> "Synchronous reactive stream did not complete cleanly: " + e.getMessage()); 325 } 326 return true; 327 } 328 329 private static void completeAsync(AsyncContext asyncCtx, RestOpSession opSession, Throwable error) { 330 var res = opSession.getResponse(); 331 var session = opSession.getRestSession(); 332 try { 333 if (error != null) { 334 LOG.log(Level.FINE, error, () -> "Reactive stream terminated with error: " + error.getMessage()); 335 session.exception(error); 336 } 337 if (! res.getHttpServletResponse().isCommitted()) 338 res.flushBuffer(); 339 } catch (IOException e) { 340 LOG.log(Level.FINEST, e, () -> "Flush during async stream completion failed: " + e.getMessage()); 341 } finally { 342 // Same completion-path ordering as ordinary async: render from the final stream state, then release the 343 // request, then complete the async context. Nested try/finally isolates each step so a debug-emit failure 344 // cannot suppress request close, and a request-close failure cannot suppress async-context completion. 345 try { 346 RestDebugPipeline.emitOnCompletion(session); 347 } finally { 348 try { 349 opSession.closeRequest(); 350 } catch (RuntimeException e) { // HTT: defensive; the guarded call does not throw today 351 LOG.log(Level.FINEST, e, () -> "Async request close failed: " + e.getMessage()); 352 } finally { 353 completeQuietly(asyncCtx); 354 } 355 } 356 } 357 } 358 359 private static void completeQuietly(AsyncContext asyncCtx) { 360 try { 361 asyncCtx.complete(); 362 } catch (IllegalStateException e) { 363 LOG.log(Level.FINEST, e, () -> "AsyncContext.complete() raced with the container: " + e.getMessage()); 364 } 365 } 366 367 /** 368 * Container-side fallback that routes servlet async terminal events (error, timeout, complete) through the shared 369 * completion hook. 370 * 371 * <p> 372 * The subscriber's own terminal callback is the normal completion trigger; this listener guarantees that a container 373 * error or timeout that bypasses the subscriber still reaches the exactly-once completion gate, so completion-path 374 * emission and request close are never silently skipped. All events funnel through the same gated {@link Consumer}; 375 * {@code onComplete} (fired by our own {@code AsyncContext.complete()}) is therefore a gated no-op. 376 */ 377 private static final class ReactiveCompletionListener implements AsyncListener { 378 private final Consumer<Throwable> completion; 379 380 ReactiveCompletionListener(Consumer<Throwable> completion) { 381 this.completion = completion; 382 } 383 384 @Override public void onComplete(AsyncEvent event) { completion.accept(null); } 385 @Override public void onError(AsyncEvent event) { completion.accept(event.getThrowable()); } 386 @Override public void onTimeout(AsyncEvent event) { completion.accept(new TimeoutException("Reactive stream timed out.")); } 387 @Override public void onStartAsync(AsyncEvent event) { /* no-op */ } 388 } 389 390 // ----------------------------------------------------------------------------------------------------------------- 391 // Frame encoders. 392 // ----------------------------------------------------------------------------------------------------------------- 393 394 private static void writeSseFrame(FinishablePrintWriter w, Object element) throws IOException, SerializeException { 395 if (element == null) 396 return; 397 if (element instanceof SseEvent element2) { 398 Sse.DEFAULT.write(element2, w); 399 return; 400 } 401 var data = element instanceof CharSequence element2 ? element2.toString() : Json.of(element); 402 Sse.DEFAULT.write(new SseEvent(null, data), w); 403 } 404 405 private static void writeNdjsonFrame(FinishablePrintWriter w, Object element) throws SerializeException { 406 if (element == null) 407 return; 408 var json = element instanceof CharSequence element2 ? element2.toString() : Json.of(element); 409 w.write(json); 410 w.write("\n"); 411 w.flush(); 412 } 413 414 // ----------------------------------------------------------------------------------------------------------------- 415 // Shape / timeout resolution. 416 // ----------------------------------------------------------------------------------------------------------------- 417 418 private static Shape resolveShape(RestOpSession opSession) { 419 var res = opSession.getResponse(); 420 var ct = res.getContentType(); 421 var accept = opSession.getRequest().getHttpServletRequest().getHeader("Accept"); 422 var probe = ((ct == null ? "" : ct) + "," + (accept == null ? "" : accept)).toLowerCase(Locale.ROOT); 423 if (probe.contains("event-stream")) 424 return Shape.SSE; 425 if (probe.contains("ndjson") || probe.contains("jsonl") || probe.contains("json5l") || probe.contains("json-seq")) 426 return Shape.NDJSON; 427 return Shape.BUFFER; 428 } 429 430 private static long syncTimeoutMillis(RestOpSession opSession) { 431 var t = opSession.getContext().getAsyncTimeoutMillis(); 432 if (t <= 0) 433 t = opSession.getRestContext().getAsyncTimeoutMillis(); 434 return t > 0 ? t : AsyncResponseProcessor.DEFAULT_ASYNC_TIMEOUT_MILLIS; 435 } 436 437 // ----------------------------------------------------------------------------------------------------------------- 438 // Adapter discovery (ServiceLoader, cached, defensive against absent backing libraries). 439 // ----------------------------------------------------------------------------------------------------------------- 440 441 private static List<ReactiveStreamsAdapter> adapters() { 442 var a = ADAPTERS.get(); 443 if (a == null) { 444 synchronized (ReactiveResponseProcessor.class) { 445 a = ADAPTERS.get(); 446 if (a == null) { // HTT: inner DCL null-check missed branch requires concurrent first-load race 447 a = loadAdapters(); 448 ADAPTERS.set(a); 449 } 450 } 451 } 452 return a; 453 } 454 455 private static List<ReactiveStreamsAdapter> loadAdapters() { 456 var out = new ArrayList<ReactiveStreamsAdapter>(); 457 try { 458 var it = ServiceLoader.load(ReactiveStreamsAdapter.class).iterator(); 459 while (it.hasNext()) 460 addNextAdapter(it, out); 461 } catch (ServiceConfigurationError | RuntimeException e) { // HTT: defensive; a malformed registration file fails inside it.next(), which addNextAdapter handles, so this only triggers if hasNext() itself fails (not reproducible without breaking ServiceLoader for the other tests) 462 LOG.log(Level.FINE, e, () -> "ServiceLoader for ReactiveStreamsAdapter failed: " + e.getMessage()); 463 } 464 return List.copyOf(out); 465 } 466 467 private static void addNextAdapter(Iterator<ReactiveStreamsAdapter> it, List<ReactiveStreamsAdapter> out) { 468 try { 469 out.add(it.next()); 470 } catch (ServiceConfigurationError | RuntimeException e) { 471 LOG.log(Level.FINE, e, () -> "Skipping reactive adapter (backing library likely absent): " + e.getMessage()); // HTT: adapters are loaded once per JVM, before any test can attach a log handler, so this message supplier only runs when FINE logging is on at first use; a seam (resettable adapter cache) would make this testable 472 } 473 } 474 475 // ----------------------------------------------------------------------------------------------------------------- 476 // Subscribers. 477 // ----------------------------------------------------------------------------------------------------------------- 478 479 /** Collects all emitted elements into a list and completes the supplied future (buffer shape). */ 480 private static final class CollectingSubscriber implements Flow.Subscriber<Object> { 481 private final CompletableFuture<List<Object>> cf; 482 private final List<Object> items = l(); 483 484 CollectingSubscriber(CompletableFuture<List<Object>> cf) { 485 this.cf = cf; 486 } 487 488 @Override public void onSubscribe(Flow.Subscription s) { s.request(Long.MAX_VALUE); } 489 @Override public void onNext(Object item) { items.add(item); } 490 @Override public void onError(Throwable t) { cf.completeExceptionally(t); } 491 @Override public void onComplete() { cf.complete(items); } 492 } 493 494 /** Writes each emitted element as a wire frame (SSE / NDJSON), one element at a time (bounded backpressure). */ 495 private static final class StreamingSubscriber implements Flow.Subscriber<Object> { 496 private final RestResponse res; 497 private final FinishablePrintWriter writer; 498 private final FrameEncoder encoder; 499 private final Map<String,String> mdc; 500 private final Consumer<Throwable> onTerminate; 501 private final AtomicBoolean terminated = new AtomicBoolean(); 502 private volatile boolean wrote; 503 504 /** Published once by {@code onSubscribe} (Reactive Streams: at most one call); read by {@code onNext}/{@code cancel}. */ 505 private final AtomicReference<Flow.Subscription> subscription = new AtomicReference<>(); 506 507 StreamingSubscriber(RestResponse res, FinishablePrintWriter writer, FrameEncoder encoder, 508 Map<String,String> mdc, Consumer<Throwable> onTerminate) { 509 this.res = res; 510 this.writer = writer; 511 this.encoder = encoder; 512 this.mdc = mdc; 513 this.onTerminate = onTerminate; 514 } 515 516 @Override public void onSubscribe(Flow.Subscription s) { 517 subscription.set(s); 518 s.request(1); 519 } 520 521 @Override public void onNext(Object item) { 522 try { 523 withMdc(() -> writeFrame(item)); 524 subscription.get().request(1); 525 } catch (RuntimeException e) { 526 subscription.get().cancel(); 527 terminate(unwrap(e)); 528 } 529 } 530 531 @Override public void onError(Throwable t) { terminate(t); } 532 @Override public void onComplete() { terminate(null); } 533 534 /** 535 * Cancels the underlying subscription and marks this subscriber terminated so no late frames are written. 536 * 537 * <p> 538 * Invoked by the synchronous fallback path when the wait times out or is interrupted before the publisher 539 * terminates, preventing an orphaned subscription from emitting writes against a finished response. 540 */ 541 void cancel() { 542 if (! terminated.compareAndSet(false, true)) // HTT: true branch needs the publisher to terminate between the wait timing out and cancel(), a cross-thread race; a seam would make this testable 543 return; 544 var s = subscription.get(); 545 if (s != null) 546 s.cancel(); 547 } 548 549 private void writeFrame(Object item) { 550 try { 551 encoder.write(writer, item); 552 writer.flush(); 553 // PrintWriter (which writer extends) never lets an IOException from the underlying stream escape — 554 // it swallows it and only records it via checkError(). Without this check, a broken/closed client 555 // connection is silently absorbed here and the pump keeps "succeeding" forever instead of detecting 556 // the write failure and cleaning up (the dead-client cleanup path, see SEP-2243). 557 if (writer.checkError()) 558 throw new IOException("Write failed: the client connection is no longer writable."); 559 res.flushBuffer(); 560 wrote = true; 561 } catch (IOException | SerializeException e) { 562 throw isex(e); 563 } 564 } 565 566 private void withMdc(Runnable r) { 567 MdcAsyncListener.wrap((BiConsumer<Object,Throwable>) (v, e) -> r.run(), mdc).accept(null, null); 568 } 569 570 private void terminate(Throwable t) { 571 if (! terminated.compareAndSet(false, true)) 572 return; 573 withMdc(() -> { 574 if (t != null && ! wrote) { 575 try { 576 if (! res.getHttpServletResponse().isCommitted()) 577 res.getHttpServletResponse().sendError(SC_INTERNAL_SERVER_ERROR); 578 } catch (IOException e) { 579 LOG.log(Level.FINEST, e, () -> "sendError on pre-write stream failure failed: " + e.getMessage()); 580 } 581 } 582 }); 583 onTerminate.accept(t); 584 } 585 586 private static Throwable unwrap(Throwable t) { 587 if ((t instanceof IllegalStateException || t instanceof CompletionException) && t.getCause() != null) 588 return t.getCause(); 589 return t; 590 } 591 } 592}