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 &mdash; 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&lt;T&gt;} (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}) &mdash; 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}) &mdash; 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) &mdash; 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 &mdash; 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}