tdcornish opened a new pull request, #726: URL: https://github.com/apache/httpcomponents-core/pull/726
`IOSessionImpl#getEventMask` now takes the session lock and returns 0 once the session is closed, matching `setEvent`, `clearEvent` and `setEventMask`. This completes [HTTPCORE-659](https://issues.apache.org/jira/browse/HTTPCORE-659) and [HTTPCORE-753](https://issues.apache.org/jira/browse/HTTPCORE-753). HTTPCORE-659 moved the closed-status check inside the lock for the three setters, and HTTPCORE-753 made `close()` cancel the key under that lock, which fixed a consumer thread's `CapacityWindow#update` racing the I/O reactor closing the session. On TLS connections that update can still fail: `SSLIOSession#setEvent` calls `updateEventMask`, which reads `getEventMask` first, and the getter had no closed check. The `CancelledKeyException` escapes `ReactiveDataConsumer`'s `Subscription#request`, and the response stream never reaches `onComplete`. Which HttpClient versions reach it: with HttpClient 5.6.5 and 5.7-alpha1 the reproducer below hits the race on both released HttpCore and current master. From HttpClient [e750f52dd](https://github.com/apache/httpcomponents-client/commit/e750f52dd9beedb34d039a2212cb5c05b6183640) ("Simplifcation of the message exchange handling logic in the main async exec interceptors") onwards it no longer does, so 5.7-beta1 and HttpClient master come out clean. That commit doesn't touch this code path directly; it looks like it changes when the exchange completes relative to the connection close, which hides the race rather than removing it. I haven't traced that part, and I couldn't reproduce with HttpCore alone, without HttpClient. The getter is still inconsistent with the setters on master, and the unit test below shows the exception directly. Design notes: - The guard is in `IOSessionImpl` rather than `SSLIOSession#updateEventMask` so that it covers every caller, as the setters' guard does. The other callers are the `SSLIOSession` constructor, where the session is open, and `InternalDataChannel#onIOEvent`, which on a closed session now skips `outputReady` instead of throwing. - The check has to be under the lock: `close()` cancels the key while holding it, so a check outside the lock could still race the cancellation. `SSLIOSession` already holds this reentrant lock when it calls `getEventMask`, so the TLS path takes no additional lock. - Returning 0 is safe for `updateEventMask`: it then calls `setEventMask`, which already does nothing on a closed session. Testing: - New `TestIOSessionImpl#eventMaskIsEmptyOnceClosed`; it fails with `CancelledKeyException` without the change. - `httpcore5`, `httpcore5-h2`, `httpcore5-reactive` and `httpcore5-testing` test suites pass (1,975 / 397 / 11 / 375 tests, 4 skipped). - Reproducer, run on x86-64 Linux (4 CPUs, JDK 21) with master 63c0556f4 and then with this change installed into the local Maven repository: | HttpClient | HttpCore | hung streams | `request()` threw | |---|---|---:|---:| | 5.6.5 | 5.4.4 | 17 | 27 | | 5.7-alpha1 | 5.5-beta2 | 29 | 63 | | 5.7-beta1 | 5.5-beta4 | 0 | 0 | | 5.6.5 | master | 23 | 37 | | 5.7-alpha1 | master | 36 | 58 | | 5.6.5 | master + this change | 0 | 0 | | 5.7-alpha1 | master + this change | 0 | 0 | Each row is 10,000 streams. macOS gave the same pattern. The race is timing-dependent, so counts vary from run to run. How to reproduce: The attached Gradle project, `closerace-repro-gradle.zip`, runs a local TLS server that streams 240 KB chunked responses, each ending with `Connection: close`, after which the server closes the connection. The client is `CloseableHttpAsyncClient` with `ReactiveResponseConsumer`; each subscriber requests random batches of 1-16 items and issues every next request from a separate thread pool. A stream that receives nothing for 5 seconds without a terminal signal counts as hung. Gradle needs JDK 17 or later. ``` ./gradlew matrix # released HttpClient/HttpCore pairs # HttpCore from a local checkout (cd ../httpcomponents-core && ./mvnw install -DskipTests -Drat.skip -Djapicmp.skip -pl httpcore5,httpcore5-h2,httpcore5-reactive -am) ./gradlew matrix -PcoreVersion=5.5-beta5-SNAPSHOT ``` `matrix` prints one row per HttpClient/HttpCore pair and marks it REPRODUCED or clean. <details> <summary>CloseRaceRepro.java (the reproducer in the zip)</summary> ```java import java.io.*; import java.net.*; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.security.cert.X509Certificate; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; import javax.net.ssl.*; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.Message; import org.apache.hc.core5.http.nio.support.AsyncRequestBuilder; import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.io.CloseMode; import org.apache.hc.core5.reactive.ReactiveResponseConsumer; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; /** * TLS server streaming chunked responses over keep-alive connections; once a connection is older * than ageLimitMs (or has served 100 requests) its next response carries "Connection: close" and * the server closes the connection after it. The client is the HttpClient 5 async client with ReactiveResponseConsumer; * each subscriber requests a random batch of 1-16 items, and for slowQuarters/4 of the streams it * issues each next request from a separate thread pool after a 1 ms pause. Reports streams that * never reach a terminal signal and calls to Subscription#request that threw. * * args: total concurrency ageLimitMs closeMode(notify|fin|rst) chunks chunkSize [slowQuarters 0-4, default 1] * example: CloseRaceRepro 10000 64 0 notify 400 600 4 * The server keystore is read from ks.p12, or from the closerace.keystore system property. */ @SuppressWarnings("deprecation") public class CloseRaceRepro { public static final class Result { public final int total, ok, okClose, err, errClose, hang, serverResponses, serverClose, requestThrew; public final List<String> hangs; public final String firstThrown; Result(int total, int ok, int okClose, int err, int errClose, int hang, int serverResponses, int serverClose, int requestThrew, List<String> hangs, String firstThrown) { this.total = total; this.ok = ok; this.okClose = okClose; this.err = err; this.errClose = errClose; this.hang = hang; this.serverResponses = serverResponses; this.serverClose = serverClose; this.requestThrew = requestThrew; this.hangs = hangs; this.firstThrown = firstThrown; } public String summary() { return String.format("total=%d ok=%d (close %d) err=%d (close %d) hang=%d serverResponses=%d serverClose=%d request()-threw=%d", total, ok, okClose, err, errClose, hang, serverResponses, serverClose, requestThrew); } } final AtomicInteger serverCloseResponses = new AtomicInteger(); final AtomicInteger serverResponses = new AtomicInteger(); final String closeMode; final long ageLimitMs; final int chunks, chunkSize; CloseRaceRepro(long ageLimitMs, String closeMode, int chunks, int chunkSize) { this.ageLimitMs = ageLimitMs; this.closeMode = closeMode; this.chunks = chunks; this.chunkSize = chunkSize; } public static void main(String[] a) throws Exception { Result r = run(a); System.out.println(r.summary()); r.hangs.stream().limit(20).forEach(h -> System.out.println("HANG " + h)); if (r.firstThrown != null) System.out.println("FIRST request() EXCEPTION\n" + r.firstThrown); System.exit(0); } public static Result run(String... a) throws Exception { int total = Integer.parseInt(a[0]); int concurrency = Integer.parseInt(a[1]); int slowQuarters = a.length > 6 ? Integer.parseInt(a[6]) : 1; return new CloseRaceRepro(Long.parseLong(a[2]), a[3], Integer.parseInt(a[4]), Integer.parseInt(a[5])) .execute(total, concurrency, slowQuarters); } Result execute(int total, int concurrency, int slowQuarters) throws Exception { long idleHangMs = 5000; SSLContext serverCtx = SSLContext.getInstance("TLS"); KeyStore ks = KeyStore.getInstance("PKCS12"); try (InputStream in = new FileInputStream(System.getProperty("closerace.keystore", "ks.p12"))) { ks.load(in, "changeit".toCharArray()); } KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); kmf.init(ks, "changeit".toCharArray()); serverCtx.init(kmf.getKeyManagers(), null, null); ServerSocket ss = new ServerSocket(0, 1024, InetAddress.getLoopbackAddress()); int port = ss.getLocalPort(); Thread acceptor = new Thread(() -> { while (true) { try { Socket plain = ss.accept(); Thread t = new Thread(() -> serve(serverCtx, plain)); t.setDaemon(true); t.start(); } catch (IOException e) { return; } } }); acceptor.setDaemon(true); acceptor.start(); SSLContext clientCtx = SSLContext.getInstance("TLS"); clientCtx.init(null, new TrustManager[]{new X509TrustManager() { public void checkClientTrusted(X509Certificate[] c, String s) {} public void checkServerTrusted(X509Certificate[] c, String s) {} public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } }}, null); PoolingAsyncClientConnectionManager cm = PoolingAsyncClientConnectionManagerBuilder.create() .setTlsStrategy(ClientTlsStrategyBuilder.create().setSslContext(clientCtx) .setHostnameVerifier(NoopHostnameVerifier.INSTANCE).build()) .setMaxConnTotal(concurrency * 2).setMaxConnPerRoute(concurrency * 2).build(); CloseableHttpAsyncClient client = HttpAsyncClients.custom().setConnectionManager(cm) .setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_1).build(); client.start(); ExecutorService consumers = Executors.newFixedThreadPool(16); ScheduledExecutorService sched = Executors.newScheduledThreadPool(2); Semaphore inflight = new Semaphore(concurrency); AtomicInteger ok = new AtomicInteger(), err = new AtomicInteger(), hang = new AtomicInteger(); AtomicInteger okClose = new AtomicInteger(), errClose = new AtomicInteger(); AtomicInteger requestThrew = new AtomicInteger(); AtomicReference<String> firstThrown = new AtomicReference<>(); List<String> hangs = Collections.synchronizedList(new ArrayList<>()); long expected = (long) chunks * chunkSize; Random rnd = new Random(42); for (int i = 0; i < total; i++) { inflight.acquire(); final int id = i; final AtomicLong bytes = new AtomicLong(); final AtomicLong lastActivity = new AtomicLong(System.currentTimeMillis()); final AtomicBoolean done = new AtomicBoolean(); final AtomicReference<String> conn = new AtomicReference<>("?"); final int batch = 1 + rnd.nextInt(16); final boolean slow = rnd.nextInt(4) < slowQuarters; ReactiveResponseConsumer consumer = new ReactiveResponseConsumer(new FutureCallback<Message<HttpResponse, Publisher<ByteBuffer>>>() { public void completed(Message<HttpResponse, Publisher<ByteBuffer>> m) { conn.set(m.getHead().getFirstHeader("Connection") == null ? "keep-alive" : m.getHead().getFirstHeader("Connection").getValue()); m.getBody().subscribe(new Subscriber<ByteBuffer>() { Subscription sub; int outstanding; void request() { try { sub.request(batch); } catch (RuntimeException e) { requestThrew.incrementAndGet(); StringWriter sw = new StringWriter(); e.printStackTrace(new PrintWriter(sw)); firstThrown.compareAndSet(null, sw.toString()); } } public void onSubscribe(Subscription s) { sub = s; outstanding = batch; request(); } public void onNext(ByteBuffer b) { bytes.addAndGet(b.remaining()); lastActivity.set(System.currentTimeMillis()); if (--outstanding == 0) { outstanding = batch; if (slow) { consumers.execute(() -> { try { Thread.sleep(1); } catch (InterruptedException e) {} request(); }); } else { request(); } } } public void onError(Throwable t) { if (done.compareAndSet(false, true)) { err.incrementAndGet(); if ("close".equalsIgnoreCase(conn.get())) errClose.incrementAndGet(); inflight.release(); } } public void onComplete() { if (done.compareAndSet(false, true)) { if (bytes.get() != expected) { System.out.println("SHORT id=" + id + " bytes=" + bytes.get()); } ok.incrementAndGet(); if ("close".equalsIgnoreCase(conn.get())) okClose.incrementAndGet(); inflight.release(); } } }); } public void failed(Exception e) { if (done.compareAndSet(false, true)) { err.incrementAndGet(); inflight.release(); } } public void cancelled() { if (done.compareAndSet(false, true)) { err.incrementAndGet(); inflight.release(); } } }); client.execute(AsyncRequestBuilder.get("https://localhost:" + port + "/stream?id=" + id).build(), consumer, null, null); sched.schedule(new Runnable() { public void run() { if (done.get()) return; if (System.currentTimeMillis() - lastActivity.get() > idleHangMs) { if (done.compareAndSet(false, true)) { hang.incrementAndGet(); hangs.add("id=" + id + " bytes=" + bytes.get() + "/" + expected + " conn=" + conn.get() + " batch=" + batch + " slow=" + slow); inflight.release(); } } else { sched.schedule(this, 1000, TimeUnit.MILLISECONDS); } } }, idleHangMs, TimeUnit.MILLISECONDS); } inflight.acquire(concurrency); client.close(CloseMode.IMMEDIATE); ss.close(); consumers.shutdownNow(); sched.shutdownNow(); return new Result(total, ok.get(), okClose.get(), err.get(), errClose.get(), hang.get(), serverResponses.get(), serverCloseResponses.get(), requestThrew.get(), new ArrayList<>(hangs), firstThrown.get()); } void serve(SSLContext ctx, Socket plain) { long born = System.currentTimeMillis(); int reqNo = 0; try { SSLSocket tls = (SSLSocket) ctx.getSocketFactory().createSocket(plain, null, plain.getPort(), true); tls.setUseClientMode(false); tls.startHandshake(); InputStream in = new BufferedInputStream(tls.getInputStream()); OutputStream out = tls.getOutputStream(); byte[] payload = new byte[chunkSize]; Arrays.fill(payload, (byte) 'x'); byte[] chunkHead = (Integer.toHexString(chunkSize) + "\r\n").getBytes(StandardCharsets.US_ASCII); while (true) { if (!readHead(in)) return; reqNo++; boolean close = ageLimitMs >= 0 && (System.currentTimeMillis() - born >= ageLimitMs || reqNo >= 100); serverResponses.incrementAndGet(); if (close) serverCloseResponses.incrementAndGet(); String head = "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n" + "Transfer-Encoding: chunked\r\n" + (close ? "Connection: close\r\n" : "") + "\r\n"; ByteArrayOutputStream buf = new ByteArrayOutputStream(); buf.write(head.getBytes(StandardCharsets.US_ASCII)); for (int c = 0; c < chunks; c++) { buf.write(chunkHead); buf.write(payload); buf.write('\r'); buf.write('\n'); if (buf.size() > 16384 || c == chunks - 1) { out.write(buf.toByteArray()); out.flush(); buf.reset(); } } out.write("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); out.flush(); if (close) { switch (closeMode) { case "notify": tls.close(); break; case "fin": plain.shutdownOutput(); plain.close(); break; case "rst": plain.setSoLinger(true, 0); plain.close(); break; default: throw new IllegalArgumentException(closeMode); } return; } } } catch (IOException e) { try { plain.close(); } catch (IOException ignore) {} } } static boolean readHead(InputStream in) throws IOException { int state = 0, b; while ((b = in.read()) != -1) { if ((state == 0 || state == 2) && b == '\r') state++; else if ((state == 1 || state == 3) && b == '\n') { state++; if (state == 4) return true; } else state = 0; } return false; } } ``` </details> -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
