From fc1345d01069ef1321941ccf78c831bf0488f52f Mon Sep 17 00:00:00 2001 From: Layue13 Date: Thu, 22 Oct 2026 18:34:36 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=8E=BB=E6=8E=89=E5=86=B7=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8A=E7=9A=84=E9=87=8D=E5=A4=8D=E5=BC=80=E9=94=80?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=AE=B0=E5=BD=95=E6=9C=AA=E9=87=87=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E4=B8=AD=E7=BB=A7=E6=89=B9=E9=87=8F=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=AE=9E=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Message.validateChannel:通道名正则只编译一次(原来每条消息 String.matches 都重新编译), 长度检查不再分配字节数组 - 后端 OwnedBukkitSessionService:反射查找按接收者类用 ClassValue 缓存(含未找到), 玩家每次加入不再重复 getMethod - testing/benchmarks/results/2026-10-22-relay-batch-completion:中继只给每批最后一次写入 挂完成回调。I/O 线程分配降 22%、中继 lambda 降 97%,但每帧 CPU 只低 2.4% 且范围重叠, 未达到事先设定的 10% 规则,未采用;补丁随记录保留 - PluginHostTest.placementFailuresOnlyFailTheirOwnRequest:40 ms 超时在负载下可能早于插件 处理器运行而偶发失败,改为 500 ms 并断言首个请求确实到达插件 Co-Authored-By: Claude Opus 5.5 --- .../bukkit/OwnedBukkitSessionService.java | 70 +++++++++++++----- .../dev/moonbridge/messaging/Message.java | 10 +-- .../core/plugin/PluginHostTest.java | 5 +- .../2026-10-22-relay-batch-completion-raw.txt | 72 +++++++++++++++++++ .../2026-10-22-relay-batch-completion.md | 57 +++++++++++++++ .../2026-10-22-relay-batch-completion.patch | 72 +++++++++++++++++++ 6 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 testing/benchmarks/results/2026-10-22-relay-batch-completion-raw.txt create mode 100644 testing/benchmarks/results/2026-10-22-relay-batch-completion.md create mode 100644 testing/benchmarks/results/2026-10-22-relay-batch-completion.patch diff --git a/backend/src/main/java/dev/moonbridge/bukkit/OwnedBukkitSessionService.java b/backend/src/main/java/dev/moonbridge/bukkit/OwnedBukkitSessionService.java index ef2de80..6e261fd 100644 --- a/backend/src/main/java/dev/moonbridge/bukkit/OwnedBukkitSessionService.java +++ b/backend/src/main/java/dev/moonbridge/bukkit/OwnedBukkitSessionService.java @@ -92,12 +92,12 @@ final class OwnedBukkitSessionService implements BukkitSessionService, Listener, } static ProofProperty stripForwardedProof(Player player) { - Object profile = invokeNoArg(player, "getProfile"); - if (profile == null) profile = invokeNoArg(player, "getGameProfile"); + Object profile = invoke(player, GET_PROFILE); + if (profile == null) profile = invoke(player, GET_GAME_PROFILE); if (profile == null) return ProofProperty.unavailable(); - Object properties = invokeNoArg(profile, "getProperties"); + Object properties = invoke(profile, GET_PROPERTIES); if (properties == null) return ProofProperty.unavailable(); - Object matches = invoke(properties, "get", new Class[]{Object.class}, ForwardedSessionProof.PROPERTY_NAME); + Object matches = invoke(properties, MULTIMAP_GET, ForwardedSessionProof.PROPERTY_NAME); if (!(matches instanceof Iterable)) return ProofProperty.unavailable(); Collection propertiesToRemove = new java.util.ArrayList(); Collection values = new java.util.ArrayList<>(2); @@ -105,40 +105,74 @@ final class OwnedBukkitSessionService implements BukkitSessionService, Listener, for (Object property : (Iterable) matches) { if (property == null) return ProofProperty.failed(true); propertiesToRemove.add(property); - Object value = invokeNoArg(property, "getValue"); + Object value = invoke(property, GET_VALUE); if (value instanceof String && ((String) value).length() <= 2048) values.add((String) value); else allValuesValid = false; } for (Object property : propertiesToRemove) { - Object removed = invoke(properties, "remove", new Class[]{Object.class, Object.class}, - ForwardedSessionProof.PROPERTY_NAME, property); + Object removed = invoke(properties, MULTIMAP_REMOVE, ForwardedSessionProof.PROPERTY_NAME, property); if (!Boolean.TRUE.equals(removed)) return ProofProperty.failed(true); } - Object remaining = invoke(properties, "get", new Class[]{Object.class}, - ForwardedSessionProof.PROPERTY_NAME); + Object remaining = invoke(properties, MULTIMAP_GET, ForwardedSessionProof.PROPERTY_NAME); if (!(remaining instanceof Iterable) || ((Iterable) remaining).iterator().hasNext()) return ProofProperty.failed(!propertiesToRemove.isEmpty()); return ProofProperty.removed(propertiesToRemove.size() == 1 && allValuesValid && values.size() == 1 ? values.iterator().next() : null); } - private static Object invokeNoArg(Object receiver, String methodName) { + /** + * A public method this class calls reflectively: the GameProfile types differ between Bukkit builds, so the + * backend does not compile against them. Instances are lookup keys compared by identity. + */ + private static final class MethodRef { + final String name; + final Class[] parameterTypes; + + MethodRef(String name, Class... parameterTypes) { + this.name = name; + this.parameterTypes = parameterTypes; + } + } + + private static final MethodRef GET_PROFILE = new MethodRef("getProfile"); + private static final MethodRef GET_GAME_PROFILE = new MethodRef("getGameProfile"); + private static final MethodRef GET_PROPERTIES = new MethodRef("getProperties"); + private static final MethodRef GET_VALUE = new MethodRef("getValue"); + private static final MethodRef MULTIMAP_GET = new MethodRef("get", Object.class); + private static final MethodRef MULTIMAP_REMOVE = new MethodRef("remove", Object.class, Object.class); + + /** + * Lookups resolved once per receiver class, including misses, so a join does not search methods again. ClassValue + * keeps the cache from holding a reloaded plugin's classes. + */ + private static final ClassValue>> METHODS = + new ClassValue>>() { + @Override protected Map> computeValue(Class type) { + return new ConcurrentHashMap>(); + } + }; + + private static Object invoke(Object receiver, MethodRef reference, Object... args) { if (receiver == null) return null; + Map> methods = METHODS.get(receiver.getClass()); + Optional method = methods.get(reference); + if (method == null) { + method = lookup(receiver.getClass(), reference); + methods.put(reference, method); + } + if (!method.isPresent()) return null; try { - Method method = receiver.getClass().getMethod(methodName); - return method.invoke(receiver); + return method.get().invoke(receiver, args); } catch (ReflectiveOperationException | RuntimeException unavailable) { return null; } } - private static Object invoke(Object receiver, String methodName, Class[] parameterTypes, Object... args) { - if (receiver == null) return null; + private static Optional lookup(Class type, MethodRef reference) { try { - Method method = receiver.getClass().getMethod(methodName, parameterTypes); - return method.invoke(receiver, args); - } catch (ReflectiveOperationException | RuntimeException unavailable) { - return null; + return Optional.of(type.getMethod(reference.name, reference.parameterTypes)); + } catch (NoSuchMethodException | RuntimeException unavailable) { + return Optional.empty(); } } diff --git a/messaging/src/main/java/dev/moonbridge/messaging/Message.java b/messaging/src/main/java/dev/moonbridge/messaging/Message.java index f8f7cfc..95f6d0d 100644 --- a/messaging/src/main/java/dev/moonbridge/messaging/Message.java +++ b/messaging/src/main/java/dev/moonbridge/messaging/Message.java @@ -1,8 +1,8 @@ package dev.moonbridge.messaging; -import java.nio.charset.StandardCharsets; import java.util.Objects; import java.util.UUID; +import java.util.regex.Pattern; /** * Immutable message envelope shared by the proxy and Java 8 backend clients. The UUID identifies a message @@ -70,11 +70,13 @@ public final class Message { /** Returns a defensive copy of the payload. */ public byte[] payload() { return payload.clone(); } + /** Compiled once: validateChannel runs for every message, and String.matches would recompile it each time. */ + private static final Pattern CHANNEL = Pattern.compile("[a-z0-9][a-z0-9_.-]*:[a-z0-9][a-z0-9_.-]*"); + public static String validateChannel(String channel) { Objects.requireNonNull(channel, "channel"); - byte[] bytes = channel.getBytes(StandardCharsets.US_ASCII); - if (bytes.length == 0 || bytes.length > MAX_CHANNEL_BYTES || !channel.matches( - "[a-z0-9][a-z0-9_.-]*:[a-z0-9][a-z0-9_.-]*")) { + // The pattern admits ASCII only, so the character count is the byte count. + if (channel.isEmpty() || channel.length() > MAX_CHANNEL_BYTES || !CHANNEL.matcher(channel).matches()) { throw new IllegalArgumentException("channel must be namespace:name using lowercase ASCII letters, digits, '.', '_' or '-'"); } return channel; diff --git a/proxy/core/src/test/java/dev/moonbridge/core/plugin/PluginHostTest.java b/proxy/core/src/test/java/dev/moonbridge/core/plugin/PluginHostTest.java index a77169f..a25d07b 100644 --- a/proxy/core/src/test/java/dev/moonbridge/core/plugin/PluginHostTest.java +++ b/proxy/core/src/test/java/dev/moonbridge/core/plugin/PluginHostTest.java @@ -1047,13 +1047,16 @@ class PluginHostTest { void placementFailuresOnlyFailTheirOwnRequest() { RecoveringPlacementPlugin plugin = new RecoveringPlacementPlugin(); BackendCatalog catalog = new InMemoryBackendCatalog(); - PluginHost host = new PluginHost(catalog, players(), Duration.ofMillis(40)); + // The timeout has to outlast dispatch to the plugin worker even on a loaded CI machine; with 40 ms it could + // fire before the handler ran, leaving nothing to cancel and shifting every later call by one. + PluginHost host = new PluginHost(catalog, players(), Duration.ofMillis(500)); host.load(List.of(plugin)); host.enable(); CompletionException failure = assertThrows(CompletionException.class, () -> host.placeInitial(PLAYER).toCompletableFuture().join()); assertInstanceOf(PlacementTimeoutException.class, failure.getCause()); + assertEquals(1, plugin.calls.get(), "the timed-out request must have reached the plugin"); assertTrue(plugin.firstDecision.isCancelled(), "timed-out placement should cancel unfinished plugin work"); assertTrue(catalog.find(new dev.moonbridge.core.backend.BackendId("placement")).isPresent()); CompletionException transientFailure = assertThrows(CompletionException.class, diff --git a/testing/benchmarks/results/2026-10-22-relay-batch-completion-raw.txt b/testing/benchmarks/results/2026-10-22-relay-batch-completion-raw.txt new file mode 100644 index 0000000..cba5bff --- /dev/null +++ b/testing/benchmarks/results/2026-10-22-relay-batch-completion-raw.txt @@ -0,0 +1,72 @@ +burst32 base pair1 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11722.0 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.45 latency_ms_p50=1.368 p95=1.480 p99=1.558 elapsed_ms=2047.439 frames/s=375102.7 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 base pair1 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11387.1 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.12 latency_ms_p50=1.390 p95=1.691 p99=1.932 elapsed_ms=2107.656 frames/s=364385.9 proxy_io_cpu_ns/frame=2511 tcp_out_segments/frame=n/a +burst32 base pair1 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11324.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.06 latency_ms_p50=1.397 p95=1.663 p99=1.862 elapsed_ms=2119.349 frames/s=362375.5 proxy_io_cpu_ns/frame=2678 tcp_out_segments/frame=n/a +burst32 base pair1 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12346.4 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.06 latency_ms_p50=1.298 p95=1.395 p99=1.460 elapsed_ms=1943.884 frames/s=395085.4 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 candidate pair1 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12126.8 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.84 latency_ms_p50=1.325 p95=1.434 p99=1.497 elapsed_ms=1979.088 frames/s=388057.5 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 candidate pair1 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11649.4 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.38 latency_ms_p50=1.348 p95=1.713 p99=2.122 elapsed_ms=2060.189 frames/s=372781.2 proxy_io_cpu_ns/frame=2336 tcp_out_segments/frame=n/a +burst32 candidate pair1 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11246.6 warmup_roundtrips=2400 window=4 one_way_MiB/s=10.98 latency_ms_p50=1.402 p95=1.684 p99=1.899 elapsed_ms=2133.985 frames/s=359890.1 proxy_io_cpu_ns/frame=2538 tcp_out_segments/frame=n/a +burst32 candidate pair1 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11605.0 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.33 latency_ms_p50=1.381 p95=1.491 p99=1.550 elapsed_ms=2068.068 frames/s=371361.1 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 candidate pair2 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12552.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.26 latency_ms_p50=1.286 p95=1.390 p99=1.490 elapsed_ms=1912.021 frames/s=401669.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 candidate pair2 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11600.5 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.33 latency_ms_p50=1.361 p95=1.628 p99=1.894 elapsed_ms=2068.868 frames/s=371217.6 proxy_io_cpu_ns/frame=2484 tcp_out_segments/frame=n/a +burst32 candidate pair2 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11609.3 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.34 latency_ms_p50=1.361 p95=1.612 p99=1.787 elapsed_ms=2067.303 frames/s=371498.5 proxy_io_cpu_ns/frame=2498 tcp_out_segments/frame=n/a +burst32 candidate pair2 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12403.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.11 latency_ms_p50=1.294 p95=1.386 p99=1.436 elapsed_ms=1934.986 frames/s=396902.0 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 base pair2 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12067.8 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.78 latency_ms_p50=1.333 p95=1.451 p99=1.517 elapsed_ms=1988.762 frames/s=386169.9 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 base pair2 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12062.8 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.78 latency_ms_p50=1.324 p95=1.614 p99=1.981 elapsed_ms=1989.592 frames/s=386008.8 proxy_io_cpu_ns/frame=2425 tcp_out_segments/frame=n/a +burst32 base pair2 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12035.9 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.75 latency_ms_p50=1.318 p95=1.595 p99=1.864 elapsed_ms=1994.034 frames/s=385148.9 proxy_io_cpu_ns/frame=2440 tcp_out_segments/frame=n/a +burst32 base pair2 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11991.4 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.71 latency_ms_p50=1.337 p95=1.449 p99=1.508 elapsed_ms=2001.427 frames/s=383726.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 base pair3 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12858.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.56 latency_ms_p50=1.248 p95=1.348 p99=1.414 elapsed_ms=1866.509 frames/s=411463.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 base pair3 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12040.1 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.76 latency_ms_p50=1.307 p95=1.611 p99=1.890 elapsed_ms=1993.347 frames/s=385281.7 proxy_io_cpu_ns/frame=2404 tcp_out_segments/frame=n/a +burst32 base pair3 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12033.7 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.75 latency_ms_p50=1.314 p95=1.573 p99=1.765 elapsed_ms=1994.404 frames/s=385077.4 proxy_io_cpu_ns/frame=2502 tcp_out_segments/frame=n/a +burst32 base pair3 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12406.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.12 latency_ms_p50=1.294 p95=1.411 p99=1.565 elapsed_ms=1934.514 frames/s=396998.9 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 candidate pair3 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12303.4 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.02 latency_ms_p50=1.305 p95=1.403 p99=1.460 elapsed_ms=1950.681 frames/s=393708.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +burst32 candidate pair3 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12131.9 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.85 latency_ms_p50=1.301 p95=1.612 p99=2.163 elapsed_ms=1978.260 frames/s=388219.9 proxy_io_cpu_ns/frame=2334 tcp_out_segments/frame=n/a +burst32 candidate pair3 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12193.9 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.91 latency_ms_p50=1.298 p95=1.561 p99=1.754 elapsed_ms=1968.195 frames/s=390205.2 proxy_io_cpu_ns/frame=2340 tcp_out_segments/frame=n/a +burst32 candidate pair3 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=13064.1 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.76 latency_ms_p50=1.232 p95=1.328 p99=1.382 elapsed_ms=1837.102 frames/s=418049.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 base pair1 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=102851.6 warmup_roundtrips=2000 window=1 one_way_MiB/s=100.44 latency_ms_p50=0.032 p95=0.069 p99=0.085 elapsed_ms=194.455 frames/s=102851.6 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 base pair1 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=44921.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=43.87 latency_ms_p50=0.068 p95=0.130 p99=0.186 elapsed_ms=445.218 frames/s=44921.8 proxy_io_cpu_ns/frame=31564 tcp_out_segments/frame=n/a +w1 base pair1 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=57791.0 warmup_roundtrips=2000 window=1 one_way_MiB/s=56.44 latency_ms_p50=0.062 p95=0.114 p99=0.136 elapsed_ms=346.075 frames/s=57791.0 proxy_io_cpu_ns/frame=27075 tcp_out_segments/frame=n/a +w1 base pair1 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=109442.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=106.88 latency_ms_p50=0.030 p95=0.065 p99=0.081 elapsed_ms=182.744 frames/s=109442.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 candidate pair1 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=99185.9 warmup_roundtrips=2000 window=1 one_way_MiB/s=96.86 latency_ms_p50=0.034 p95=0.071 p99=0.087 elapsed_ms=201.642 frames/s=99185.9 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 candidate pair1 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=50681.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=49.49 latency_ms_p50=0.072 p95=0.129 p99=0.160 elapsed_ms=394.619 frames/s=50681.8 proxy_io_cpu_ns/frame=32760 tcp_out_segments/frame=n/a +w1 candidate pair1 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=54899.2 warmup_roundtrips=2000 window=1 one_way_MiB/s=53.61 latency_ms_p50=0.066 p95=0.119 p99=0.144 elapsed_ms=364.304 frames/s=54899.2 proxy_io_cpu_ns/frame=28599 tcp_out_segments/frame=n/a +w1 candidate pair1 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=107379.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=104.86 latency_ms_p50=0.031 p95=0.066 p99=0.083 elapsed_ms=186.255 frames/s=107379.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 candidate pair2 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=101572.0 warmup_roundtrips=2000 window=1 one_way_MiB/s=99.19 latency_ms_p50=0.033 p95=0.070 p99=0.085 elapsed_ms=196.905 frames/s=101572.0 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 candidate pair2 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=44966.0 warmup_roundtrips=2000 window=1 one_way_MiB/s=43.91 latency_ms_p50=0.078 p95=0.161 p99=0.245 elapsed_ms=444.780 frames/s=44966.0 proxy_io_cpu_ns/frame=33267 tcp_out_segments/frame=n/a +w1 candidate pair2 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=53790.7 warmup_roundtrips=2000 window=1 one_way_MiB/s=52.53 latency_ms_p50=0.068 p95=0.120 p99=0.142 elapsed_ms=371.811 frames/s=53790.7 proxy_io_cpu_ns/frame=29831 tcp_out_segments/frame=n/a +w1 candidate pair2 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=106647.4 warmup_roundtrips=2000 window=1 one_way_MiB/s=104.15 latency_ms_p50=0.031 p95=0.067 p99=0.083 elapsed_ms=187.534 frames/s=106647.4 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 base pair2 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=99284.1 warmup_roundtrips=2000 window=1 one_way_MiB/s=96.96 latency_ms_p50=0.034 p95=0.069 p99=0.084 elapsed_ms=201.442 frames/s=99284.1 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 base pair2 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=48153.5 warmup_roundtrips=2000 window=1 one_way_MiB/s=47.02 latency_ms_p50=0.078 p95=0.133 p99=0.165 elapsed_ms=415.338 frames/s=48153.5 proxy_io_cpu_ns/frame=34794 tcp_out_segments/frame=n/a +w1 base pair2 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=55567.3 warmup_roundtrips=2000 window=1 one_way_MiB/s=54.26 latency_ms_p50=0.066 p95=0.117 p99=0.139 elapsed_ms=359.924 frames/s=55567.3 proxy_io_cpu_ns/frame=27717 tcp_out_segments/frame=n/a +w1 base pair2 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=84781.6 warmup_roundtrips=2000 window=1 one_way_MiB/s=82.79 latency_ms_p50=0.035 p95=0.106 p99=0.163 elapsed_ms=235.900 frames/s=84781.6 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 base pair3 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=103762.5 warmup_roundtrips=2000 window=1 one_way_MiB/s=101.33 latency_ms_p50=0.032 p95=0.068 p99=0.085 elapsed_ms=192.748 frames/s=103762.5 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 base pair3 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=47659.1 warmup_roundtrips=2000 window=1 one_way_MiB/s=46.54 latency_ms_p50=0.080 p95=0.134 p99=0.167 elapsed_ms=419.647 frames/s=47659.1 proxy_io_cpu_ns/frame=35591 tcp_out_segments/frame=n/a +w1 base pair3 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=58511.2 warmup_roundtrips=2000 window=1 one_way_MiB/s=57.14 latency_ms_p50=0.062 p95=0.111 p99=0.135 elapsed_ms=341.815 frames/s=58511.2 proxy_io_cpu_ns/frame=26286 tcp_out_segments/frame=n/a +w1 base pair3 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=109563.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=107.00 latency_ms_p50=0.031 p95=0.065 p99=0.081 elapsed_ms=182.542 frames/s=109563.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 candidate pair3 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=102711.3 warmup_roundtrips=2000 window=1 one_way_MiB/s=100.30 latency_ms_p50=0.032 p95=0.069 p99=0.085 elapsed_ms=194.720 frames/s=102711.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w1 candidate pair3 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=51613.9 warmup_roundtrips=2000 window=1 one_way_MiB/s=50.40 latency_ms_p50=0.071 p95=0.126 p99=0.153 elapsed_ms=387.493 frames/s=51613.9 proxy_io_cpu_ns/frame=31881 tcp_out_segments/frame=n/a +w1 candidate pair3 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=56566.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=55.24 latency_ms_p50=0.064 p95=0.116 p99=0.136 elapsed_ms=353.564 frames/s=56566.8 proxy_io_cpu_ns/frame=27733 tcp_out_segments/frame=n/a +w1 candidate pair3 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=108651.2 warmup_roundtrips=2000 window=1 one_way_MiB/s=106.10 latency_ms_p50=0.031 p95=0.066 p99=0.083 elapsed_ms=184.075 frames/s=108651.2 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 base pair1 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=140521.8 warmup_roundtrips=2000 window=16 one_way_MiB/s=137.23 latency_ms_p50=0.441 p95=0.608 p99=0.790 elapsed_ms=142.327 frames/s=140521.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 base pair1 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=119463.8 warmup_roundtrips=2000 window=16 one_way_MiB/s=116.66 latency_ms_p50=0.416 p95=1.034 p99=2.207 elapsed_ms=167.415 frames/s=119463.8 proxy_io_cpu_ns/frame=24052 tcp_out_segments/frame=n/a +w16 base pair1 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=116408.8 warmup_roundtrips=2000 window=16 one_way_MiB/s=113.68 latency_ms_p50=0.411 p95=1.253 p99=3.477 elapsed_ms=171.808 frames/s=116408.8 proxy_io_cpu_ns/frame=23561 tcp_out_segments/frame=n/a +w16 base pair1 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=142838.1 warmup_roundtrips=2000 window=16 one_way_MiB/s=139.49 latency_ms_p50=0.437 p95=0.590 p99=0.660 elapsed_ms=140.019 frames/s=142838.1 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 candidate pair1 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=142293.6 warmup_roundtrips=2000 window=16 one_way_MiB/s=138.96 latency_ms_p50=0.441 p95=0.612 p99=0.750 elapsed_ms=140.555 frames/s=142293.6 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 candidate pair1 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=110984.5 warmup_roundtrips=2000 window=16 one_way_MiB/s=108.38 latency_ms_p50=0.495 p95=0.996 p99=1.765 elapsed_ms=180.205 frames/s=110984.5 proxy_io_cpu_ns/frame=21673 tcp_out_segments/frame=n/a +w16 candidate pair1 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=114924.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=112.23 latency_ms_p50=0.397 p95=0.950 p99=4.230 elapsed_ms=174.027 frames/s=114924.7 proxy_io_cpu_ns/frame=22167 tcp_out_segments/frame=n/a +w16 candidate pair1 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=147423.1 warmup_roundtrips=2000 window=16 one_way_MiB/s=143.97 latency_ms_p50=0.387 p95=0.729 p99=1.202 elapsed_ms=135.664 frames/s=147423.1 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 candidate pair2 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=142233.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=138.90 latency_ms_p50=0.441 p95=0.599 p99=0.763 elapsed_ms=140.614 frames/s=142233.7 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 candidate pair2 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=110771.2 warmup_roundtrips=2000 window=16 one_way_MiB/s=108.18 latency_ms_p50=0.524 p95=0.949 p99=1.444 elapsed_ms=180.552 frames/s=110771.2 proxy_io_cpu_ns/frame=21900 tcp_out_segments/frame=n/a +w16 candidate pair2 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=111617.1 warmup_roundtrips=2000 window=16 one_way_MiB/s=109.00 latency_ms_p50=0.412 p95=1.100 p99=3.880 elapsed_ms=179.184 frames/s=111617.1 proxy_io_cpu_ns/frame=22810 tcp_out_segments/frame=n/a +w16 candidate pair2 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=148224.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=144.75 latency_ms_p50=0.403 p95=0.661 p99=1.249 elapsed_ms=134.930 frames/s=148224.7 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 base pair2 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=145075.9 warmup_roundtrips=2000 window=16 one_way_MiB/s=141.68 latency_ms_p50=0.433 p95=0.596 p99=0.717 elapsed_ms=137.859 frames/s=145075.9 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 base pair2 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=110407.5 warmup_roundtrips=2000 window=16 one_way_MiB/s=107.82 latency_ms_p50=0.519 p95=0.921 p99=1.564 elapsed_ms=181.147 frames/s=110407.5 proxy_io_cpu_ns/frame=22888 tcp_out_segments/frame=n/a +w16 base pair2 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=114153.1 warmup_roundtrips=2000 window=16 one_way_MiB/s=111.48 latency_ms_p50=0.404 p95=1.265 p99=3.695 elapsed_ms=175.203 frames/s=114153.1 proxy_io_cpu_ns/frame=22665 tcp_out_segments/frame=n/a +w16 base pair2 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=152408.3 warmup_roundtrips=2000 window=16 one_way_MiB/s=148.84 latency_ms_p50=0.368 p95=0.688 p99=1.206 elapsed_ms=131.226 frames/s=152408.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 base pair3 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=139121.5 warmup_roundtrips=2000 window=16 one_way_MiB/s=135.86 latency_ms_p50=0.447 p95=0.615 p99=0.808 elapsed_ms=143.759 frames/s=139121.5 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 base pair3 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=118869.5 warmup_roundtrips=2000 window=16 one_way_MiB/s=116.08 latency_ms_p50=0.432 p95=1.135 p99=2.117 elapsed_ms=168.252 frames/s=118869.5 proxy_io_cpu_ns/frame=23829 tcp_out_segments/frame=n/a +w16 base pair3 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=113474.4 warmup_roundtrips=2000 window=16 one_way_MiB/s=110.81 latency_ms_p50=0.422 p95=1.165 p99=3.149 elapsed_ms=176.251 frames/s=113474.4 proxy_io_cpu_ns/frame=24491 tcp_out_segments/frame=n/a +w16 base pair3 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=140474.2 warmup_roundtrips=2000 window=16 one_way_MiB/s=137.18 latency_ms_p50=0.442 p95=0.614 p99=0.756 elapsed_ms=142.375 frames/s=140474.2 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 candidate pair3 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=144711.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=141.32 latency_ms_p50=0.427 p95=0.615 p99=0.729 elapsed_ms=138.206 frames/s=144711.7 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a +w16 candidate pair3 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=107387.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=104.87 latency_ms_p50=0.482 p95=1.050 p99=2.447 elapsed_ms=186.241 frames/s=107387.7 proxy_io_cpu_ns/frame=22794 tcp_out_segments/frame=n/a +w16 candidate pair3 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=114588.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=111.90 latency_ms_p50=0.490 p95=0.803 p99=2.276 elapsed_ms=174.537 frames/s=114588.7 proxy_io_cpu_ns/frame=18155 tcp_out_segments/frame=n/a +w16 candidate pair3 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=143326.2 warmup_roundtrips=2000 window=16 one_way_MiB/s=139.97 latency_ms_p50=0.421 p95=0.649 p99=0.913 elapsed_ms=139.542 frames/s=143326.2 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a diff --git a/testing/benchmarks/results/2026-10-22-relay-batch-completion.md b/testing/benchmarks/results/2026-10-22-relay-batch-completion.md new file mode 100644 index 0000000..01d5955 --- /dev/null +++ b/testing/benchmarks/results/2026-10-22-relay-batch-completion.md @@ -0,0 +1,57 @@ +# Relay write completion per batch instead of per frame — 2026-10-22 + +Base revision: `4ed6162` (main). Candidate: [`2026-10-22-relay-batch-completion.patch`](2026-10-22-relay-batch-completion.patch). The ordinary `RawRelay` path still writes each frame with `peer.write`, but only the last write of each read batch gets a completion listener. The listener is attached when the batch is flushed and subtracts the whole batch from `writesInFlight`. Before, every frame got a listener, and every completion queued one more event-loop task. One channel completes its writes in order and fails every later write once one fails, so the last write's outcome covers the batch. The paused-replay path was unchanged. + +**Outcome: not adopted.** Allocation on the proxy I/O threads fell by about a fifth, but per-frame CPU did not move beyond run-to-run range. + +## Decision rule (set before the comparison) + +Adopt only if, on burst32, the candidate's median proxy I/O CPU per frame is at least 10% below the base and the paired proxy/direct throughput is not lower. Window 1 and window 16 must show no consistent regression (medians within 10% or overlapping ranges). + +## Profile that motivated it + +A JFR `profile` recording of the base on burst32 attributed the sampled allocation on `moonbridge-session-io` threads as follows: `ChannelOutboundBuffer$Entry` 26.8%, `DefaultChannelPromise` 26.6%, `UnpooledSlicedByteBuf` (frame decoder slices) 20.8%, and two `RawRelay$Side` lambdas 24.2%. The lambdas were the per-frame completion listener and the task it queued. They were the only share the relay itself could remove without changing write semantics. + +## Environment and method + +Apple M4 (10 cores), macOS, Zulu OpenJDK 25.0.4.1, Netty 4.2.18.Final, kqueue transport, loopback only. Both variants were built with `:proxy-core:installDist`, and the same `ProxySessionBenchmark` source was compiled against each `moonbridge.jar`. For each workload, three base/candidate JVM pairs ran, alternating which variant started (base first in pairs 1 and 3). Each JVM ran `--repeats 2`, so there are six paired direct/proxy phases per cell. Every echo was checked; all 72 phases report `errors=0`. + +```sh +java -Xms512m -Xmx512m --enable-native-access=ALL-UNNAMED -cp "/classes:/moonbridge.jar" \ + dev.moonbridge.core.session.ProxySessionBenchmark --transport kqueue \ + --connections 4 --messages 6000 --warmup 600 --payload 32 --window 4 --burst 32 --repeats 2 # burst32 + --connections 4 --messages 5000 --warmup 500 --payload 1024 --window 1 --burst 1 --repeats 2 # w1 + --connections 4 --messages 5000 --warmup 500 --payload 1024 --window 16 --burst 1 --repeats 2 # w16 +``` + +## Results + +Values are the median and [min–max] of the six proxy phases. "Proxy/direct" divides each proxy phase's frames/s by its paired direct phase. + +| Workload | Variant | Proxy/direct | p50 ms | p95 ms | Proxy I/O CPU ns/frame | +| --- | --- | ---: | ---: | ---: | ---: | +| burst32 | base | 0.971 [0.917–1.004] | 1.321 [1.307–1.397] | 1.613 [1.573–1.691] | 2471 [2404–2678] | +| burst32 | candidate | 0.948 [0.924–0.986] | 1.355 [1.298–1.402] | 1.620 [1.561–1.713] | 2412 [2334–2538] | +| w1 | base | 0.507 [0.437–0.655] | 0.067 [0.062–0.080] | 0.123 [0.111–0.134] | 29640 [26286–35591] | +| w1 | candidate | 0.508 [0.443–0.521] | 0.070 [0.064–0.078] | 0.123 [0.116–0.161] | 30856 [27733–33267] | +| w16 | base | 0.811 [0.749–0.854] | 0.419 [0.404–0.519] | 1.150 [0.921–1.265] | 23695 [22665–24491] | +| w16 | candidate | 0.779 [0.742–0.799] | 0.486 [0.397–0.524] | 0.973 [0.803–1.100] | 22034 [18155–22810] | + +Allocation, from one JFR `profile` recording per variant on burst32 (sampled weight on `moonbridge-session-io` threads): + +| Variant | Total | Relay lambdas | Promise | Outbound entry | Slices | +| --- | ---: | ---: | ---: | ---: | ---: | +| base | 677 MB | 167 MB | 180 MB | 181 MB | 141 MB | +| candidate | 530 MB | 6 MB | 174 MB | 212 MB | 126 MB | + +Raw output: [2026-10-22-relay-batch-completion-raw.txt](2026-10-22-relay-batch-completion-raw.txt) (lines are `workload variant pair phase: metrics`). + +## Interpretation + +- burst32: the median CPU per frame is 2.4% lower and the ranges overlap; paired throughput is slightly lower, also overlapping. The 10% CPU criterion is not met. +- The candidate removed 97% of the relay's own allocation and 22% of the I/O threads' total. JFR allocation samples are weighted estimates; the promise, entry and slice rows did not change in the code and differ only by sampling. On this heap, young-generation collection of short-lived objects is cheap enough that this does not show up as CPU per frame. +- w1 and w16: CPU and throughput ranges overlap. w16 p95 is lower, but p50 is higher, so there is no consistent direction either way. + +## Limits + +On this host the direct path runs at nearly the same rate as the proxy path (proxy/direct close to 1 on burst32). The single-JVM client and fake backend are the throughput limit, so throughput cannot show a proxy-side gain here; CPU per frame is the meaningful metric. This was one loopback run on one desktop, with no encryption (`ONLINE_BUNGEE`), WebSocket, real Forge traffic, cross-host RTT or production player counts. GC cost under a long-running, larger heap was not measured. A future run on Linux with epoll, or a GC-sensitive measurement (allocation rate under sustained load with many sessions), could revisit it with the patch above. Single-host loopback numbers are never capacity claims. diff --git a/testing/benchmarks/results/2026-10-22-relay-batch-completion.patch b/testing/benchmarks/results/2026-10-22-relay-batch-completion.patch new file mode 100644 index 0000000..ac39cf7 --- /dev/null +++ b/testing/benchmarks/results/2026-10-22-relay-batch-completion.patch @@ -0,0 +1,72 @@ +diff --git a/proxy/core/src/main/java/dev/moonbridge/core/session/play/RawRelay.java b/proxy/core/src/main/java/dev/moonbridge/core/session/play/RawRelay.java +index e1832e1..a6288f2 100644 +--- a/proxy/core/src/main/java/dev/moonbridge/core/session/play/RawRelay.java ++++ b/proxy/core/src/main/java/dev/moonbridge/core/session/play/RawRelay.java +@@ -3,6 +3,7 @@ package dev.moonbridge.core.session.play; + import dev.moonbridge.core.protocol.ProtocolProfile; + import io.netty.buffer.ByteBuf; + import io.netty.channel.Channel; ++import io.netty.channel.ChannelFuture; + import io.netty.channel.ChannelFutureListener; + import io.netty.channel.ChannelHandlerContext; + import io.netty.channel.ChannelInboundHandlerAdapter; +@@ -173,6 +174,9 @@ public final class RawRelay { + private volatile boolean closingAfterWrites; + private boolean readsEnabled; + private boolean flushPending; ++ /** The last frame written since the previous flush, and how many frames that batch holds. */ ++ private ChannelFuture batchTail; ++ private int batchWrites; + private ScheduledFuture closeDeadline; + private CompletableFuture pauseComplete; + private CompletableFuture resumeComplete; +@@ -340,17 +344,10 @@ public final class RawRelay { + try { + Consumer activeObserver = observer; + if (activeObserver != null) activeObserver.accept(((ByteBuf) message).duplicate()); +- peer.write(message).addListener((ChannelFutureListener) future -> +- ctx.executor().execute(() -> { +- writesInFlight--; +- if (paused && writesInFlight == 0 && pauseComplete != null) { +- if (future.isSuccess()) pauseComplete.complete(null); +- else pauseComplete.completeExceptionally(future.cause()); +- pauseComplete = null; +- } +- if (!future.isSuccess() || (closingAfterWrites && writesInFlight == 0)) closePair(ctx); +- else if (!closingAfterWrites) requestRead(); +- })); ++ // Only the batch's last write gets a listener, attached when the batch is flushed: one channel ++ // completes writes in order and fails every later write once one fails, so its outcome covers all. ++ batchTail = peer.write(message); ++ batchWrites++; + scheduleFlush(ctx); + } catch (RuntimeException failure) { + writesInFlight--; +@@ -379,9 +376,27 @@ public final class RawRelay { + private void flushPeer() { + if (!flushPending) return; + flushPending = false; ++ ChannelFuture tail = batchTail; ++ int writes = batchWrites; ++ batchTail = null; ++ batchWrites = 0; ++ if (tail != null) tail.addListener((ChannelFutureListener) future -> batchCompleted(future, writes)); + peer.flush(); + } + ++ private void batchCompleted(ChannelFuture future, int writes) { ++ context.executor().execute(() -> { ++ writesInFlight -= writes; ++ if (paused && writesInFlight == 0 && pauseComplete != null) { ++ if (future.isSuccess()) pauseComplete.complete(null); ++ else pauseComplete.completeExceptionally(future.cause()); ++ pauseComplete = null; ++ } ++ if (!future.isSuccess() || (closingAfterWrites && writesInFlight == 0)) closePair(context); ++ else if (!closingAfterWrites) requestRead(); ++ }); ++ } ++ + @Override + public void channelWritabilityChanged(ChannelHandlerContext ctx) { + if (ctx.channel().isWritable()) opposite.requestRead();