From 6295db718aee38211121024e28ce71029ab58767 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 12 Jan 2026 09:42:51 -0300 Subject: [PATCH 01/11] WIP: GRPC: nothing work --- dump.txt | 423 ++++++++++++++++++ jooby/src/main/java/io/jooby/Context.java | 16 + .../main/java/io/jooby/DefaultContext.java | 5 + .../main/java/io/jooby/ForwardingContext.java | 11 + jooby/src/main/java/io/jooby/Sender.java | 9 + .../java/io/jooby/internal/HeadContext.java | 5 + list.txt | 11 + modules/jooby-bom/pom.xml | 5 + modules/jooby-grpc/pom.xml | 57 +++ .../main/java/io/jooby/grpc/GrpcDeframer.java | 53 +++ .../main/java/io/jooby/grpc/GrpcHandler.java | 229 ++++++++++ .../io/jooby/grpc/GrpcMethodRegistry.java | 28 ++ .../main/java/io/jooby/grpc/GrpcModule.java | 54 +++ .../java/io/jooby/grpc/GrpcRequestBridge.java | 79 ++++ .../java/io/jooby/grpc/UnifiedGrpcBridge.java | 253 +++++++++++ .../io/jooby/internal/jetty/JettyContext.java | 23 +- .../internal/jetty/JettyGrpcHandler.java | 39 ++ .../io/jooby/internal/jetty/JettyHandler.java | 12 +- .../internal/jetty/JettyRequestPublisher.java | 137 ++++++ .../io/jooby/internal/jetty/JettySender.java | 58 ++- .../jetty/http2/JettyHttp2Configurer.java | 20 +- .../src/main/java/module-info.java | 1 + .../internal/netty/NettyByteBufBody.java | 97 ++++ .../io/jooby/internal/netty/NettyContext.java | 26 +- .../io/jooby/internal/netty/NettyHandler.java | 16 +- .../io/jooby/internal/netty/NettySender.java | 21 +- .../netty/http2/NettyHttp2Configurer.java | 2 +- .../src/main/java/module-info.java | 1 - .../main/java/io/jooby/test/MockContext.java | 17 +- .../internal/undertow/UndertowContext.java | 16 +- .../undertow/UndertowGrpcHandler.java | 56 +++ .../internal/undertow/UndertowHandler.java | 21 +- .../undertow/UndertowOutputCallback.java | 44 -- .../undertow/UndertowRequestPublisher.java | 88 ++++ .../internal/undertow/UndertowSender.java | 44 +- .../io/jooby/undertow/UndertowServer.java | 2 +- modules/pom.xml | 2 + pom.xml | 1 + tests/pom.xml | 55 +++ tests/src/main/proto/chat.proto | 17 + tests/src/main/proto/hello.proto | 20 + .../test/java/examples/grpc/ChatClient.java | 81 ++++ .../java/examples/grpc/ChatServiceImpl.java | 46 ++ .../java/examples/grpc/GreeterService.java | 20 + .../test/java/examples/grpc/GrpcClient.java | 26 ++ .../test/java/examples/grpc/GrpcServer.java | 49 ++ .../java/examples/grpc/ReflectionClient.java | 68 +++ .../src/test/java/io/jooby/test/GrpcTest.java | 55 +++ tests/src/test/resources/logback.xml | 2 +- 49 files changed, 2329 insertions(+), 92 deletions(-) create mode 100644 dump.txt create mode 100644 list.txt create mode 100644 modules/jooby-grpc/pom.xml create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcHandler.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java create mode 100644 modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java create mode 100644 modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java create mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java create mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java delete mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java create mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java create mode 100644 tests/src/main/proto/chat.proto create mode 100644 tests/src/main/proto/hello.proto create mode 100644 tests/src/test/java/examples/grpc/ChatClient.java create mode 100644 tests/src/test/java/examples/grpc/ChatServiceImpl.java create mode 100644 tests/src/test/java/examples/grpc/GreeterService.java create mode 100644 tests/src/test/java/examples/grpc/GrpcClient.java create mode 100644 tests/src/test/java/examples/grpc/GrpcServer.java create mode 100644 tests/src/test/java/examples/grpc/ReflectionClient.java create mode 100644 tests/src/test/java/io/jooby/test/GrpcTest.java diff --git a/dump.txt b/dump.txt new file mode 100644 index 0000000000..1fb5ca0768 --- /dev/null +++ b/dump.txt @@ -0,0 +1,423 @@ +2026-01-02 19:02:09 +Full thread dump OpenJDK 64-Bit Server VM (24.0.2+12 mixed mode, sharing): + +Threads class SMR info: +_java_thread_list=0x0000600001982ce0, length=31, elements={ +0x000000014a00e200, 0x000000014a012000, 0x000000014a012800, 0x000000014a013000, +0x000000014a013800, 0x000000014a014000, 0x000000014a01c800, 0x000000014981d600, +0x000000014a122e00, 0x000000014a123600, 0x000000014a9e8800, 0x000000014aa54e00, +0x000000014aa55600, 0x000000014a9e9000, 0x00000001038d6000, 0x000000014c059c00, +0x000000014c057800, 0x000000014c058000, 0x000000012fb59200, 0x000000014a1f7000, +0x000000012fb5bc00, 0x000000013f810200, 0x000000013f819400, 0x000000012f1d5800, +0x000000012f1d6000, 0x000000013e998400, 0x000000014a1f7800, 0x000000014aa71e00, +0x000000012e03be00, 0x000000013e875000, 0x000000012e045800 +} + +"Reference Handler" #15 [29443] daemon prio=10 os_prio=31 cpu=0.26ms elapsed=24.14s tid=0x000000014a00e200 nid=29443 waiting on condition [0x000000016e5c2000] + java.lang.Thread.State: RUNNABLE + at java.lang.ref.Reference.waitForReferencePendingList(java.base@24.0.2/Native Method) + at java.lang.ref.Reference.processPendingReferences(java.base@24.0.2/Reference.java:246) + at java.lang.ref.Reference$ReferenceHandler.run(java.base@24.0.2/Reference.java:208) + + Locked ownable synchronizers: + - None + +"Finalizer" #16 [24835] daemon prio=8 os_prio=31 cpu=0.05ms elapsed=24.14s tid=0x000000014a012000 nid=24835 in Object.wait() [0x000000016e7ce000] + java.lang.Thread.State: WAITING (on object monitor) + at java.lang.Object.wait0(java.base@24.0.2/Native Method) + - waiting on <0x000000052b00cd30> (a java.lang.ref.ReferenceQueue$Lock) + at java.lang.Object.wait(java.base@24.0.2/Object.java:389) + at java.lang.Object.wait(java.base@24.0.2/Object.java:351) + at java.lang.ref.ReferenceQueue.remove0(java.base@24.0.2/ReferenceQueue.java:138) + at java.lang.ref.ReferenceQueue.remove(java.base@24.0.2/ReferenceQueue.java:229) + - locked <0x000000052b00cd30> (a java.lang.ref.ReferenceQueue$Lock) + at java.lang.ref.Finalizer$FinalizerThread.run(java.base@24.0.2/Finalizer.java:165) + + Locked ownable synchronizers: + - None + +"Signal Dispatcher" #17 [29187] daemon prio=9 os_prio=31 cpu=0.12ms elapsed=24.14s tid=0x000000014a012800 nid=29187 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"Service Thread" #18 [25603] daemon prio=9 os_prio=31 cpu=0.84ms elapsed=24.14s tid=0x000000014a013000 nid=25603 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"Monitor Deflation Thread" #19 [26115] daemon prio=9 os_prio=31 cpu=2.54ms elapsed=24.14s tid=0x000000014a013800 nid=26115 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"C2 CompilerThread0" #20 [28931] daemon prio=9 os_prio=31 cpu=208.92ms elapsed=24.14s tid=0x000000014a014000 nid=28931 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + No compile task + + Locked ownable synchronizers: + - None + +"C1 CompilerThread0" #28 [26627] daemon prio=9 os_prio=31 cpu=100.30ms elapsed=24.14s tid=0x000000014a01c800 nid=26627 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + No compile task + + Locked ownable synchronizers: + - None + +"Common-Cleaner" #32 [27395] daemon prio=8 os_prio=31 cpu=0.04ms elapsed=24.12s tid=0x000000014981d600 nid=27395 in Object.wait() [0x000000016f82e000] + java.lang.Thread.State: TIMED_WAITING (on object monitor) + at java.lang.Object.wait0(java.base@24.0.2/Native Method) + - waiting on <0x000000052b0199e8> (a java.lang.ref.ReferenceQueue$Lock) + at java.lang.Object.wait(java.base@24.0.2/Object.java:389) + at java.lang.ref.ReferenceQueue.remove0(java.base@24.0.2/ReferenceQueue.java:124) + at java.lang.ref.ReferenceQueue.remove(java.base@24.0.2/ReferenceQueue.java:215) + - locked <0x000000052b0199e8> (a java.lang.ref.ReferenceQueue$Lock) + at jdk.internal.ref.CleanerImpl.run(java.base@24.0.2/CleanerImpl.java:140) + at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) + at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) + at jdk.internal.misc.InnocuousThread.run(java.base@24.0.2/InnocuousThread.java:148) + + Locked ownable synchronizers: + - None + +"Monitor Ctrl-Break" #33 [43011] daemon prio=5 os_prio=31 cpu=8.19ms elapsed=24.08s tid=0x000000014a122e00 nid=43011 runnable [0x000000016fc46000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.SocketDispatcher.read0(java.base@24.0.2/Native Method) + at sun.nio.ch.SocketDispatcher.read(java.base@24.0.2/SocketDispatcher.java:47) + at sun.nio.ch.NioSocketImpl.tryRead(java.base@24.0.2/NioSocketImpl.java:255) + at sun.nio.ch.NioSocketImpl.implRead(java.base@24.0.2/NioSocketImpl.java:306) + at sun.nio.ch.NioSocketImpl.read(java.base@24.0.2/NioSocketImpl.java:345) + at sun.nio.ch.NioSocketImpl$1.read(java.base@24.0.2/NioSocketImpl.java:790) + at java.net.Socket$SocketInputStream.implRead(java.base@24.0.2/Socket.java:983) + at java.net.Socket$SocketInputStream.read(java.base@24.0.2/Socket.java:970) + at sun.nio.cs.StreamDecoder.readBytes(java.base@24.0.2/StreamDecoder.java:279) + at sun.nio.cs.StreamDecoder.implRead(java.base@24.0.2/StreamDecoder.java:322) + at sun.nio.cs.StreamDecoder.read(java.base@24.0.2/StreamDecoder.java:186) + - locked <0x000000052b0266a0> (a java.io.InputStreamReader) + at java.io.InputStreamReader.read(java.base@24.0.2/InputStreamReader.java:175) + at java.io.BufferedReader.fill(java.base@24.0.2/BufferedReader.java:166) + at java.io.BufferedReader.readLine(java.base@24.0.2/BufferedReader.java:333) + - locked <0x000000052b0266a0> (a java.io.InputStreamReader) + at java.io.BufferedReader.readLine(java.base@24.0.2/BufferedReader.java:400) + at com.intellij.rt.execution.application.AppMainV2$1.run(AppMainV2.java:31) + + Locked ownable synchronizers: + - <0x000000052b3d2888> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) + +"Notification Thread" #34 [42499] daemon prio=9 os_prio=31 cpu=0.01ms elapsed=24.08s tid=0x000000014a123600 nid=42499 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"worker I/O-1" #49 [38915] prio=5 os_prio=31 cpu=0.35ms elapsed=23.78s tid=0x000000014a9e8800 nid=38915 runnable [0x00000003220ba000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b0333c8> (a sun.nio.ch.Util$2) + - locked <0x000000052b033370> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-2" #50 [38403] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x000000014aa54e00 nid=38403 runnable [0x00000003222c6000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b040080> (a sun.nio.ch.Util$2) + - locked <0x000000052b040028> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-3" #51 [37891] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x000000014aa55600 nid=37891 runnable [0x00000003224d2000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b04cd38> (a sun.nio.ch.Util$2) + - locked <0x000000052b04cce0> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-4" #52 [43523] prio=5 os_prio=31 cpu=0.24ms elapsed=23.78s tid=0x000000014a9e9000 nid=43523 runnable [0x00000003226de000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b0599f0> (a sun.nio.ch.Util$2) + - locked <0x000000052b059998> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-5" #53 [44035] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x00000001038d6000 nid=44035 runnable [0x00000003228ea000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b073360> (a sun.nio.ch.Util$2) + - locked <0x000000052b073308> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-6" #54 [44291] prio=5 os_prio=31 cpu=0.25ms elapsed=23.78s tid=0x000000014c059c00 nid=44291 runnable [0x0000000322af6000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b0666a8> (a sun.nio.ch.Util$2) + - locked <0x000000052b066650> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-7" #55 [64771] prio=5 os_prio=31 cpu=0.23ms elapsed=23.78s tid=0x000000014c057800 nid=64771 runnable [0x0000000322d02000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b000158> (a sun.nio.ch.Util$2) + - locked <0x000000052b000100> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-8" #56 [44803] prio=5 os_prio=31 cpu=28.09ms elapsed=23.78s tid=0x000000014c058000 nid=44803 runnable [0x0000000322f0e000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b080018> (a sun.nio.ch.Util$2) + - locked <0x000000052b07ffc0> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:142) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:563) + + Locked ownable synchronizers: + - None + +"worker I/O-9" #57 [64515] prio=5 os_prio=31 cpu=0.04ms elapsed=23.78s tid=0x000000012fb59200 nid=64515 runnable [0x000000032311a000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b08ccd0> (a sun.nio.ch.Util$2) + - locked <0x000000052b08cc78> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-10" #58 [45571] prio=5 os_prio=31 cpu=0.05ms elapsed=23.78s tid=0x000000014a1f7000 nid=45571 runnable [0x0000000323326000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b019b18> (a sun.nio.ch.Util$2) + - locked <0x000000052b019ac0> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-11" #59 [64003] prio=5 os_prio=31 cpu=0.12ms elapsed=23.78s tid=0x000000012fb5bc00 nid=64003 runnable [0x0000000323532000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b00ce58> (a sun.nio.ch.Util$2) + - locked <0x000000052b00ce00> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-12" #60 [46083] prio=5 os_prio=31 cpu=0.06ms elapsed=23.78s tid=0x000000013f810200 nid=46083 runnable [0x000000032373e000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b02cd58> (a sun.nio.ch.Util$2) + - locked <0x000000052b02cd00> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-13" #61 [46339] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000013f819400 nid=46339 runnable [0x000000032394a000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b099988> (a sun.nio.ch.Util$2) + - locked <0x000000052b099930> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-14" #62 [63235] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000012f1d5800 nid=63235 runnable [0x0000000323b56000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b059b70> (a sun.nio.ch.Util$2) + - locked <0x000000052b059b18> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-15" #63 [46595] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000012f1d6000 nid=46595 runnable [0x0000000323d62000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b033548> (a sun.nio.ch.Util$2) + - locked <0x000000052b0334f0> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-16" #64 [47107] prio=5 os_prio=31 cpu=0.04ms elapsed=23.78s tid=0x000000013e998400 nid=47107 runnable [0x0000000323f6e000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b02ced8> (a sun.nio.ch.Util$2) + - locked <0x000000052b02ce80> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker Accept" #65 [62467] prio=5 os_prio=31 cpu=4.29ms elapsed=23.78s tid=0x000000014a1f7800 nid=62467 runnable [0x000000032417a000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b019d30> (a sun.nio.ch.Util$2) + - locked <0x000000052b019cd8> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"DestroyJavaVM" #67 [5635] prio=5 os_prio=31 cpu=510.86ms elapsed=23.63s tid=0x000000014aa71e00 nid=5635 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"worker task-1" #68 [27911] prio=5 os_prio=31 cpu=70.58ms elapsed=16.81s tid=0x000000012e03be00 nid=27911 waiting on condition [0x000000016f622000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@24.0.2/Native Method) + - parking to wait for <0x000000052b3de1c8> (a org.jboss.threads.EnhancedQueueExecutor) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@24.0.2/LockSupport.java:271) + at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1421) + at org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282) + at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) + at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) + + Locked ownable synchronizers: + - None + +"grpc-timer-0" #69 [27143] daemon prio=5 os_prio=31 cpu=0.12ms elapsed=16.81s tid=0x000000013e875000 nid=27143 waiting on condition [0x000000016fa3a000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@24.0.2/Native Method) + - parking to wait for <0x000000052b059c88> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@24.0.2/LockSupport.java:271) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@24.0.2/AbstractQueuedSynchronizer.java:1802) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@24.0.2/ScheduledThreadPoolExecutor.java:1166) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@24.0.2/ScheduledThreadPoolExecutor.java:883) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@24.0.2/ThreadPoolExecutor.java:1021) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@24.0.2/ThreadPoolExecutor.java:1081) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@24.0.2/ThreadPoolExecutor.java:619) + at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) + at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) + + Locked ownable synchronizers: + - None + +"Attach Listener" #72 [41995] daemon prio=9 os_prio=31 cpu=0.34ms elapsed=0.11s tid=0x000000012e045800 nid=41995 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"G1 Conc#2" os_prio=31 cpu=0.96ms elapsed=16.77s tid=0x000000014b1148c0 nid=34055 runnable + +"G1 Conc#1" os_prio=31 cpu=2.36ms elapsed=16.77s tid=0x0000000103616eb0 nid=33799 runnable + +"GC Thread#12" os_prio=31 cpu=2.93ms elapsed=23.83s tid=0x000000014971c4b0 nid=37379 runnable + +"GC Thread#11" os_prio=31 cpu=3.12ms elapsed=23.83s tid=0x000000014971bf30 nid=36867 runnable + +"GC Thread#10" os_prio=31 cpu=2.92ms elapsed=23.83s tid=0x000000014971b9b0 nid=36611 runnable + +"GC Thread#9" os_prio=31 cpu=2.97ms elapsed=23.83s tid=0x000000014971b430 nid=36355 runnable + +"GC Thread#8" os_prio=31 cpu=3.12ms elapsed=23.83s tid=0x000000014971aeb0 nid=36099 runnable + +"GC Thread#7" os_prio=31 cpu=3.09ms elapsed=23.83s tid=0x000000014971a930 nid=40451 runnable + +"GC Thread#6" os_prio=31 cpu=3.04ms elapsed=23.83s tid=0x000000014971a3b0 nid=35587 runnable + +"GC Thread#5" os_prio=31 cpu=2.95ms elapsed=23.83s tid=0x0000000149719e30 nid=35075 runnable + +"GC Thread#4" os_prio=31 cpu=2.86ms elapsed=23.83s tid=0x00000001497198b0 nid=34819 runnable + +"GC Thread#3" os_prio=31 cpu=3.13ms elapsed=23.83s tid=0x000000014b01c6a0 nid=41219 runnable + +"GC Thread#2" os_prio=31 cpu=3.11ms elapsed=23.83s tid=0x0000000149719330 nid=41731 runnable + +"GC Thread#1" os_prio=31 cpu=3.06ms elapsed=23.83s tid=0x000000012e812cd0 nid=34307 runnable + +"VM Thread" os_prio=31 cpu=5.25ms elapsed=24.15s tid=0x000000012df04560 nid=19715 runnable + +"VM Periodic Task Thread" os_prio=31 cpu=13.48ms elapsed=24.15s tid=0x00000001497086d0 nid=20743 waiting on condition + +"G1 Service" os_prio=31 cpu=1.69ms elapsed=24.15s tid=0x00000001497065d0 nid=21251 runnable + +"G1 Refine#0" os_prio=31 cpu=0.02ms elapsed=24.15s tid=0x000000014b860600 nid=16643 runnable + +"G1 Conc#0" os_prio=31 cpu=1.46ms elapsed=24.15s tid=0x0000000149705e30 nid=13827 runnable + +"G1 Main Marker" os_prio=31 cpu=0.12ms elapsed=24.15s tid=0x000000014b107a60 nid=13315 runnable + +"GC Thread#0" os_prio=31 cpu=3.00ms elapsed=24.15s tid=0x000000014b1072b0 nid=13059 runnable + +JNI global refs: 23, weak refs: 0 + diff --git a/jooby/src/main/java/io/jooby/Context.java b/jooby/src/main/java/io/jooby/Context.java index 1f5be9f676..1088474719 100644 --- a/jooby/src/main/java/io/jooby/Context.java +++ b/jooby/src/main/java/io/jooby/Context.java @@ -1088,6 +1088,15 @@ default Value lookup(String name) { */ Context setResponseHeader(@NonNull String name, @NonNull String value); + /** + * Set response trailer header. + * + * @param name Header name. + * @param value Header value. + * @return This context. + */ + Context setResponseTrailer(@NonNull String name, @NonNull String value); + /** * Remove a response header. * @@ -1238,6 +1247,13 @@ Context responseStream( * * @return HTTP channel as chunker. Usually for chunked response. */ + Sender responseSender(boolean startResponse); + + /** + * HTTP response channel as chunker. Mark the response as started. + * + * @return HTTP channel as chunker. Usually for chunked response. + */ Sender responseSender(); /** diff --git a/jooby/src/main/java/io/jooby/DefaultContext.java b/jooby/src/main/java/io/jooby/DefaultContext.java index 40edf0cb25..8bd8800395 100644 --- a/jooby/src/main/java/io/jooby/DefaultContext.java +++ b/jooby/src/main/java/io/jooby/DefaultContext.java @@ -554,6 +554,11 @@ default Context render(@NonNull Object value) { } } + @Override + default Sender responseSender() { + return responseSender(true); + } + @Override default OutputStream responseStream(@NonNull MediaType contentType) { setResponseType(contentType); diff --git a/jooby/src/main/java/io/jooby/ForwardingContext.java b/jooby/src/main/java/io/jooby/ForwardingContext.java index 5d2a8fcfe6..3efc766080 100644 --- a/jooby/src/main/java/io/jooby/ForwardingContext.java +++ b/jooby/src/main/java/io/jooby/ForwardingContext.java @@ -1091,6 +1091,12 @@ public Context setResponseHeader(@NonNull String name, @NonNull Date value) { return this; } + @Override + public Context setResponseTrailer(@NonNull String name, @NonNull String value) { + ctx.setResponseHeader(name, value); + return this; + } + @Override public Context setResponseHeader(@NonNull String name, @NonNull Instant value) { ctx.setResponseHeader(name, value); @@ -1217,6 +1223,11 @@ public Sender responseSender() { return ctx.responseSender(); } + @Override + public Sender responseSender(boolean startResponse) { + return ctx.responseSender(startResponse); + } + @Override public PrintWriter responseWriter() { return ctx.responseWriter(); diff --git a/jooby/src/main/java/io/jooby/Sender.java b/jooby/src/main/java/io/jooby/Sender.java index 7db97c811d..90dae3eef8 100644 --- a/jooby/src/main/java/io/jooby/Sender.java +++ b/jooby/src/main/java/io/jooby/Sender.java @@ -73,6 +73,15 @@ default Sender write(@NonNull String data, @NonNull Callback callback) { return write(data, StandardCharsets.UTF_8, callback); } + /** + * Set response trailer header. + * + * @param name Header name. + * @param value Header value. + * @return This context. + */ + Sender setTrailer(@NonNull String name, @NonNull String value); + /** * Write a string chunk. Chunk is flushed immediately. * diff --git a/jooby/src/main/java/io/jooby/internal/HeadContext.java b/jooby/src/main/java/io/jooby/internal/HeadContext.java index c4dc99aec9..06f66e9d22 100644 --- a/jooby/src/main/java/io/jooby/internal/HeadContext.java +++ b/jooby/src/main/java/io/jooby/internal/HeadContext.java @@ -190,6 +190,11 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { return this; } + @Override + public Sender setTrailer(@NonNull String name, @NonNull String value) { + return this; + } + @Override public void close() {} } diff --git a/list.txt b/list.txt new file mode 100644 index 0000000000..bb31be4d70 --- /dev/null +++ b/list.txt @@ -0,0 +1,11 @@ +INFO [2026-01-07 18:40:39,944] [worker-91] JettySubscription read data started +INFO [2026-01-07 18:40:39,945] [worker-91] JettySubscription byte read: 00000000033a012a +INFO [2026-01-07 18:40:39,945] [worker-91] GrpcRequestBridge deframe 3a012a +INFO [2026-01-07 18:40:39,946] [worker-91] GrpcRequestBridge asking for more request(1) +INFO [2026-01-07 18:40:39,984] [grpc-default-executor-1] UnifiedGrpcBridge onNext Send 12033a012a32460a120a10746573742e43686174536572766963650a250a23677270632e7265666c656374696f6e2e76312e5365727665725265666c656374696f6e0a090a0747726565746572 +INFO [2026-01-07 18:40:44,114] [grpc-default-executor-0] UnifiedGrpcBridge error io.grpc.StatusRuntimeException: UNAVAILABLE: Channel shutdownNow invoked + +INFO [2026-01-07 18:40:44,114] [Thread-0] GrpcServer Stopped GrpcServer +INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription read data started +INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription last reach +INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription handle complete diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 7bc3f91a4b..cef12bd69c 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -115,6 +115,11 @@ jooby-graphql ${project.version} + + io.jooby + jooby-grpc + ${project.version} + io.jooby jooby-gson diff --git a/modules/jooby-grpc/pom.xml b/modules/jooby-grpc/pom.xml new file mode 100644 index 0000000000..a9427cf56a --- /dev/null +++ b/modules/jooby-grpc/pom.xml @@ -0,0 +1,57 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.0.14-SNAPSHOT + + jooby-grpc + jooby-grpc + + + + io.jooby + jooby + ${jooby.version} + + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + io.grpc + grpc-inprocess + ${grpc.version} + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.jacoco + org.jacoco.agent + runtime + test + + + + org.mockito + mockito-core + test + + + diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java new file mode 100644 index 0000000000..d05e20cef0 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java @@ -0,0 +1,53 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.nio.ByteBuffer; +import java.util.function.Consumer; + +public class GrpcDeframer { + private enum State { + HEADER, + PAYLOAD + } + + private State state = State.HEADER; + private final ByteBuffer headerBuffer = ByteBuffer.allocate(5); + private ByteBuffer payloadBuffer; + + public void process(byte[] data, Consumer onMessage) { + ByteBuffer input = ByteBuffer.wrap(data); + while (input.hasRemaining()) { + if (state == State.HEADER) { + while (headerBuffer.hasRemaining() && input.hasRemaining()) { + headerBuffer.put(input.get()); + } + if (!headerBuffer.hasRemaining()) { + headerBuffer.flip(); + headerBuffer.get(); // skip compressed flag + int length = headerBuffer.getInt(); + if (length == 0) { + onMessage.accept(new byte[0]); + headerBuffer.clear(); + } else { + payloadBuffer = ByteBuffer.allocate(length); + state = State.PAYLOAD; + } + } + } else if (state == State.PAYLOAD) { + while (payloadBuffer.hasRemaining() && input.hasRemaining()) { + payloadBuffer.put(input.get()); + } + if (!payloadBuffer.hasRemaining()) { + onMessage.accept(payloadBuffer.array()); + headerBuffer.clear(); + payloadBuffer = null; + state = State.HEADER; + } + } + } + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcHandler.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcHandler.java new file mode 100644 index 0000000000..9073718b5a --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcHandler.java @@ -0,0 +1,229 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.CompletableFuture; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.grpc.*; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.StreamObserver; +import io.jooby.Context; +import io.jooby.Route; +import io.jooby.Sender; +import io.jooby.exception.BadRequestException; + +public class GrpcHandler implements Route.Handler { + // Minimal Marshaller to pass raw bytes through the bridge + private static class RawMarshaller implements MethodDescriptor.Marshaller { + @Override + public InputStream stream(byte[] value) { + return new ByteArrayInputStream(value); + } + + @Override + public byte[] parse(InputStream stream) { + try { + return stream.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private final ManagedChannel channel; + private final GrpcMethodRegistry methodRegistry; + + public GrpcHandler(ManagedChannel channel, GrpcMethodRegistry methodRegistry) { + this.channel = channel; + this.methodRegistry = methodRegistry; + } + + @Override + public Object apply(@NonNull Context ctx) throws Exception { + // Detect gRPC content type + String contentType = ctx.header("Content-Type").value(); + if (contentType == null || !contentType.contains("application/grpc")) { + throw new BadRequestException(String.format("Content-Type: %s not supported", contentType)); + } + + // Setup gRPC response headers + ctx.setResponseType(contentType); + + var path = ctx.path("service").value() + "/" + ctx.path("method").value(); + var descriptor = methodRegistry.get(path); + if (descriptor == null) { + return ctx.setResponseCode(404).send("Service not found"); + } + var method = + MethodDescriptor.newBuilder() + .setType(descriptor.getType()) + .setFullMethodName(descriptor.getFullMethodName()) + .setRequestMarshaller(new RawMarshaller()) + .setResponseMarshaller(new RawMarshaller()) + .build(); + + // 1. Initiate the internal gRPC call + // We use byte[] marshallers to keep it raw and fast + CompletableFuture future = + switch (method.getType()) { + case UNARY -> handleUnary(ctx, method); + case BIDI_STREAMING -> handleBidi(ctx, method); + // case SERVER_STREAMING -> handleServerStreaming(ctx, method); + // case CLIENT_STREAMING -> handleClientStreaming(ctx, method); + default -> + CompletableFuture.failedFuture(new UnsupportedOperationException("Unknown type")); + }; + return future; + } + + private CompletableFuture handleBidi(Context ctx, MethodDescriptor method) + throws IOException { + CompletableFuture future = new CompletableFuture<>(); + + var sender = ctx.responseSender(false); + StreamObserver requestObserver = + ClientCalls.asyncBidiStreamingCall( + channel.newCall(method, CallOptions.DEFAULT), + new StreamObserver() { + @Override + public void onNext(byte[] value) { + ctx.setResponseTrailer("grpc-status", "0"); + sender.write( + addGrpcHeader(value), + new Sender.Callback() { + @Override + public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) {} + }); + } + + @Override + public void onError(Throwable t) { + ctx.setResponseTrailer( + "grpc-status", Integer.toString(Status.fromThrowable(t).getCode().value())); + future.completeExceptionally(t); + } + + @Override + public void onCompleted() { + sender.close(); + future.complete(ctx); + } + }); + + var is = ctx.body().stream(); + byte[] frame; + while ((frame = readOneGrpcFrame(is)) != null) { + requestObserver.onNext(frame); + } + requestObserver.onCompleted(); + return future; + } + + private CompletableFuture handleUnary( + Context ctx, MethodDescriptor method) throws IOException { + CompletableFuture future = new CompletableFuture<>(); + byte[] requestPayload = readOneGrpcFrame(ctx.body().stream()); + ClientCalls.asyncUnaryCall( + channel.newCall(method, CallOptions.DEFAULT), + requestPayload, + new StreamObserver() { + @Override + public void onNext(byte[] value) { + ctx.setResponseTrailer("grpc-status", "0"); + ctx.send(addGrpcHeader(value)); + } + + @Override + public void onError(Throwable t) { + ctx.setResponseTrailer( + "grpc-status", Integer.toString(Status.fromThrowable(t).getCode().value())); + future.completeExceptionally(t); + } + + @Override + public void onCompleted() { + future.complete(ctx); + } + }); + return future; + } + + /** + * Prepends the 5-byte gRPC header to the payload. * @param payload The raw binary message from + * the internal gRPC service. + * + * @return A new byte array containing [Flag][Length][Payload]. + */ + private byte[] addGrpcHeader(byte[] payload) { + int length = payload.length; + byte[] framedMessage = new byte[5 + length]; + + // 1. Compression Flag (0 = none) + // We pass 0 because our bridge usually handles raw uncompressed bytes + // or handles already-compressed payloads transparently. + framedMessage[0] = 0; + + // 2. Encode Length as 4-byte Big Endian integer + framedMessage[1] = (byte) ((length >> 24) & 0xFF); + framedMessage[2] = (byte) ((length >> 16) & 0xFF); + framedMessage[3] = (byte) ((length >> 8) & 0xFF); + framedMessage[4] = (byte) (length & 0xFF); + + // 3. Copy the actual payload after the 5-byte header + System.arraycopy(payload, 0, framedMessage, 5, length); + + return framedMessage; + } + + /** + * Reads exactly one gRPC frame from the input stream. * @param is The Jooby/Servlet input stream + * + * @return The raw protobuf payload (without the 5-byte header), or null if the end of the stream + * is reached. + */ + private byte[] readOneGrpcFrame(InputStream is) throws IOException { + // 1. Read the 5-byte gRPC header + // Byte 0: Compression flag + // Bytes 1-4: Message length (Big Endian) + byte[] header = new byte[5]; + int bytesRead = is.readNBytes(header, 0, 5); + + if (bytesRead == 0) { + return null; // Normal End of Stream (Half-close) + } + + if (bytesRead < 5) { + throw new IOException("Incomplete gRPC header. Expected 5 bytes, got " + bytesRead); + } + + // 2. Extract the length (Big Endian) + // We mask with 0xFF to treat bytes as unsigned + int length = + ((header[1] & 0xFF) << 24) + | ((header[2] & 0xFF) << 16) + | ((header[3] & 0xFF) << 8) + | ((header[4] & 0xFF)); + + if (length < 0) { + throw new IOException("Invalid gRPC frame length: " + length); + } + + // 3. Read exactly 'length' bytes for the payload + byte[] payload = is.readNBytes(length); + + if (payload.length < length) { + throw new IOException( + "Incomplete gRPC payload. Expected " + length + " bytes, got " + payload.length); + } + + return payload; + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java new file mode 100644 index 0000000000..2d7e39ba12 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java @@ -0,0 +1,28 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.util.HashMap; +import java.util.Map; + +import io.grpc.BindableService; +import io.grpc.MethodDescriptor; + +public class GrpcMethodRegistry { + private final Map> registry = new HashMap<>(); + + public void registerService(BindableService service) { + var serviceDef = service.bindService(); + for (var methodDef : serviceDef.getMethods()) { + MethodDescriptor descriptor = methodDef.getMethodDescriptor(); + registry.put(descriptor.getFullMethodName(), descriptor); + } + } + + public MethodDescriptor get(String fullMethodName) { + return registry.get(fullMethodName); + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java new file mode 100644 index 0000000000..a2b26e9206 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java @@ -0,0 +1,54 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.util.List; +import java.util.function.Function; + +import io.grpc.*; +import io.grpc.Server; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.jooby.*; + +public class GrpcModule implements Extension { + private final List services; + private final GrpcMethodRegistry methodRegistry = new GrpcMethodRegistry(); + private final String serverName = "jooby-internal-" + System.nanoTime(); + private Server grpcServer; + + public GrpcModule(BindableService... services) { + this.services = List.of(services); + } + + @Override + public void install(Jooby app) throws Exception { + // 1. Start an In-Process gRPC Server (Memory only) + var builder = InProcessServerBuilder.forName(serverName).directExecutor(); + for (BindableService service : services) { + builder.addService(service); + methodRegistry.registerService(service); + } + + this.grpcServer = builder.build().start(); + + // 2. Create the Channel to talk to it + var channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + + var handler = new UnifiedGrpcBridge(channel, methodRegistry); + app.getServices().put(ServiceKey.key(Function.class, "gRPC"), handler); + // 3. Register the bridge route + // gRPC paths are always /{package.Service}/{Method} + // app.post("/{service}/{method}", ReactiveSupport.concurrent(new GrpcHandler(channel, + // methodRegistry))); + + app.onStop( + () -> { + channel.shutdownNow(); + grpcServer.shutdownNow(); + }); + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java new file mode 100644 index 0000000000..074a147a81 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java @@ -0,0 +1,79 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.util.HexFormat; +import java.util.concurrent.Flow.Subscriber; +import java.util.concurrent.Flow.Subscription; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.StreamObserver; + +/** + * Wraps a gRPC StreamObserver as an internal field and feeds it data from a standard Java + * Flow.Publisher. + */ +public class GrpcRequestBridge implements Subscriber { + + private final Logger log = LoggerFactory.getLogger(getClass()); + private final ClientCallStreamObserver internalObserver; + private final GrpcDeframer deframer; + private String path; + private Subscription subscription; + private AtomicBoolean completed = new AtomicBoolean(false); + + public GrpcRequestBridge(String path, StreamObserver internalObserver) { + this.path = path; + this.deframer = new GrpcDeframer(); + this.internalObserver = (ClientCallStreamObserver) internalObserver; + } + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + // Demand the first chunk. In a pro bridge, you might demand 1 + // and only demand more when the gRPC observer is ready. + subscription.request(1); + } + + public void onNext(byte[] item) { + try { + + deframer.process( + item, + msg -> { + log.info("deframe {}", HexFormat.of().formatHex(msg)); + internalObserver.onNext(msg); + }); + + log.info("asking for more request(1)"); + internalObserver.request(1); + } catch (Throwable t) { + subscription.cancel(); + internalObserver.onError(t); + } + } + + private boolean isReflectionPath(String path) { + return path.contains("ServerReflectionInfo"); + } + + @Override + public void onError(Throwable throwable) { + internalObserver.onError(throwable); + } + + @Override + public void onComplete() { + if (completed.compareAndSet(false, true)) { + internalObserver.onCompleted(); + } + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java new file mode 100644 index 0000000000..9a5b4178d0 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java @@ -0,0 +1,253 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.HexFormat; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.grpc.*; +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.ClientResponseObserver; +import io.grpc.stub.StreamObserver; +import io.jooby.Context; +import io.jooby.Sender; + +public class UnifiedGrpcBridge implements Function> { + // Minimal Marshaller to pass raw bytes through the bridge + private static class RawMarshaller implements MethodDescriptor.Marshaller { + @Override + public InputStream stream(byte[] value) { + return new ByteArrayInputStream(value); + } + + @Override + public byte[] parse(InputStream stream) { + try { + return stream.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private final Logger log = LoggerFactory.getLogger(getClass()); + private final ManagedChannel channel; + private final GrpcMethodRegistry methodRegistry; + + public UnifiedGrpcBridge(ManagedChannel channel, GrpcMethodRegistry methodRegistry) { + this.channel = channel; + this.methodRegistry = methodRegistry; + } + + @Override + public Flow.Subscriber apply(Context context) { + return new GrpcRequestBridge(context.getRequestPath(), startCall(context)); + } + + /** + * Unified entry point to start an internal call. Handles Unary, Bidi, and Streaming via a single + * StreamObserver interface. + */ + public StreamObserver startCall(Context ctx) { + // Setup gRPC response headers + ctx.setResponseType("application/grpc"); + + var path = + ctx.getRequestPath(); // ctx.path("service").value() + "/" + ctx.path("method").value(); + var descriptor = methodRegistry.get(path.substring(1)); + if (descriptor == null) { + terminateWithStatus( + null, + Status.UNIMPLEMENTED.withDescription("Method not found in bridge registry: " + path)); + return null; + } + + var method = + MethodDescriptor.newBuilder() + .setType(descriptor.getType()) + .setFullMethodName(descriptor.getFullMethodName()) + .setRequestMarshaller(new RawMarshaller()) + .setResponseMarshaller(new RawMarshaller()) + .build(); + + // 2. Prepare Call Options (Propagation of timeouts/metadata could happen here) + CallOptions callOptions = CallOptions.DEFAULT; + ClientCall call = channel.newCall(method, callOptions); + + ClientResponseObserver responseObserver; + log.info("method type: {}", method.getType()); + if (method.getType() == MethodDescriptor.MethodType.UNARY) { + // Atomic guard to prevent multiple terminal calls + var isFinished = new AtomicBoolean(false); + // 3. Unified Response Observer (Handles data coming BACK from the server) + responseObserver = + new ClientResponseObserver<>() { + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + requestStream.disableAutoInboundFlowControl(); + } + + @Override + public void onNext(byte[] value) { + if (isFinished.get()) return; + log.info("onNext Send {}", HexFormat.of().formatHex(value)); + + // Professional Framing: 5-byte header + payload + ctx.setResponseTrailer("grpc-status", "0"); + byte[] framed = addGrpcHeader(value); + ctx.send(framed); + } + + @Override + public void onError(Throwable t) { + if (isFinished.compareAndSet(false, true)) { + log.info(" error", t); + terminateWithStatus(ctx, Status.fromThrowable(t)); + } + } + + @Override + public void onCompleted() { + if (isFinished.compareAndSet(false, true)) { + log.info("onCompleted"); + terminateWithStatus(ctx, Status.OK); + } + } + }; + } else { + var sender = ctx.responseSender(false); + // Atomic guard to prevent multiple terminal calls + var isFinished = new AtomicBoolean(false); + // 3. Unified Response Observer (Handles data coming BACK from the server) + responseObserver = + new ClientResponseObserver<>() { + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + requestStream.disableAutoInboundFlowControl(); + } + + @Override + public void onNext(byte[] value) { + if (isFinished.get()) return; + log.info("onNext Send {}", HexFormat.of().formatHex(value)); + + // Professional Framing: 5-byte header + payload + sender.setTrailer("grpc-status", "0"); + byte[] framed = addGrpcHeader(value); + sender.write( + framed, + new Sender.Callback() { + @Override + public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) { + log.info("onNext Sent {}", ctx); + if (cause != null) { + onError(cause); + } + } + }); + } + + @Override + public void onError(Throwable t) { + if (isFinished.compareAndSet(false, true)) { + log.info(" error", t); + terminateWithStatus(ctx, Status.fromThrowable(t)); + } + } + + @Override + public void onCompleted() { + if (isFinished.compareAndSet(false, true)) { + log.info("onCompleted"); + terminateWithStatus(ctx, Status.OK); + } + } + }; + } + + // 4. Map gRPC Method Type to the correct ClientCalls utility + return switch (method.getType()) { + case UNARY -> wrapUnary(call, responseObserver); + case BIDI_STREAMING, CLIENT_STREAMING -> + ClientCalls.asyncBidiStreamingCall(call, responseObserver); + case SERVER_STREAMING -> wrapServerStreaming(call, responseObserver); + default -> { + terminateWithStatus(ctx, Status.INTERNAL.withDescription("Unsupported method type")); + yield null; + } + }; + } + + private boolean isReflectionPath(String path) { + return path.contains("ServerReflectionInfo"); + } + + private StreamObserver wrapUnary( + ClientCall call, StreamObserver responseObserver) { + // Unary expects a single message. We use the Bidi utility but logic ensures 1:1. + return ClientCalls.asyncBidiStreamingCall(call, responseObserver); + } + + private StreamObserver wrapServerStreaming( + ClientCall call, StreamObserver responseObserver) { + // Server streaming takes 1 request and returns an observer for the result stream + return new StreamObserver<>() { + private boolean sent = false; + + @Override + public void onNext(byte[] value) { + if (!sent) { + ClientCalls.asyncServerStreamingCall(call, value, responseObserver); + sent = true; + } + } + + @Override + public void onError(Throwable t) { + responseObserver.onError(t); + } + + @Override + public void onCompleted() { + /* Server side handles completion */ + } + }; + } + + /** + * Professional Status Termination. Sets gRPC trailers and closes the Jetty response correctly. + */ + private void terminateWithStatus(Context ctx, Status status) { + ctx.setResponseTrailer("grpc-status", String.valueOf(status.getCode().value())); + if (status.getDescription() != null) { + ctx.setResponseTrailer("grpc-message", status.getDescription()); + } + ctx.send(""); + } + + private byte[] addGrpcHeader(byte[] payload) { + int len = payload.length; + byte[] framed = new byte[5 + len]; + framed[0] = 0; // Uncompressed + framed[1] = (byte) (len >> 24); + framed[2] = (byte) (len >> 16); + framed[3] = (byte) (len >> 8); + framed[4] = (byte) len; + System.arraycopy(payload, 0, framed, 5, len); + return framed; + } +} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java index f9d0d1d81b..ec68ae6c34 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java @@ -81,6 +81,7 @@ static DeleteFileTask of(FileDownload file) { private final long maxRequestSize; Request request; Response response; + HttpFields.Mutable trailers; private QueryString query; private Formdata formdata; @@ -439,6 +440,16 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } + @Override + public Context setResponseTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = HttpFields.build(); + response.setTrailersSupplier(() -> trailers); + } + trailers.put(name, value); + return this; + } + @NonNull @Override public Context removeResponseHeader(@NonNull String name) { response.getHeaders().remove(name); @@ -480,11 +491,13 @@ public long getResponseLength() { return this; } - @NonNull @Override - public Sender responseSender() { - responseStarted = true; - ifSetChunked(); - return new JettySender(this, response); + @Override + public Sender responseSender(boolean startResponse) { + responseStarted = startResponse; + if (startResponse) { + ifSetChunked(); + } + return new JettySender(this); } @NonNull @Override diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java new file mode 100644 index 0000000000..ded27ef280 --- /dev/null +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java @@ -0,0 +1,39 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jetty; + +import java.util.concurrent.Flow; +import java.util.function.Function; + +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +import io.jooby.Context; + +/** A professional Jetty Handler that bridges HTTP/2 streams to a gRPC Subscriber. */ +public class JettyGrpcHandler extends Handler.Abstract { + + private final Function> subscriberFactory; + private final Context ctx; + + public JettyGrpcHandler( + io.jooby.Context ctx, Function> subscriberFactory) { + this.ctx = ctx; + this.subscriberFactory = subscriberFactory; + } + + @Override + public boolean handle(Request request, Response response, Callback callback) { + Flow.Subscriber subscriber = subscriberFactory.apply(ctx); + + JettyRequestPublisher publisher = new JettyRequestPublisher(request); + publisher.subscribe(subscriber); + + return true; + } +} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java index c1aeba9972..ebe378783b 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java @@ -5,12 +5,16 @@ */ package io.jooby.internal.jetty; +import java.util.function.Function; + +import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.util.Callback; import io.jooby.Router; +import io.jooby.ServiceKey; import io.jooby.internal.jetty.http2.JettyHeaders; public class JettyHandler extends Handler.Abstract { @@ -43,7 +47,13 @@ public boolean handle(Request request, Response response, Callback callback) { var context = new JettyContext( getInvocationType(), request, response, callback, router, bufferSize, maxRequestSize); - router.match(context).execute(context); + if (!"POST".equalsIgnoreCase(request.getMethod()) + || !request.getHeaders().contains(HttpHeader.CONTENT_TYPE, "application/grpc")) { + router.match(context).execute(context); + } else { + var subscriber = router.require(ServiceKey.key(Function.class, "gRPC")); + new JettyGrpcHandler(context, subscriber).handle(request, response, callback); + } } catch (JettyStopPipeline ignored) { // handled already, } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java new file mode 100644 index 0000000000..6cffb42bde --- /dev/null +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java @@ -0,0 +1,137 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jetty; + +import java.util.HexFormat; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Request; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JettyRequestPublisher implements Flow.Publisher { + private final Logger log = LoggerFactory.getLogger(getClass()); + private final Request request; + + public JettyRequestPublisher(Request request) { + this.request = request; + } + + @Override + public void subscribe(Flow.Subscriber subscriber) { + var subscription = new JettySubscription(request, subscriber); + subscriber.onSubscribe(subscription); + } +} + +/** + * Professional Jetty 12 Core Subscription. Uses the demand-callback pattern to satisfy gRPC stream + * requirements. + */ +class JettySubscription implements Flow.Subscription { + + private static final Logger log = LoggerFactory.getLogger(JettySubscription.class); + private final Request request; + private final Flow.Subscriber subscriber; + + private final AtomicLong demand = new AtomicLong(); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private final AtomicBoolean completed = new AtomicBoolean(false); + + public JettySubscription(Request request, Flow.Subscriber subscriber) { + this.request = request; + this.subscriber = subscriber; + } + + private final AtomicBoolean demandPending = new AtomicBoolean(false); + + private void process(String call) { + log.info("{}- start reading request", call); + try { + var demandMore = false; + while (true) { + // 2. Check for data. We MUST read if the deframer is "hungry," + // even if application demand is 0. + var chunk = request.read(); + + if (chunk == null) { + log.info("{}- demanding more", call); + request.demand( + () -> { + process(call + ".demand"); + }); + return; + } + + if (Content.Chunk.isFailure(chunk)) { + log.info("{}- bad chunk: {}", call, chunk); + boolean fatal = chunk.isLast(); + if (fatal) { + handleComplete(); + return; + } else { + handleError(chunk.getFailure()); + return; + } + } + var buffer = chunk.getByteBuffer(); + + if (buffer != null && buffer.hasRemaining()) { + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + + log.info("{}- byte read: {}", call, HexFormat.of().formatHex(bytes)); + // demand.decrementAndGet(); + subscriber.onNext(bytes); + } + chunk.release(); + + if (chunk.isLast()) { + log.info("{}- last reach", call); + // Even if we have 0 demand, we must finish the stream + handleComplete(); + return; + } + } + } catch (Throwable t) { + handleError(t); + } finally { + log.info("{}- finish reading request", call); + } + } + + private void handleComplete() { + if (completed.compareAndSet(false, true) && !cancelled.get()) { + log.info("handle complete"); + subscriber.onComplete(); + } + } + + private void handleError(Throwable t) { + if (completed.compareAndSet(false, true) && !cancelled.get()) { + log.info("handle error", t); + subscriber.onError(t); + } + } + + long c = 0; + + @Override + public void request(long n) { + if (n <= 0) return; + log.info("init request({})", n); + c += n; + process(Long.toString(c)); + } + + @Override + public void cancel() { + cancelled.set(true); + } +} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java index c8f235041f..fa55675039 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java @@ -5,11 +5,11 @@ */ package io.jooby.internal.jetty; -import static io.jooby.internal.jetty.JettyCallbacks.fromOutput; - import java.nio.ByteBuffer; +import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Sender; @@ -18,24 +18,63 @@ public class JettySender implements Sender { private final JettyContext ctx; private final Response response; + private HttpFields.Mutable trailers; + private ByteBuffer pending; + private org.eclipse.jetty.util.Callback pendingCallback; - public JettySender(JettyContext ctx, Response response) { + public JettySender(JettyContext ctx) { this.ctx = ctx; - this.response = response; + this.response = ctx.response; + this.trailers = ctx.trailers; } @Override - public Sender write(@NonNull byte[] data, @NonNull Callback callback) { - response.write(false, ByteBuffer.wrap(data), toJettyCallback(ctx, callback)); + public Sender setTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = HttpFields.build(); + } + trailers.put(name, value); return this; } + @Override + public Sender write(@NonNull byte[] data, @NonNull Callback callback) { + return write(ByteBuffer.wrap(data), callback); + } + @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { - fromOutput(response, toJettyCallback(ctx, callback), output).send(false); + return write(output.asByteBuffer(), callback); + } + + public Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { + response.write(false, buffer, toJettyCallback(ctx, callback)); + // if (trailers == null) { + // response.write(false, buffer, toJettyCallback(ctx, callback)); + // } else { + // if (pending != null) { + // response.write(false, pending, pendingCallback); + // } + // pending = buffer; + // pendingCallback = toJettyCallback(ctx, callback); + // } return this; } + @Override + public void close() { + if (trailers != null) { + response.setTrailersSupplier(() -> trailers); + response.write(true, null, ctx); + } + // if (pending != null) { + // response.setTrailersSupplier(() -> trailers); + // response.write(true, pending, ctx); + // } else { + // response.write(true, null, ctx); + // } + } + private static org.eclipse.jetty.util.Callback toJettyCallback( JettyContext ctx, Callback callback) { return new org.eclipse.jetty.util.Callback() { @@ -51,9 +90,4 @@ public void failed(Throwable x) { } }; } - - @Override - public void close() { - response.write(false, null, ctx); - } } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/http2/JettyHttp2Configurer.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/http2/JettyHttp2Configurer.java index ab3b8d50cc..1a1d03d459 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/http2/JettyHttp2Configurer.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/http2/JettyHttp2Configurer.java @@ -5,8 +5,6 @@ */ package io.jooby.internal.jetty.http2; -import java.util.Arrays; -import java.util.Collections; import java.util.List; import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; @@ -25,13 +23,23 @@ public class JettyHttp2Configurer { public List configure(HttpConfiguration input) { if (input.getCustomizer(SecureRequestCustomizer.class) != null) { ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(H2, H2_17, HTTP_1_1); - alpn.setDefaultProtocol(HTTP_1_1); + alpn.setDefaultProtocol(H2); - HTTP2ServerConnectionFactory https2 = new HTTP2ServerConnectionFactory(input); + HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(input); + h2.setInitialStreamRecvWindow(1024 * 1024); + h2.setInitialSessionRecvWindow(10 * 1024 * 1024); - return Arrays.asList(alpn, https2); + // FIX: Set Max Concurrent Streams higher if you have many bidi clients + h2.setMaxConcurrentStreams(1000); + return List.of(alpn, h2); } else { - return Collections.singletonList(new HTTP2CServerConnectionFactory(input)); + var h2c = new HTTP2CServerConnectionFactory(input); + h2c.setInitialStreamRecvWindow(1024 * 1024); + h2c.setInitialSessionRecvWindow(10 * 1024 * 1024); + + // FIX: Set Max Concurrent Streams higher if you have many bidi clients + h2c.setMaxConcurrentStreams(1000); + return List.of(h2c); } } } diff --git a/modules/jooby-jetty/src/main/java/module-info.java b/modules/jooby-jetty/src/main/java/module-info.java index e10ff88cd1..8b1a6e495b 100644 --- a/modules/jooby-jetty/src/main/java/module-info.java +++ b/modules/jooby-jetty/src/main/java/module-info.java @@ -18,6 +18,7 @@ requires org.eclipse.jetty.http2.server; requires org.eclipse.jetty.websocket.server; requires java.desktop; + requires org.eclipse.jetty.http; provides Server with JettyServer; diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java new file mode 100644 index 0000000000..e3ec01b05c --- /dev/null +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java @@ -0,0 +1,97 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.netty; + +import java.io.InputStream; +import java.lang.reflect.Type; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.jooby.Body; +import io.jooby.Context; +import io.jooby.MediaType; +import io.jooby.value.Value; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.ByteBufUtil; + +public class NettyByteBufBody implements Body { + private final Context ctx; + private final ByteBuf data; + private final long length; + + public NettyByteBufBody(Context ctx, ByteBuf data) { + this.ctx = ctx; + this.data = data; + this.length = data.readableBytes(); + } + + @Override + public boolean isInMemory() { + return true; + } + + @Override + public long getSize() { + return length; + } + + @Override + public InputStream stream() { + return new ByteBufInputStream(data); + } + + @Override + public Value get(@NonNull String name) { + return Value.missing(ctx.getValueFactory(), name); + } + + @Override + public Value getOrDefault(@NonNull String name, @NonNull String defaultValue) { + return Value.value(ctx.getValueFactory(), name, defaultValue); + } + + @Override + public ReadableByteChannel channel() { + return Channels.newChannel(stream()); + } + + @Override + public byte[] bytes() { + return ByteBufUtil.getBytes(data); + } + + @NonNull @Override + public String value() { + return value(StandardCharsets.UTF_8); + } + + @Override + public String name() { + return "body"; + } + + @NonNull @Override + public T to(@NonNull Type type) { + return ctx.decode(type, ctx.getRequestType(MediaType.text)); + } + + @Nullable @Override + public T toNullable(@NonNull Type type) { + return ctx.decode(type, ctx.getRequestType(MediaType.text)); + } + + @Override + public Map> toMultimap() { + return Collections.emptyMap(); + } +} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java index 0f1c34d959..f0334184a7 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java @@ -118,6 +118,7 @@ public void operationComplete(ChannelFuture future) { private static final String STREAM_ID = "x-http2-stream-id"; private String streamId; + HeadersMultiMap trailers; HeadersMultiMap setHeaders = HEADERS.newHeaders(); private int bufferSize; InterfaceHttpPostRequestDecoder decoder; @@ -375,7 +376,9 @@ public Body body() { if (decoder != null && decoder.hasNext()) { return new NettyBody(this, (HttpData) decoder.next(), HttpUtil.getContentLength(req, -1L)); } - return Body.empty(this); + return (req instanceof DefaultFullHttpRequest full) + ? new NettyByteBufBody(this, full.content()) + : Body.empty(this); } @Override @@ -487,6 +490,15 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } + @NonNull @Override + public Context setResponseTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = HEADERS.newHeaders(); + } + trailers.set(name, value); + return this; + } + @NonNull @Override public Context removeResponseHeader(@NonNull String name) { setHeaders.remove(name); @@ -567,9 +579,11 @@ public PrintWriter responseWriter(MediaType type) { new NettyWriter(newOutputStream(), ofNullable(type.getCharset()).orElse(UTF_8))); } - @NonNull @Override - public Sender responseSender() { - prepareChunked(); + @Override + public Sender responseSender(boolean startResponse) { + if (startResponse) { + prepareChunked(); + } ctx.write(new DefaultHttpResponse(HTTP_1_1, status, setHeaders)); return new NettySender(this); } @@ -623,7 +637,9 @@ Context send(@NonNull ByteBuf data, CharSequence contentLength) { try { responseStarted = true; setHeaders.set(CONTENT_LENGTH, contentLength); - var response = new DefaultFullHttpResponse(HTTP_1_1, status, data, setHeaders, NO_TRAILING); + var response = + new DefaultFullHttpResponse( + HTTP_1_1, status, data, setHeaders, trailers == null ? NO_TRAILING : trailers); connection.writeMessage(response, promise()); return this; } finally { diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyHandler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyHandler.java index 327d3416ca..fc357b81de 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyHandler.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyHandler.java @@ -84,9 +84,19 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) { // possibly body: long contentLength = contentLength(req); if (contentLength > 0 || isTransferEncodingChunked(req)) { - context.httpDataFactory = new DefaultHttpDataFactory(bufferSize); - context.httpDataFactory.setBaseDir(app.getTmpdir().toString()); - context.setDecoder(newDecoder(req, context.httpDataFactory, maxFormFields)); + if (req.getClass() == DefaultFullHttpRequest.class) { + // HTTP2 aggregates all into a full http request. + if (((DefaultFullHttpRequest) req).content().readableBytes() > maxRequestSize) { + router.match(context).execute(context, Route.REQUEST_ENTITY_TOO_LARGE); + return; + } + // full body is here move + router.match(context).execute(context); + } else { + context.httpDataFactory = new DefaultHttpDataFactory(bufferSize); + context.httpDataFactory.setBaseDir(app.getTmpdir().toString()); + context.setDecoder(newDecoder(req, context.httpDataFactory, maxFormFields)); + } } else { // no body, move on router.match(context).execute(context); diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java index 1fb339fae0..47daa6bff0 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java @@ -6,6 +6,7 @@ package io.jooby.internal.netty; import static io.jooby.internal.netty.NettyByteBufRef.byteBuf; +import static io.jooby.internal.netty.NettyHeadersFactory.HEADERS; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Sender; @@ -14,16 +15,28 @@ import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.LastHttpContent; public class NettySender implements Sender { private final NettyContext ctx; private final ChannelHandlerContext context; + private HeadersMultiMap trailers; public NettySender(NettyContext ctx) { this.ctx = ctx; this.context = ctx.ctx; + this.trailers = ctx.trailers; + } + + @Override + public Sender setTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = HEADERS.newHeaders(); + } + trailers.set(name, value); + return this; } @Override @@ -44,7 +57,13 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { @Override public void close() { - context.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT, ctx.promise()); + LastHttpContent lastContent; + if (trailers != null) { + lastContent = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER, trailers); + } else { + lastContent = LastHttpContent.EMPTY_LAST_CONTENT; + } + context.writeAndFlush(lastContent, ctx.promise()); ctx.requestComplete(); } diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java index 3e0de59faf..cfd84463d9 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java @@ -49,7 +49,7 @@ private Http2ConnectionHandler newHttp2Handler(int maxRequestSize, HttpScheme sc new InboundHttp2ToHttpAdapterBuilder(connection) .propagateSettings(false) .validateHttpHeaders(true) - .maxContentLength(maxRequestSize) + .maxContentLength(-1) .build(); return new HttpToHttp2ConnectionHandlerBuilder() diff --git a/modules/jooby-netty/src/main/java/module-info.java b/modules/jooby-netty/src/main/java/module-info.java index 5f9e7f16e4..0831e5136c 100644 --- a/modules/jooby-netty/src/main/java/module-info.java +++ b/modules/jooby-netty/src/main/java/module-info.java @@ -25,7 +25,6 @@ requires static io.netty.transport.classes.epoll; requires static io.netty.transport.classes.kqueue; requires static io.netty.transport.classes.io_uring; - requires java.desktop; provides Server with NettyServer; diff --git a/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java b/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java index 66686d4189..e8ab24cf6a 100644 --- a/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java +++ b/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java @@ -87,6 +87,8 @@ public class MockContext implements DefaultContext { private Map responseHeaders = new HashMap<>(); + private Map responseTrailers = new HashMap<>(); + private Map attributes = new HashMap<>(); private MockResponse response = new MockResponse(); @@ -497,6 +499,12 @@ public MockContext setResponseHeader(@NonNull String name, @NonNull String value return this; } + @Override + public MockContext setResponseTrailer(@NonNull String name, @NonNull String value) { + responseTrailers.put(name, value); + return this; + } + @Override public MockContext setResponseLength(long length) { response.setContentLength(length); @@ -557,8 +565,8 @@ public OutputStream responseStream() { } @Override - public Sender responseSender() { - responseStarted = true; + public Sender responseSender(boolean startResponse) { + responseStarted = startResponse; return new Sender() { @Override public Sender write(@NonNull byte[] data, @NonNull Callback callback) { @@ -567,6 +575,11 @@ public Sender write(@NonNull byte[] data, @NonNull Callback callback) { return this; } + @Override + public Sender setTrailer(@NonNull String name, @NonNull String value) { + return this; + } + @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { response.setResult(output); diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java index 7eba730fce..02cfb85d4a 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java @@ -46,12 +46,14 @@ import io.undertow.server.RenegotiationRequiredException; import io.undertow.server.SSLSessionInfo; import io.undertow.server.handlers.form.FormData; +import io.undertow.server.protocol.http.HttpAttachments; import io.undertow.util.*; public class UndertowContext implements DefaultContext, IoCallback { private static final ByteBuffer EMPTY = ByteBuffer.wrap(new byte[0]); private Route route; HttpServerExchange exchange; + HeaderMap trailers; private Router router; private QueryString query; private Formdata formdata; @@ -331,6 +333,16 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } + @Override + public Context setResponseTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = new HeaderMap(); + exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, trailers); + } + trailers.put(HttpString.tryFromString(name), value); + return this; + } + @NonNull @Override public Context removeResponseHeader(@NonNull String name) { exchange.getResponseHeaders().remove(name); @@ -412,8 +424,8 @@ public OutputStream responseStream() { } @NonNull @Override - public io.jooby.Sender responseSender() { - return new UndertowSender(this, exchange); + public io.jooby.Sender responseSender(boolean startResponse) { + return new UndertowSender(this); } @NonNull @Override diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java new file mode 100644 index 0000000000..4700325fd0 --- /dev/null +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java @@ -0,0 +1,56 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.undertow; + +import java.util.concurrent.Flow; +import java.util.function.Function; + +import io.jooby.Context; +import io.jooby.Router; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; + +public class UndertowGrpcHandler implements HttpHandler { + private final HttpHandler next; + private final Router router; + private final int bufferSize; + private final Function> subscriberFactory; + + public UndertowGrpcHandler( + HttpHandler next, + Router router, + int bufferSize, + Function> subscriberFactory) { + this.next = next; + this.router = router; + this.bufferSize = bufferSize; + this.subscriberFactory = subscriberFactory; + } + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + if (!exchange + .getRequestHeaders() + .get(Headers.CONTENT_TYPE) + .getFirst() + .contains("application/grpc")) { + next.handleRequest(exchange); + } else { + // Prevents Undertow from automatically closing/draining the request + // exchange.setPersistent(true); + + // 2. IMPORTANT: Dispatch to a worker thread so we don't block the IO thread + exchange.dispatch( + () -> { + // Ensure we don't trigger the default draining behavior + var context = new UndertowContext(exchange, router, bufferSize); + var subscriber = subscriberFactory.apply(context); + new UndertowRequestPublisher(exchange).subscribe(subscriber); + }); + } + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java index 9e33f66609..c05ded01d5 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java @@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; +import java.util.function.Function; import io.jooby.*; import io.undertow.io.Receiver; @@ -19,6 +20,7 @@ import io.undertow.util.HeaderMap; import io.undertow.util.Headers; import io.undertow.util.ParameterLimitException; +import io.undertow.util.Protocols; public class UndertowHandler implements HttpHandler { private final long maxRequestSize; @@ -54,9 +56,20 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } else { // possibly HTTP body HeaderMap headers = exchange.getRequestHeaders(); + if (exchange + .getRequestHeaders() + .get(Headers.CONTENT_TYPE) + .getFirst() + .contains("application/grpc")) { + // var route = router.match(context); + // context.setRoute(route.route()); + var subscriber = router.require(ServiceKey.key(Function.class, "gRPC")); + new UndertowGrpcHandler(this, router, bufferSize, subscriber).handleRequest(exchange); + return; + } long len = parseLen(headers.getFirst(Headers.CONTENT_LENGTH)); String chunked = headers.getFirst(Headers.TRANSFER_ENCODING); - if (len > 0 || chunked != null) { + if (len > 0 || chunked != null || exchange.getProtocol().equals(Protocols.HTTP_2_0)) { if (len > maxRequestSize) { Router.Match route = router.match(context); if (route.matches()) { @@ -88,7 +101,11 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { if (len > 0 && len <= bufferSize) { receiver.receiveFullBytes(reader); } else { - receiver.receivePartialBytes(reader); + if (exchange.getProtocol().equals(Protocols.HTTP_2_0)) { + receiver.receiveFullBytes(reader); + } else { + receiver.receivePartialBytes(reader); + } } } else { try { diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java deleted file mode 100644 index 0a2222f6cc..0000000000 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.undertow; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.Iterator; - -import io.jooby.output.Output; -import io.undertow.io.IoCallback; -import io.undertow.io.Sender; -import io.undertow.server.HttpServerExchange; - -public class UndertowOutputCallback implements IoCallback { - - private Iterator iterator; - private IoCallback callback; - - public UndertowOutputCallback(Output buffer, IoCallback callback) { - this.iterator = buffer.iterator(); - this.callback = callback; - } - - public void send(HttpServerExchange exchange) { - exchange.getResponseSender().send(iterator.next(), this); - } - - @Override - public void onComplete(HttpServerExchange exchange, Sender sender) { - if (iterator.hasNext()) { - sender.send(iterator.next(), this); - } else { - callback.onComplete(exchange, sender); - } - } - - @Override - public void onException(HttpServerExchange exchange, Sender sender, IOException exception) { - callback.onException(exchange, sender, exception); - } -} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java new file mode 100644 index 0000000000..fbd6aa4729 --- /dev/null +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java @@ -0,0 +1,88 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.undertow; + +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import io.undertow.server.HttpServerExchange; + +public class UndertowRequestPublisher implements Flow.Publisher { + private final HttpServerExchange exchange; + + public UndertowRequestPublisher(HttpServerExchange exchange) { + this.exchange = exchange; + } + + @Override + public void subscribe(Flow.Subscriber subscriber) { + // We use the Subscription to manage the state between Undertow and the Subscriber + UndertowReceiverSubscription sub = new UndertowReceiverSubscription(exchange, subscriber); + subscriber.onSubscribe(sub); + } +} + +class UndertowReceiverSubscription implements Flow.Subscription { + private final HttpServerExchange exchange; + private final Flow.Subscriber subscriber; + private final AtomicBoolean started = new AtomicBoolean(false); + private final AtomicLong demand = new AtomicLong(0); + private final AtomicBoolean readingStarted = new AtomicBoolean(false); + + public UndertowReceiverSubscription( + HttpServerExchange exchange, Flow.Subscriber subscriber) { + this.exchange = exchange; + this.subscriber = subscriber; + } + + @Override + public void request(long n) { + if (n <= 0) return; + + // Add to our demand counter + long prevDemand = demand.getAndAdd(n); + + // Case 1: First time starting the read + if (readingStarted.compareAndSet(false, true)) { + startReading(); + } + // Case 2: We were paused (demand was 0) and now have new demand + else if (prevDemand == 0) { + exchange.getRequestReceiver().resume(); + } + } + + private void startReading() { + exchange + .getRequestReceiver() + .receivePartialBytes( + (exch, message, last) -> { + if (message.length > 0) { + // Pass bytes to De-framer + subscriber.onNext(message); + } + // If we've exhausted the demand requested by the Bridge, pause Undertow + if (demand.decrementAndGet() == 0) { + exchange.getRequestReceiver().pause(); + } + // THE KEY FIX: + // 1. If 'last' is true, the stream is definitely over. + // 2. If 'isRequestComplete' is true, Undertow's internal state knows it's over. + if (last) { + subscriber.onComplete(); + } + }, + (exch, err) -> { + subscriber.onError(err); + }); + } + + @Override + public void cancel() { + exchange.getRequestReceiver().pause(); + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java index a3844cfc6f..cc8fcac016 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java @@ -13,30 +13,64 @@ import io.jooby.output.Output; import io.undertow.io.IoCallback; import io.undertow.server.HttpServerExchange; +import io.undertow.server.protocol.http.HttpAttachments; +import io.undertow.util.HeaderMap; +import io.undertow.util.HttpString; public class UndertowSender implements Sender { private final UndertowContext ctx; private final HttpServerExchange exchange; + private HeaderMap trailers; - public UndertowSender(UndertowContext ctx, HttpServerExchange exchange) { + public UndertowSender(UndertowContext ctx) { this.ctx = ctx; - this.exchange = exchange; + this.exchange = ctx.exchange; + this.trailers = ctx.trailers; } @Override - public Sender write(@NonNull byte[] data, @NonNull Callback callback) { - exchange.getResponseSender().send(ByteBuffer.wrap(data), newIoCallback(ctx, callback)); + public Sender setTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = new HeaderMap(); + } + trailers.put(HttpString.tryFromString(name), value); return this; } + @Override + public Sender write(@NonNull byte[] data, @NonNull Callback callback) { + return write(ByteBuffer.wrap(data), callback); + } + @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { - new UndertowOutputCallback(output, newIoCallback(ctx, callback)).send(exchange); + return write(output.asByteBuffer(), callback); + } + + private Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { + exchange.getResponseSender().send(buffer, newIoCallback(ctx, callback)); return this; } @Override public void close() { + if (trailers != null) { + exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, this.trailers); + exchange + .getResponseSender() + .send( + "", + new IoCallback() { + @Override + public void onComplete(HttpServerExchange exchange, io.undertow.io.Sender sender) {} + + @Override + public void onException( + HttpServerExchange exchange, + io.undertow.io.Sender sender, + IOException exception) {} + }); + } ctx.destroy(null); } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java index f335a220ed..026c68ec74 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java @@ -154,7 +154,7 @@ public Server start(@NonNull Jooby... application) { builder.addHttpListener(options.getPort(), options.getHost()); } - // HTTP @ + // HTTP/2 builder.setServerOption(ENABLE_HTTP2, options.isHttp2() == Boolean.TRUE); var classLoader = this.applications.get(0).getClassLoader(); SSLContext sslContext = options.getSSLContext(classLoader); diff --git a/modules/pom.xml b/modules/pom.xml index 75f125079c..1e39ee0edc 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -62,6 +62,8 @@ jooby-thymeleaf jooby-camel + jooby-grpc + jooby-avaje-validator jooby-hibernate-validator diff --git a/pom.xml b/pom.xml index 8d87ce3931..d6e177fa61 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,7 @@ 7.0.0 + 1.78.0 1.5.23 diff --git a/tests/pom.xml b/tests/pom.xml index 8cf0181a19..05c3dac7f0 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -111,6 +111,11 @@ jooby-guice ${jooby.version} + + io.jooby + jooby-grpc + ${jooby.version} + io.jooby jooby-pac4j @@ -169,6 +174,30 @@ kotlin-reflect + + io.grpc + grpc-services + ${grpc.version} + + + + io.grpc + grpc-servlet + ${grpc.version} + + + + io.grpc + grpc-netty-shaded + ${grpc.version} + + + + io.grpc + grpc-okhttp + ${grpc.version} + + org.slf4j jcl-over-slf4j @@ -286,7 +315,33 @@ + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:3.25.1:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + + + + + compile + compile-custom + + + + + org.jetbrains.kotlin kotlin-maven-plugin diff --git a/tests/src/main/proto/chat.proto b/tests/src/main/proto/chat.proto new file mode 100644 index 0000000000..9f5f87d317 --- /dev/null +++ b/tests/src/main/proto/chat.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package test; + +option java_package = "com.example.grpc"; +option java_multiple_files = true; + +service ChatService { + // BiDi: Client sends a stream of messages, + // Server responds to each one individually. + rpc ChatStream (stream ChatMessage) returns (stream ChatMessage); +} + +message ChatMessage { + string user = 1; + string text = 2; +} diff --git a/tests/src/main/proto/hello.proto b/tests/src/main/proto/hello.proto new file mode 100644 index 0000000000..6efa27221e --- /dev/null +++ b/tests/src/main/proto/hello.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +option java_package = "com.example.grpc"; +option java_multiple_files = true; + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} diff --git a/tests/src/test/java/examples/grpc/ChatClient.java b/tests/src/test/java/examples/grpc/ChatClient.java new file mode 100644 index 0000000000..2078e0abf5 --- /dev/null +++ b/tests/src/test/java/examples/grpc/ChatClient.java @@ -0,0 +1,81 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import java.util.concurrent.CountDownLatch; + +import com.example.grpc.*; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.StreamObserver; + +public class ChatClient { + public static void main(String[] args) throws InterruptedException { + // 1. Create a channel to your JOOBY server + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", 8080) + .usePlaintext() // Assuming the bridge is HTTP/2 Cleartext + .build(); + + // 2. Create an ASYNC stub (BiDi requires the async stub) + ChatServiceGrpc.ChatServiceStub asyncStub = ChatServiceGrpc.newStub(channel); + + // This latch helps the main thread wait until the stream is fully finished + CountDownLatch latch = new CountDownLatch(3); + + // 3. Define the observer to handle responses coming BACK from the Bridge + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ChatMessage value) { + System.out.println( + "Received from Bridge: [" + value.getUser() + "] " + value.getText()); + latch.countDown(); + } + + @Override + public void onError(Throwable t) { + System.err.println("Bridge Error: " + t.getMessage()); + t.printStackTrace(); + latch.countDown(); + latch.countDown(); + latch.countDown(); + } + + @Override + public void onCompleted() { + System.out.println("Bridge closed the stream (Trailers received successfully)."); + latch.countDown(); + } + }; + + // 4. Start the call. Returns the observer we use to SEND messages TO the Bridge. + StreamObserver requestObserver = asyncStub.chatStream(responseObserver); + + try { + System.out.println("Connecting to Bridge and sending messages..."); + + // 5. Send a stream of messages over time + requestObserver.onNext( + ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 1").build()); + + Thread.sleep(1000); // Simulate network/processing delay + + requestObserver.onNext( + ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 2").build()); + + // 6. Tell the Bridge we are done sending data + requestObserver.onCompleted(); + + } catch (Exception e) { + requestObserver.onError(e); + } + latch.await(); + + // Wait for the server to finish responding (timeout after 10 seconds) + channel.shutdown(); + } +} diff --git a/tests/src/test/java/examples/grpc/ChatServiceImpl.java b/tests/src/test/java/examples/grpc/ChatServiceImpl.java new file mode 100644 index 0000000000..280511763a --- /dev/null +++ b/tests/src/test/java/examples/grpc/ChatServiceImpl.java @@ -0,0 +1,46 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.example.grpc.ChatMessage; +import com.example.grpc.ChatServiceGrpc; +import io.grpc.stub.StreamObserver; + +public class ChatServiceImpl extends ChatServiceGrpc.ChatServiceImplBase { + private final Logger log = LoggerFactory.getLogger(getClass()); + + @Override + public StreamObserver chatStream(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ChatMessage request) { + log.info("Got message: {}", request.getTextBytes()); + // Logic: Echo back the text with a prefix + ChatMessage response = + ChatMessage.newBuilder() + .setUser("Server") + .setText("Echo: " + request.getText()) + .build(); + + responseObserver.onNext(response); + } + + @Override + public void onError(Throwable t) { + System.err.println("Stream error: " + t.getMessage()); + } + + @Override + public void onCompleted() { + log.info("Chat closed"); + responseObserver.onCompleted(); + } + }; + } +} diff --git a/tests/src/test/java/examples/grpc/GreeterService.java b/tests/src/test/java/examples/grpc/GreeterService.java new file mode 100644 index 0000000000..fa6976499c --- /dev/null +++ b/tests/src/test/java/examples/grpc/GreeterService.java @@ -0,0 +1,20 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import com.example.grpc.GreeterGrpc; +import com.example.grpc.HelloReply; +import com.example.grpc.HelloRequest; +import io.grpc.stub.StreamObserver; + +public class GreeterService extends GreeterGrpc.GreeterImplBase { + @Override + public void sayHello(HelloRequest req, StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } +} diff --git a/tests/src/test/java/examples/grpc/GrpcClient.java b/tests/src/test/java/examples/grpc/GrpcClient.java new file mode 100644 index 0000000000..68d0b3c383 --- /dev/null +++ b/tests/src/test/java/examples/grpc/GrpcClient.java @@ -0,0 +1,26 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import com.example.grpc.GreeterGrpc; +import com.example.grpc.HelloReply; +import com.example.grpc.HelloRequest; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; + +public class GrpcClient { + public static void main(String[] args) { + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build(); + + GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel); + + HelloReply response = stub.sayHello(HelloRequest.newBuilder().setName("Edgar").build()); + System.out.println(response.getMessage()); + + channel.shutdown(); + } +} diff --git a/tests/src/test/java/examples/grpc/GrpcServer.java b/tests/src/test/java/examples/grpc/GrpcServer.java new file mode 100644 index 0000000000..82b678a880 --- /dev/null +++ b/tests/src/test/java/examples/grpc/GrpcServer.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import java.io.IOException; +import java.util.List; + +import io.grpc.protobuf.services.ProtoReflectionServiceV1; +import io.jooby.Jooby; +import io.jooby.ServerOptions; +import io.jooby.StartupSummary; +import io.jooby.grpc.GrpcModule; +import io.jooby.handler.AccessLogHandler; +import io.jooby.jetty.JettyServer; + +public class GrpcServer extends Jooby { + + { + setStartupSummary(List.of(StartupSummary.VERBOSE)); + use(new AccessLogHandler()); + install( + new GrpcModule( + new GreeterService(), new ChatServiceImpl(), ProtoReflectionServiceV1.newInstance())); + } + + public static void main(final String[] args) throws InterruptedException, IOException { + runApp( + args, + new JettyServer(new ServerOptions().setSecurePort(8443).setHttp2(true)), + GrpcServer::new); + + // Build the server + // Server server = io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.forPort(9090) + // .addService(new GreeterService()) + // .addService(ProtoReflectionServiceV1.newInstance())// Your generated service + // implementation + // .build(); + // + // // Start the server + // server.start(); + // System.out.println("Server started on port 9090"); + // + // // Keep the main thread alive until the server is shut down + // server.awaitTermination(); + } +} diff --git a/tests/src/test/java/examples/grpc/ReflectionClient.java b/tests/src/test/java/examples/grpc/ReflectionClient.java new file mode 100644 index 0000000000..62219a7ebe --- /dev/null +++ b/tests/src/test/java/examples/grpc/ReflectionClient.java @@ -0,0 +1,68 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import java.util.concurrent.CountDownLatch; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.reflection.v1.ServerReflectionGrpc; +import io.grpc.reflection.v1.ServerReflectionRequest; +import io.grpc.reflection.v1.ServerReflectionResponse; +import io.grpc.stub.StreamObserver; + +public class ReflectionClient { + public static void main(String[] args) throws InterruptedException { + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build(); + + var latch = new CountDownLatch(1); + ServerReflectionGrpc.ServerReflectionStub stub = ServerReflectionGrpc.newStub(channel); + + // 1. Prepare the response observer + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ServerReflectionResponse response) { + // This is the part that returns the list of services + response + .getListServicesResponse() + .getServiceList() + .forEach( + s -> { + System.out.println("Service: " + s.getName()); + }); + } + + @Override + public void onError(Throwable t) { + t.printStackTrace(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + // 2. Open the bidirectional stream + StreamObserver requestObserver = + stub.serverReflectionInfo(responseObserver); + + // 3. Send the "List Services" request + requestObserver.onNext( + ServerReflectionRequest.newBuilder() + .setListServices("") // The trigger for 'list' + .setHost("localhost") + .build()); + + // 4. Signal half-close (Very important for reflection) + requestObserver.onCompleted(); + + latch.await(); + channel.shutdown(); + } +} diff --git a/tests/src/test/java/io/jooby/test/GrpcTest.java b/tests/src/test/java/io/jooby/test/GrpcTest.java new file mode 100644 index 0000000000..838c7c985e --- /dev/null +++ b/tests/src/test/java/io/jooby/test/GrpcTest.java @@ -0,0 +1,55 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.example.grpc.GreeterGrpc; +import com.example.grpc.HelloReply; +import com.example.grpc.HelloRequest; +import io.grpc.stub.StreamObserver; +import io.jooby.ServerOptions; +import io.jooby.grpc.GrpcModule; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; +import okhttp3.*; + +public class GrpcTest { + + public class GreeterService extends GreeterGrpc.GreeterImplBase { + @Override + public void sayHello(HelloRequest req, StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + } + + @ServerTest + public void http2(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define( + app -> { + app.install(new GrpcModule(new GreeterService())); + }) + .ready( + (http, https) -> { + https.get( + "/", + rsp -> { + assertEquals( + "{secure=true, protocol=HTTP/2.0, scheme=https}", rsp.body().string()); + }); + http.get( + "/", + rsp -> { + assertEquals( + "{secure=false, protocol=HTTP/1.1, scheme=http}", rsp.body().string()); + }); + }); + } +} diff --git a/tests/src/test/resources/logback.xml b/tests/src/test/resources/logback.xml index 442413b895..7427344940 100644 --- a/tests/src/test/resources/logback.xml +++ b/tests/src/test/resources/logback.xml @@ -2,7 +2,7 @@ - %-5p [%d{ISO8601}] [%thread] %msg %ex{0}%n + %-5p [%d{ISO8601}] [%thread] %logger{0} %msg %ex{0}%n From 6770ac510ed0c37ac9ed4231c256f1f1091459b1 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 15 Jan 2026 13:48:19 -0300 Subject: [PATCH 02/11] WIP: gRpc: works for jetty, almost work for undertow - grpcurl list doesn't work for undertow (sadly) --- dump.txt | 423 ------------------ .../java/io/jooby/grpc/GrpcRequestBridge.java | 1 + .../java/io/jooby/grpc/UnifiedGrpcBridge.java | 139 +++--- .../internal/jetty/JettyRequestPublisher.java | 1 - .../io/jooby/internal/jetty/JettySender.java | 41 +- .../internal/undertow/UndertowContext.java | 2 +- .../internal/undertow/UndertowHandler.java | 2 - .../undertow/UndertowRequestPublisher.java | 213 ++++++++- .../internal/undertow/UndertowSender.java | 38 +- .../test/java/examples/grpc/GrpcServer.java | 21 +- .../java/examples/grpc/JettyTrailerTest.java | 255 +++++++++++ .../java/examples/grpc/ReflectionClient.java | 80 ++-- 12 files changed, 623 insertions(+), 593 deletions(-) create mode 100644 tests/src/test/java/examples/grpc/JettyTrailerTest.java diff --git a/dump.txt b/dump.txt index 1fb5ca0768..e69de29bb2 100644 --- a/dump.txt +++ b/dump.txt @@ -1,423 +0,0 @@ -2026-01-02 19:02:09 -Full thread dump OpenJDK 64-Bit Server VM (24.0.2+12 mixed mode, sharing): - -Threads class SMR info: -_java_thread_list=0x0000600001982ce0, length=31, elements={ -0x000000014a00e200, 0x000000014a012000, 0x000000014a012800, 0x000000014a013000, -0x000000014a013800, 0x000000014a014000, 0x000000014a01c800, 0x000000014981d600, -0x000000014a122e00, 0x000000014a123600, 0x000000014a9e8800, 0x000000014aa54e00, -0x000000014aa55600, 0x000000014a9e9000, 0x00000001038d6000, 0x000000014c059c00, -0x000000014c057800, 0x000000014c058000, 0x000000012fb59200, 0x000000014a1f7000, -0x000000012fb5bc00, 0x000000013f810200, 0x000000013f819400, 0x000000012f1d5800, -0x000000012f1d6000, 0x000000013e998400, 0x000000014a1f7800, 0x000000014aa71e00, -0x000000012e03be00, 0x000000013e875000, 0x000000012e045800 -} - -"Reference Handler" #15 [29443] daemon prio=10 os_prio=31 cpu=0.26ms elapsed=24.14s tid=0x000000014a00e200 nid=29443 waiting on condition [0x000000016e5c2000] - java.lang.Thread.State: RUNNABLE - at java.lang.ref.Reference.waitForReferencePendingList(java.base@24.0.2/Native Method) - at java.lang.ref.Reference.processPendingReferences(java.base@24.0.2/Reference.java:246) - at java.lang.ref.Reference$ReferenceHandler.run(java.base@24.0.2/Reference.java:208) - - Locked ownable synchronizers: - - None - -"Finalizer" #16 [24835] daemon prio=8 os_prio=31 cpu=0.05ms elapsed=24.14s tid=0x000000014a012000 nid=24835 in Object.wait() [0x000000016e7ce000] - java.lang.Thread.State: WAITING (on object monitor) - at java.lang.Object.wait0(java.base@24.0.2/Native Method) - - waiting on <0x000000052b00cd30> (a java.lang.ref.ReferenceQueue$Lock) - at java.lang.Object.wait(java.base@24.0.2/Object.java:389) - at java.lang.Object.wait(java.base@24.0.2/Object.java:351) - at java.lang.ref.ReferenceQueue.remove0(java.base@24.0.2/ReferenceQueue.java:138) - at java.lang.ref.ReferenceQueue.remove(java.base@24.0.2/ReferenceQueue.java:229) - - locked <0x000000052b00cd30> (a java.lang.ref.ReferenceQueue$Lock) - at java.lang.ref.Finalizer$FinalizerThread.run(java.base@24.0.2/Finalizer.java:165) - - Locked ownable synchronizers: - - None - -"Signal Dispatcher" #17 [29187] daemon prio=9 os_prio=31 cpu=0.12ms elapsed=24.14s tid=0x000000014a012800 nid=29187 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"Service Thread" #18 [25603] daemon prio=9 os_prio=31 cpu=0.84ms elapsed=24.14s tid=0x000000014a013000 nid=25603 runnable [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"Monitor Deflation Thread" #19 [26115] daemon prio=9 os_prio=31 cpu=2.54ms elapsed=24.14s tid=0x000000014a013800 nid=26115 runnable [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"C2 CompilerThread0" #20 [28931] daemon prio=9 os_prio=31 cpu=208.92ms elapsed=24.14s tid=0x000000014a014000 nid=28931 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - No compile task - - Locked ownable synchronizers: - - None - -"C1 CompilerThread0" #28 [26627] daemon prio=9 os_prio=31 cpu=100.30ms elapsed=24.14s tid=0x000000014a01c800 nid=26627 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - No compile task - - Locked ownable synchronizers: - - None - -"Common-Cleaner" #32 [27395] daemon prio=8 os_prio=31 cpu=0.04ms elapsed=24.12s tid=0x000000014981d600 nid=27395 in Object.wait() [0x000000016f82e000] - java.lang.Thread.State: TIMED_WAITING (on object monitor) - at java.lang.Object.wait0(java.base@24.0.2/Native Method) - - waiting on <0x000000052b0199e8> (a java.lang.ref.ReferenceQueue$Lock) - at java.lang.Object.wait(java.base@24.0.2/Object.java:389) - at java.lang.ref.ReferenceQueue.remove0(java.base@24.0.2/ReferenceQueue.java:124) - at java.lang.ref.ReferenceQueue.remove(java.base@24.0.2/ReferenceQueue.java:215) - - locked <0x000000052b0199e8> (a java.lang.ref.ReferenceQueue$Lock) - at jdk.internal.ref.CleanerImpl.run(java.base@24.0.2/CleanerImpl.java:140) - at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) - at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) - at jdk.internal.misc.InnocuousThread.run(java.base@24.0.2/InnocuousThread.java:148) - - Locked ownable synchronizers: - - None - -"Monitor Ctrl-Break" #33 [43011] daemon prio=5 os_prio=31 cpu=8.19ms elapsed=24.08s tid=0x000000014a122e00 nid=43011 runnable [0x000000016fc46000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.SocketDispatcher.read0(java.base@24.0.2/Native Method) - at sun.nio.ch.SocketDispatcher.read(java.base@24.0.2/SocketDispatcher.java:47) - at sun.nio.ch.NioSocketImpl.tryRead(java.base@24.0.2/NioSocketImpl.java:255) - at sun.nio.ch.NioSocketImpl.implRead(java.base@24.0.2/NioSocketImpl.java:306) - at sun.nio.ch.NioSocketImpl.read(java.base@24.0.2/NioSocketImpl.java:345) - at sun.nio.ch.NioSocketImpl$1.read(java.base@24.0.2/NioSocketImpl.java:790) - at java.net.Socket$SocketInputStream.implRead(java.base@24.0.2/Socket.java:983) - at java.net.Socket$SocketInputStream.read(java.base@24.0.2/Socket.java:970) - at sun.nio.cs.StreamDecoder.readBytes(java.base@24.0.2/StreamDecoder.java:279) - at sun.nio.cs.StreamDecoder.implRead(java.base@24.0.2/StreamDecoder.java:322) - at sun.nio.cs.StreamDecoder.read(java.base@24.0.2/StreamDecoder.java:186) - - locked <0x000000052b0266a0> (a java.io.InputStreamReader) - at java.io.InputStreamReader.read(java.base@24.0.2/InputStreamReader.java:175) - at java.io.BufferedReader.fill(java.base@24.0.2/BufferedReader.java:166) - at java.io.BufferedReader.readLine(java.base@24.0.2/BufferedReader.java:333) - - locked <0x000000052b0266a0> (a java.io.InputStreamReader) - at java.io.BufferedReader.readLine(java.base@24.0.2/BufferedReader.java:400) - at com.intellij.rt.execution.application.AppMainV2$1.run(AppMainV2.java:31) - - Locked ownable synchronizers: - - <0x000000052b3d2888> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) - -"Notification Thread" #34 [42499] daemon prio=9 os_prio=31 cpu=0.01ms elapsed=24.08s tid=0x000000014a123600 nid=42499 runnable [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"worker I/O-1" #49 [38915] prio=5 os_prio=31 cpu=0.35ms elapsed=23.78s tid=0x000000014a9e8800 nid=38915 runnable [0x00000003220ba000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b0333c8> (a sun.nio.ch.Util$2) - - locked <0x000000052b033370> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-2" #50 [38403] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x000000014aa54e00 nid=38403 runnable [0x00000003222c6000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b040080> (a sun.nio.ch.Util$2) - - locked <0x000000052b040028> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-3" #51 [37891] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x000000014aa55600 nid=37891 runnable [0x00000003224d2000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b04cd38> (a sun.nio.ch.Util$2) - - locked <0x000000052b04cce0> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-4" #52 [43523] prio=5 os_prio=31 cpu=0.24ms elapsed=23.78s tid=0x000000014a9e9000 nid=43523 runnable [0x00000003226de000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b0599f0> (a sun.nio.ch.Util$2) - - locked <0x000000052b059998> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-5" #53 [44035] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x00000001038d6000 nid=44035 runnable [0x00000003228ea000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b073360> (a sun.nio.ch.Util$2) - - locked <0x000000052b073308> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-6" #54 [44291] prio=5 os_prio=31 cpu=0.25ms elapsed=23.78s tid=0x000000014c059c00 nid=44291 runnable [0x0000000322af6000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b0666a8> (a sun.nio.ch.Util$2) - - locked <0x000000052b066650> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-7" #55 [64771] prio=5 os_prio=31 cpu=0.23ms elapsed=23.78s tid=0x000000014c057800 nid=64771 runnable [0x0000000322d02000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b000158> (a sun.nio.ch.Util$2) - - locked <0x000000052b000100> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-8" #56 [44803] prio=5 os_prio=31 cpu=28.09ms elapsed=23.78s tid=0x000000014c058000 nid=44803 runnable [0x0000000322f0e000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b080018> (a sun.nio.ch.Util$2) - - locked <0x000000052b07ffc0> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:142) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:563) - - Locked ownable synchronizers: - - None - -"worker I/O-9" #57 [64515] prio=5 os_prio=31 cpu=0.04ms elapsed=23.78s tid=0x000000012fb59200 nid=64515 runnable [0x000000032311a000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b08ccd0> (a sun.nio.ch.Util$2) - - locked <0x000000052b08cc78> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-10" #58 [45571] prio=5 os_prio=31 cpu=0.05ms elapsed=23.78s tid=0x000000014a1f7000 nid=45571 runnable [0x0000000323326000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b019b18> (a sun.nio.ch.Util$2) - - locked <0x000000052b019ac0> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-11" #59 [64003] prio=5 os_prio=31 cpu=0.12ms elapsed=23.78s tid=0x000000012fb5bc00 nid=64003 runnable [0x0000000323532000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b00ce58> (a sun.nio.ch.Util$2) - - locked <0x000000052b00ce00> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-12" #60 [46083] prio=5 os_prio=31 cpu=0.06ms elapsed=23.78s tid=0x000000013f810200 nid=46083 runnable [0x000000032373e000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b02cd58> (a sun.nio.ch.Util$2) - - locked <0x000000052b02cd00> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-13" #61 [46339] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000013f819400 nid=46339 runnable [0x000000032394a000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b099988> (a sun.nio.ch.Util$2) - - locked <0x000000052b099930> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-14" #62 [63235] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000012f1d5800 nid=63235 runnable [0x0000000323b56000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b059b70> (a sun.nio.ch.Util$2) - - locked <0x000000052b059b18> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-15" #63 [46595] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000012f1d6000 nid=46595 runnable [0x0000000323d62000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b033548> (a sun.nio.ch.Util$2) - - locked <0x000000052b0334f0> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-16" #64 [47107] prio=5 os_prio=31 cpu=0.04ms elapsed=23.78s tid=0x000000013e998400 nid=47107 runnable [0x0000000323f6e000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b02ced8> (a sun.nio.ch.Util$2) - - locked <0x000000052b02ce80> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker Accept" #65 [62467] prio=5 os_prio=31 cpu=4.29ms elapsed=23.78s tid=0x000000014a1f7800 nid=62467 runnable [0x000000032417a000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b019d30> (a sun.nio.ch.Util$2) - - locked <0x000000052b019cd8> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"DestroyJavaVM" #67 [5635] prio=5 os_prio=31 cpu=510.86ms elapsed=23.63s tid=0x000000014aa71e00 nid=5635 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"worker task-1" #68 [27911] prio=5 os_prio=31 cpu=70.58ms elapsed=16.81s tid=0x000000012e03be00 nid=27911 waiting on condition [0x000000016f622000] - java.lang.Thread.State: TIMED_WAITING (parking) - at jdk.internal.misc.Unsafe.park(java.base@24.0.2/Native Method) - - parking to wait for <0x000000052b3de1c8> (a org.jboss.threads.EnhancedQueueExecutor) - at java.util.concurrent.locks.LockSupport.parkNanos(java.base@24.0.2/LockSupport.java:271) - at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1421) - at org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282) - at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) - at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) - - Locked ownable synchronizers: - - None - -"grpc-timer-0" #69 [27143] daemon prio=5 os_prio=31 cpu=0.12ms elapsed=16.81s tid=0x000000013e875000 nid=27143 waiting on condition [0x000000016fa3a000] - java.lang.Thread.State: TIMED_WAITING (parking) - at jdk.internal.misc.Unsafe.park(java.base@24.0.2/Native Method) - - parking to wait for <0x000000052b059c88> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) - at java.util.concurrent.locks.LockSupport.parkNanos(java.base@24.0.2/LockSupport.java:271) - at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@24.0.2/AbstractQueuedSynchronizer.java:1802) - at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@24.0.2/ScheduledThreadPoolExecutor.java:1166) - at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@24.0.2/ScheduledThreadPoolExecutor.java:883) - at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@24.0.2/ThreadPoolExecutor.java:1021) - at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@24.0.2/ThreadPoolExecutor.java:1081) - at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@24.0.2/ThreadPoolExecutor.java:619) - at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) - at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) - - Locked ownable synchronizers: - - None - -"Attach Listener" #72 [41995] daemon prio=9 os_prio=31 cpu=0.34ms elapsed=0.11s tid=0x000000012e045800 nid=41995 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"G1 Conc#2" os_prio=31 cpu=0.96ms elapsed=16.77s tid=0x000000014b1148c0 nid=34055 runnable - -"G1 Conc#1" os_prio=31 cpu=2.36ms elapsed=16.77s tid=0x0000000103616eb0 nid=33799 runnable - -"GC Thread#12" os_prio=31 cpu=2.93ms elapsed=23.83s tid=0x000000014971c4b0 nid=37379 runnable - -"GC Thread#11" os_prio=31 cpu=3.12ms elapsed=23.83s tid=0x000000014971bf30 nid=36867 runnable - -"GC Thread#10" os_prio=31 cpu=2.92ms elapsed=23.83s tid=0x000000014971b9b0 nid=36611 runnable - -"GC Thread#9" os_prio=31 cpu=2.97ms elapsed=23.83s tid=0x000000014971b430 nid=36355 runnable - -"GC Thread#8" os_prio=31 cpu=3.12ms elapsed=23.83s tid=0x000000014971aeb0 nid=36099 runnable - -"GC Thread#7" os_prio=31 cpu=3.09ms elapsed=23.83s tid=0x000000014971a930 nid=40451 runnable - -"GC Thread#6" os_prio=31 cpu=3.04ms elapsed=23.83s tid=0x000000014971a3b0 nid=35587 runnable - -"GC Thread#5" os_prio=31 cpu=2.95ms elapsed=23.83s tid=0x0000000149719e30 nid=35075 runnable - -"GC Thread#4" os_prio=31 cpu=2.86ms elapsed=23.83s tid=0x00000001497198b0 nid=34819 runnable - -"GC Thread#3" os_prio=31 cpu=3.13ms elapsed=23.83s tid=0x000000014b01c6a0 nid=41219 runnable - -"GC Thread#2" os_prio=31 cpu=3.11ms elapsed=23.83s tid=0x0000000149719330 nid=41731 runnable - -"GC Thread#1" os_prio=31 cpu=3.06ms elapsed=23.83s tid=0x000000012e812cd0 nid=34307 runnable - -"VM Thread" os_prio=31 cpu=5.25ms elapsed=24.15s tid=0x000000012df04560 nid=19715 runnable - -"VM Periodic Task Thread" os_prio=31 cpu=13.48ms elapsed=24.15s tid=0x00000001497086d0 nid=20743 waiting on condition - -"G1 Service" os_prio=31 cpu=1.69ms elapsed=24.15s tid=0x00000001497065d0 nid=21251 runnable - -"G1 Refine#0" os_prio=31 cpu=0.02ms elapsed=24.15s tid=0x000000014b860600 nid=16643 runnable - -"G1 Conc#0" os_prio=31 cpu=1.46ms elapsed=24.15s tid=0x0000000149705e30 nid=13827 runnable - -"G1 Main Marker" os_prio=31 cpu=0.12ms elapsed=24.15s tid=0x000000014b107a60 nid=13315 runnable - -"GC Thread#0" os_prio=31 cpu=3.00ms elapsed=24.15s tid=0x000000014b1072b0 nid=13059 runnable - -JNI global refs: 23, weak refs: 0 - diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java index 074a147a81..377b59e37b 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java @@ -55,6 +55,7 @@ public void onNext(byte[] item) { log.info("asking for more request(1)"); internalObserver.request(1); + // subscription.request(1); } catch (Throwable t) { subscription.cancel(); internalObserver.onError(t); diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java index 9a5b4178d0..9611bb4dee 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java @@ -71,7 +71,7 @@ public StreamObserver startCall(Context ctx) { var descriptor = methodRegistry.get(path.substring(1)); if (descriptor == null) { terminateWithStatus( - null, + ctx, Status.UNIMPLEMENTED.withDescription("Method not found in bridge registry: " + path)); return null; } @@ -90,94 +90,55 @@ public StreamObserver startCall(Context ctx) { ClientResponseObserver responseObserver; log.info("method type: {}", method.getType()); - if (method.getType() == MethodDescriptor.MethodType.UNARY) { - // Atomic guard to prevent multiple terminal calls - var isFinished = new AtomicBoolean(false); - // 3. Unified Response Observer (Handles data coming BACK from the server) - responseObserver = - new ClientResponseObserver<>() { - @Override - public void beforeStart(ClientCallStreamObserver requestStream) { - requestStream.disableAutoInboundFlowControl(); - } - - @Override - public void onNext(byte[] value) { - if (isFinished.get()) return; - log.info("onNext Send {}", HexFormat.of().formatHex(value)); - - // Professional Framing: 5-byte header + payload - ctx.setResponseTrailer("grpc-status", "0"); - byte[] framed = addGrpcHeader(value); - ctx.send(framed); - } - - @Override - public void onError(Throwable t) { - if (isFinished.compareAndSet(false, true)) { - log.info(" error", t); - terminateWithStatus(ctx, Status.fromThrowable(t)); - } - } - - @Override - public void onCompleted() { - if (isFinished.compareAndSet(false, true)) { - log.info("onCompleted"); - terminateWithStatus(ctx, Status.OK); - } - } - }; - } else { - var sender = ctx.responseSender(false); - // Atomic guard to prevent multiple terminal calls - var isFinished = new AtomicBoolean(false); - // 3. Unified Response Observer (Handles data coming BACK from the server) - responseObserver = - new ClientResponseObserver<>() { - @Override - public void beforeStart(ClientCallStreamObserver requestStream) { - requestStream.disableAutoInboundFlowControl(); - } - - @Override - public void onNext(byte[] value) { - if (isFinished.get()) return; - log.info("onNext Send {}", HexFormat.of().formatHex(value)); - - // Professional Framing: 5-byte header + payload - sender.setTrailer("grpc-status", "0"); - byte[] framed = addGrpcHeader(value); - sender.write( - framed, - new Sender.Callback() { - @Override - public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) { - log.info("onNext Sent {}", ctx); - if (cause != null) { - onError(cause); - } + var sender = ctx.responseSender(false); + // Atomic guard to prevent multiple terminal calls + var isFinished = new AtomicBoolean(false); + sender.setTrailer("grpc-status", "0"); + // 3. Unified Response Observer (Handles data coming BACK from the server) + responseObserver = + new ClientResponseObserver<>() { + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + requestStream.disableAutoInboundFlowControl(); + } + + @Override + public void onNext(byte[] value) { + if (isFinished.get()) return; + log.info("onNext Send {}", HexFormat.of().formatHex(value)); + + // Professional Framing: 5-byte header + payload + + byte[] framed = addGrpcHeader(value); + sender.write( + framed, + new Sender.Callback() { + @Override + public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) { + log.info("onNext Sent {}", ctx); + if (cause != null) { + onError(cause); } - }); - } - - @Override - public void onError(Throwable t) { - if (isFinished.compareAndSet(false, true)) { - log.info(" error", t); - terminateWithStatus(ctx, Status.fromThrowable(t)); - } + } + }); + } + + @Override + public void onError(Throwable t) { + if (isFinished.compareAndSet(false, true)) { + log.info(" error", t); + terminateWithStatus(sender, Status.fromThrowable(t)); } + } - @Override - public void onCompleted() { - if (isFinished.compareAndSet(false, true)) { - log.info("onCompleted"); - terminateWithStatus(ctx, Status.OK); - } + @Override + public void onCompleted() { + if (isFinished.compareAndSet(false, true)) { + log.info("onCompleted"); + terminateWithStatus(sender, Status.OK); } - }; - } + } + }; // 4. Map gRPC Method Type to the correct ClientCalls utility return switch (method.getType()) { @@ -228,6 +189,14 @@ public void onCompleted() { }; } + private void terminateWithStatus(Sender ctx, Status status) { + ctx.setTrailer("grpc-status", String.valueOf(status.getCode().value())); + if (status.getDescription() != null) { + ctx.setTrailer("grpc-message", status.getDescription()); + } + ctx.close(); + } + /** * Professional Status Termination. Sets gRPC trailers and closes the Jetty response correctly. */ diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java index 6cffb42bde..97228e26bd 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java @@ -87,7 +87,6 @@ private void process(String call) { buffer.get(bytes); log.info("{}- byte read: {}", call, HexFormat.of().formatHex(bytes)); - // demand.decrementAndGet(); subscriber.onNext(bytes); } chunk.release(); diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java index fa55675039..a3c38b84fa 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java @@ -48,7 +48,25 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { } public Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { - response.write(false, buffer, toJettyCallback(ctx, callback)); + if (trailers != null) { + var copy = HttpFields.build(trailers); + response.setTrailersSupplier(() -> copy); + this.trailers = null; + } + response.write( + false, + buffer, + new org.eclipse.jetty.util.Callback() { + @Override + public void succeeded() { + org.eclipse.jetty.util.Callback.super.succeeded(); + } + + @Override + public void failed(Throwable x) { + org.eclipse.jetty.util.Callback.super.failed(x); + } + }); // if (trailers == null) { // response.write(false, buffer, toJettyCallback(ctx, callback)); // } else { @@ -63,10 +81,23 @@ public Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { @Override public void close() { - if (trailers != null) { - response.setTrailersSupplier(() -> trailers); - response.write(true, null, ctx); - } + // if (trailers != null) { + // response.setTrailersSupplier(() -> trailers); + // response.write(true, null, new org.eclipse.jetty.util.Callback() { + // @Override + // public void succeeded() { + // System.out.println("Succeed"); + // } + // + // @Override + // public void failed(Throwable throwable) { + // System.out.println("Failed"); + // throwable.printStackTrace(); + // } + // }); + // } else { + response.write(true, null, ctx); + // } // if (pending != null) { // response.setTrailersSupplier(() -> trailers); // response.write(true, pending, ctx); diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java index 02cfb85d4a..46b2134c65 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java @@ -487,7 +487,7 @@ public Context send(@NonNull ByteBuffer[] data) { public Context send(@NonNull ByteBuffer data) { ifUnDispatch(data); exchange.setResponseContentLength(data.remaining()); - exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, Long.toString(data.remaining())); + // exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, Long.toString(data.remaining())); exchange.getResponseSender().send(data, this); return this; } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java index c05ded01d5..04460ad7c0 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java @@ -61,8 +61,6 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { .get(Headers.CONTENT_TYPE) .getFirst() .contains("application/grpc")) { - // var route = router.match(context); - // context.setRoute(route.route()); var subscriber = router.require(ServiceKey.key(Function.class, "gRPC")); new UndertowGrpcHandler(this, router, bufferSize, subscriber).handleRequest(exchange); return; diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java index fbd6aa4729..847e88fcab 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java @@ -5,13 +5,35 @@ */ package io.jooby.internal.undertow; +import static io.undertow.io.IoCallback.END_EXCHANGE; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.HexFormat; import java.util.concurrent.Flow; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xnio.channels.StreamSourceChannel; + +import io.undertow.UndertowLogger; +import io.undertow.UndertowMessages; +import io.undertow.connector.PooledByteBuffer; +import io.undertow.io.Receiver; +import io.undertow.server.Connectors; import io.undertow.server.HttpServerExchange; +import io.undertow.util.AttachmentKey; +import io.undertow.util.Headers; +import io.undertow.util.StatusCodes; public class UndertowRequestPublisher implements Flow.Publisher { + + public static final AttachmentKey REQUEST_CHANNEL = + AttachmentKey.create(StreamSourceChannel.class); + private final HttpServerExchange exchange; public UndertowRequestPublisher(HttpServerExchange exchange) { @@ -27,11 +49,13 @@ public void subscribe(Flow.Subscriber subscriber) { } class UndertowReceiverSubscription implements Flow.Subscription { + private static final Logger log = LoggerFactory.getLogger(UndertowReceiverSubscription.class); private final HttpServerExchange exchange; private final Flow.Subscriber subscriber; private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicLong demand = new AtomicLong(0); private final AtomicBoolean readingStarted = new AtomicBoolean(false); + private UndertowReceiver receiver; public UndertowReceiverSubscription( HttpServerExchange exchange, Flow.Subscriber subscriber) { @@ -42,37 +66,35 @@ public UndertowReceiverSubscription( @Override public void request(long n) { if (n <= 0) return; - - // Add to our demand counter - long prevDemand = demand.getAndAdd(n); - - // Case 1: First time starting the read - if (readingStarted.compareAndSet(false, true)) { - startReading(); - } - // Case 2: We were paused (demand was 0) and now have new demand - else if (prevDemand == 0) { - exchange.getRequestReceiver().resume(); - } + log.info("init request({})", n); + // if (receiver == null) { + // receiver = new UndertowReceiver(exchange, () -> {}); + process(); + // } else { + // receiver.resume(); + // } } - private void startReading() { + private void process() { + var call = new AtomicInteger(0); + + // var receiver = exchange.getRequestReceiver(); exchange .getRequestReceiver() .receivePartialBytes( (exch, message, last) -> { + call.incrementAndGet(); + log.info("{}- byte len: {}", call, message.length); if (message.length > 0) { // Pass bytes to De-framer + log.info("{}- byte read: {}", call, HexFormat.of().formatHex(message)); subscriber.onNext(message); } - // If we've exhausted the demand requested by the Bridge, pause Undertow - if (demand.decrementAndGet() == 0) { - exchange.getRequestReceiver().pause(); - } // THE KEY FIX: // 1. If 'last' is true, the stream is definitely over. // 2. If 'isRequestComplete' is true, Undertow's internal state knows it's over. if (last) { + log.info("{}- last reach", call); subscriber.onComplete(); } }, @@ -86,3 +108,160 @@ public void cancel() { exchange.getRequestReceiver().pause(); } } + +class UndertowReceiver { + private final Logger log = LoggerFactory.getLogger(UndertowReceiver.class); + private final HttpServerExchange exchange; + private final StreamSourceChannel channel; + private final Runnable runnable; + private int maxBufferSize = -1; + private boolean paused = false; + private boolean done = false; + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + private static final Receiver.ErrorCallback END_EXCHANGE = + new Receiver.ErrorCallback() { + @Override + public void error(HttpServerExchange exchange, IOException e) { + e.printStackTrace(); + exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR); + UndertowLogger.REQUEST_IO_LOGGER.ioException(e); + exchange.endExchange(); + } + }; + + public UndertowReceiver(HttpServerExchange exchange, Runnable runnable) { + this.exchange = exchange; + this.channel = exchange.getRequestChannel(); + exchange.putAttachment(UndertowRequestPublisher.REQUEST_CHANNEL, this.channel); + this.runnable = runnable; + } + + public void receivePartialBytes( + final Receiver.PartialBytesCallback callback, final Receiver.ErrorCallback errorCallback) { + if (done) { + throw UndertowMessages.MESSAGES.requestBodyAlreadyRead(); + } + final Receiver.ErrorCallback error = errorCallback == null ? END_EXCHANGE : errorCallback; + if (callback == null) { + throw UndertowMessages.MESSAGES.argumentCannotBeNull("callback"); + } + if (exchange.isRequestComplete()) { + log.info("request complete"); + callback.handle(exchange, EMPTY_BYTE_ARRAY, true); + return; + } + String contentLengthString = exchange.getRequestHeaders().getFirst(Headers.CONTENT_LENGTH); + if (contentLengthString == null) { + contentLengthString = exchange.getRequestHeaders().getFirst(Headers.X_CONTENT_LENGTH); + } + long contentLength; + if (contentLengthString != null) { + contentLength = Long.parseLong(contentLengthString); + if (contentLength > Integer.MAX_VALUE) { + error.error(exchange, new Receiver.RequestToLargeException()); + return; + } + } else { + contentLength = -1; + } + if (maxBufferSize > 0) { + if (contentLength > maxBufferSize) { + error.error(exchange, new Receiver.RequestToLargeException()); + return; + } + } + PooledByteBuffer pooled = exchange.getConnection().getByteBufferPool().allocate(); + final ByteBuffer buffer = pooled.getBuffer(); + + channel + .getReadSetter() + .set( + channel -> { + if (done || paused) { + log.info("request done: {} or paused: {}", done, paused); + return; + } + PooledByteBuffer pooled1 = exchange.getConnection().getByteBufferPool().allocate(); + final ByteBuffer buffer1 = pooled1.getBuffer(); + try { + int res2; + do { + if (paused) { + return; + } + try { + buffer1.clear(); + res2 = channel.read(buffer1); + if (res2 == -1) { + done = true; + log.info("INSIDE request read done: {} ", res2); + Connectors.executeRootHandler( + exchange -> callback.handle(exchange, EMPTY_BYTE_ARRAY, true), exchange); + return; + } else if (res2 == 0) { + log.info("INSIDE resume reads: {}", res2); + // channel.resumeReads(); + return; + } else { + buffer1.flip(); + final byte[] data = new byte[buffer1.remaining()]; + buffer1.get(data); + + Connectors.executeRootHandler( + exchange -> { + callback.handle(exchange, data, false); + channel.resumeReads(); + }, + exchange); + } + } catch (final IOException e) { + log.info("INSIDE error reading from {}", exchange, e); + Connectors.executeRootHandler(exchange -> error.error(exchange, e), exchange); + return; + } + } while (true); + } finally { + pooled1.close(); + } + }); + + try { + int res; + do { + try { + buffer.clear(); + res = channel.read(buffer); + if (res == -1) { + log.info("request read out-of listener: {} ", res); + done = true; + callback.handle(exchange, EMPTY_BYTE_ARRAY, true); + return; + } else if (res == 0) { + log.info("request resume reads out-of listener: {} ", res); + channel.resumeReads(); + return; + } else { + buffer.flip(); + byte[] data = new byte[buffer.remaining()]; + buffer.get(data); + log.info("request read done out-of listener: {} ", res); + callback.handle(exchange, data, false); + if (paused) { + return; + } + } + } catch (IOException e) { + error.error(exchange, e); + return; + } + } while (true); + } finally { + log.info("channel open: {} ", channel.isOpen()); + pooled.close(); + } + } + + public void resume() { + channel.wakeupReads(); + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java index cc8fcac016..2de98bc591 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java @@ -48,29 +48,31 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { } private Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { - exchange.getResponseSender().send(buffer, newIoCallback(ctx, callback)); + if (trailers != null) { + var copy = new HeaderMap(); + copy.putAll(trailers); + trailers = null; + exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, copy); + } + exchange + .getResponseSender() + .send( + buffer, + new IoCallback() { + @Override + public void onComplete(HttpServerExchange exchange, io.undertow.io.Sender sender) {} + + @Override + public void onException( + HttpServerExchange exchange, + io.undertow.io.Sender sender, + IOException exception) {} + }); return this; } @Override public void close() { - if (trailers != null) { - exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, this.trailers); - exchange - .getResponseSender() - .send( - "", - new IoCallback() { - @Override - public void onComplete(HttpServerExchange exchange, io.undertow.io.Sender sender) {} - - @Override - public void onException( - HttpServerExchange exchange, - io.undertow.io.Sender sender, - IOException exception) {} - }); - } ctx.destroy(null); } diff --git a/tests/src/test/java/examples/grpc/GrpcServer.java b/tests/src/test/java/examples/grpc/GrpcServer.java index 82b678a880..827506bb48 100644 --- a/tests/src/test/java/examples/grpc/GrpcServer.java +++ b/tests/src/test/java/examples/grpc/GrpcServer.java @@ -14,7 +14,7 @@ import io.jooby.StartupSummary; import io.jooby.grpc.GrpcModule; import io.jooby.handler.AccessLogHandler; -import io.jooby.jetty.JettyServer; +import io.jooby.undertow.UndertowServer; public class GrpcServer extends Jooby { @@ -26,10 +26,27 @@ public class GrpcServer extends Jooby { new GreeterService(), new ChatServiceImpl(), ProtoReflectionServiceV1.newInstance())); } + // INFO [2026-01-15 10:19:29,307] [worker-55] UnifiedGrpcBridge method type: BIDI_STREAMING + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription init request(1) + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- start reading request + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- byte read: 00000000033a012a + // INFO [2026-01-15 10:19:29,308] [worker-55] GrpcRequestBridge deframe 3a012a + // INFO [2026-01-15 10:19:29,308] [worker-55] UnifiedGrpcBridge onNext Send + // 12033a012a32460a120a10746573742e43686174536572766963650a250a23677270632e7265666c656374696f6e2e76312e5365727665725265666c656374696f6e0a090a0747726565746572 + // INFO [2026-01-15 10:19:29,308] [worker-55] GrpcRequestBridge asking for more request(1) + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- demanding more + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- finish reading request + // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- start reading request + // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- last reach + // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription handle complete + // INFO [2026-01-15 10:19:29,309] [worker-52] UnifiedGrpcBridge onCompleted + // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- finish reading request + // INFO [2026-01-15 10:20:08,267] [Thread-0] GrpcServer Stopped GrpcServer + public static void main(final String[] args) throws InterruptedException, IOException { runApp( args, - new JettyServer(new ServerOptions().setSecurePort(8443).setHttp2(true)), + new UndertowServer(new ServerOptions().setSecurePort(8443).setHttp2(true)), GrpcServer::new); // Build the server diff --git a/tests/src/test/java/examples/grpc/JettyTrailerTest.java b/tests/src/test/java/examples/grpc/JettyTrailerTest.java new file mode 100644 index 0000000000..7d149bc6ab --- /dev/null +++ b/tests/src/test/java/examples/grpc/JettyTrailerTest.java @@ -0,0 +1,255 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import static org.junit.jupiter.api.Assertions.fail; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jetty.http.*; +import org.eclipse.jetty.http2.api.Session; +import org.eclipse.jetty.http2.api.Stream; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.frames.DataFrame; +import org.eclipse.jetty.http2.frames.HeadersFrame; +import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.server.*; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Promise; +import org.junit.jupiter.api.Test; + +public class JettyTrailerTest { + + @Test + public void testEmptyWriteWithTrailersCausesUnexpectedEOS() throws Exception { + // 1. Setup Server + Server server = new Server(); + HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(); + ServerConnector connector = new ServerConnector(server, h2); + connector.setPort(0); + server.addConnector(connector); + + server.setHandler( + new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + // Set the trailers that gRPC-Java expects + response.setTrailersSupplier(() -> HttpFields.build().put("grpc-status", "0")); + + // The core of the issue: sending a "last" write with no data + // This triggers Data.eof() in Jetty 12 instead of a HEADERS frame + response.write(true, null, callback); + return true; + } + }); + server.start(); + + // 2. Setup Client + HTTP2Client client = new HTTP2Client(); + client.start(); + + CompletableFuture resultFuture = new CompletableFuture<>(); + InetSocketAddress address = new InetSocketAddress("localhost", connector.getLocalPort()); + + client.connect( + address, + new Session.Listener() {}, + new Promise<>() { + @Override + public void succeeded(Session session) { + HttpURI uri = HttpURI.from("http://localhost:" + connector.getLocalPort() + "/"); + MetaData.Request metaData = + new MetaData.Request("GET", uri, HttpVersion.HTTP_2, HttpFields.build()); + HeadersFrame requestFrame = new HeadersFrame(metaData, null, true); + + session.newStream( + requestFrame, + Promise.noop(), + new Stream.Listener() { + @Override + public void onDataAvailable(Stream stream) { + // Access the Data wrapper you identified + Stream.Data data = stream.readData(); + if (data != null) { + DataFrame frame = data.frame(); + + // If we receive an empty DATA frame with END_STREAM, + // Jetty has closed the stream before sending trailers. + if (frame.isEndStream()) { + resultFuture.completeExceptionally( + new RuntimeException( + "BUG REPRODUCED: Received Data.EOF (DATA frame with END_STREAM)." + + " This violates gRPC protocol as trailers in HEADERS were" + + " expected.")); + return; + } + } + stream.demand(); + } + + @Override + public void onHeaders(Stream stream, HeadersFrame frame) { + MetaData metaData = frame.getMetaData(); + HttpFields fields = metaData.getHttpFields(); + // If trailers arrive correctly with END_STREAM, the test passes + if (frame.isEndStream() && fields.contains("grpc-status")) { + resultFuture.complete(null); + } + } + + @Override + public void onFailure( + Stream stream, + int error, + String reason, + Throwable failure, + Callback callback) { + resultFuture.completeExceptionally(failure); + callback.succeeded(); + } + }); + } + + @Override + public void failed(Throwable x) { + resultFuture.completeExceptionally(x); + } + }); + + // 3. Evaluation + try { + // We expect this to time out or fail if the bug exists + resultFuture.get(5, TimeUnit.SECONDS); + System.out.println("SUCCESS: Trailers arrived correctly on a HEADERS frame."); + } catch (Exception e) { + // In the bug scenario, e.getCause() will contain our RuntimeException + String message = e.getMessage() != null ? e.getMessage() : e.getCause().getMessage(); + fail("Test failed due to Jetty behavior: " + message); + } finally { + server.stop(); + client.stop(); + } + } + + @Test + public void testBidiExchangeWithTrailers() throws Exception { + // 1. Setup Server + Server server = new Server(); + ServerConnector connector = new ServerConnector(server, new HTTP2ServerConnectionFactory()); + server.addConnector(connector); + + server.setHandler( + new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + // Set trailers supplier + response.setTrailersSupplier(() -> HttpFields.build().put("grpc-status", "0")); + + // Obtain the content source to read client data + + request.demand( + () -> { + var chunk = request.read(); + if (chunk != null) { + // If we received data from client + if (BufferUtil.hasContent(chunk.getByteBuffer())) { + // Send an echo response back + var echo = ByteBuffer.wrap("Server Echo".getBytes(StandardCharsets.UTF_8)); + response.write(false, echo, Callback.NOOP); + } + + // Check if this was the last chunk from client + if (chunk.isLast()) { + // SIGNAL END OF SERVER STREAM + // This triggers the Data.EOF bug in 12.1.5 + response.write(true, null, callback); + } + chunk.release(); + } + }); + return true; + } + }); + server.start(); + + // 2. Setup Client + HTTP2Client client = new HTTP2Client(); + client.start(); + CompletableFuture resultFuture = new CompletableFuture<>(); + int port = connector.getLocalPort(); + + client.connect( + new InetSocketAddress("localhost", port), + new Session.Listener() {}, + new Promise<>() { + @Override + public void succeeded(Session session) { + HttpURI uri = HttpURI.from("http://localhost:" + port + "/"); + MetaData.Request metaData = + new MetaData.Request("POST", uri, HttpVersion.HTTP_2, HttpFields.build()); + + // Client starts stream + HeadersFrame headers = new HeadersFrame(metaData, null, false); + + session.newStream( + headers, + Promise.noop(), + new Stream.Listener() { + @Override + public void onDataAvailable(Stream stream) { + Stream.Data data = stream.readData(); + if (data != null) { + if (data.frame().isEndStream()) { + // If Jetty sends a DATA frame with END_STREAM, the bug is reproduced + resultFuture.completeExceptionally( + new RuntimeException( + "Received DATA frame with END_STREAM flag. Expected Trailers in" + + " HEADERS.")); + return; + } + } + stream.demand(); + } + + @Override + public void onHeaders(Stream stream, HeadersFrame frame) { + HttpFields fields = frame.getMetaData().getHttpFields(); + if (frame.isEndStream() && fields.contains("grpc-status")) { + resultFuture.complete(null); + } + } + }); + + // Client sends one message and closes client-side stream + session + .getStreams() + .forEach( + s -> { + ByteBuffer clientMsg = + ByteBuffer.wrap("Client Hello".getBytes(StandardCharsets.UTF_8)); + s.data(new DataFrame(s.getId(), clientMsg, true), Callback.NOOP); + }); + } + }); + + // 3. Evaluation + try { + resultFuture.get(5, TimeUnit.SECONDS); + System.out.println("SUCCESS: Bidi exchange completed correctly."); + } catch (Exception e) { + String msg = (e.getCause() != null) ? e.getCause().getMessage() : e.getMessage(); + fail("Reproduced: " + msg); + } finally { + server.stop(); + client.stop(); + } + } +} diff --git a/tests/src/test/java/examples/grpc/ReflectionClient.java b/tests/src/test/java/examples/grpc/ReflectionClient.java index 62219a7ebe..bdcc61795a 100644 --- a/tests/src/test/java/examples/grpc/ReflectionClient.java +++ b/tests/src/test/java/examples/grpc/ReflectionClient.java @@ -18,51 +18,53 @@ public class ReflectionClient { public static void main(String[] args) throws InterruptedException { ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build(); + try { + var latch = new CountDownLatch(1); + ServerReflectionGrpc.ServerReflectionStub stub = ServerReflectionGrpc.newStub(channel); - var latch = new CountDownLatch(1); - ServerReflectionGrpc.ServerReflectionStub stub = ServerReflectionGrpc.newStub(channel); + // 1. Prepare the response observer + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ServerReflectionResponse response) { + // This is the part that returns the list of services + response + .getListServicesResponse() + .getServiceList() + .forEach( + s -> { + System.out.println("Service: " + s.getName()); + }); + } - // 1. Prepare the response observer - StreamObserver responseObserver = - new StreamObserver<>() { - @Override - public void onNext(ServerReflectionResponse response) { - // This is the part that returns the list of services - response - .getListServicesResponse() - .getServiceList() - .forEach( - s -> { - System.out.println("Service: " + s.getName()); - }); - } + @Override + public void onError(Throwable t) { + t.printStackTrace(); + } - @Override - public void onError(Throwable t) { - t.printStackTrace(); - } + @Override + public void onCompleted() { + latch.countDown(); + } + }; - @Override - public void onCompleted() { - latch.countDown(); - } - }; + // 2. Open the bidirectional stream + StreamObserver requestObserver = + stub.serverReflectionInfo(responseObserver); - // 2. Open the bidirectional stream - StreamObserver requestObserver = - stub.serverReflectionInfo(responseObserver); + // 3. Send the "List Services" request + requestObserver.onNext( + ServerReflectionRequest.newBuilder() + .setListServices("") // The trigger for 'list' + .setHost("localhost") + .build()); - // 3. Send the "List Services" request - requestObserver.onNext( - ServerReflectionRequest.newBuilder() - .setListServices("") // The trigger for 'list' - .setHost("localhost") - .build()); + // 4. Signal half-close (Very important for reflection) + requestObserver.onCompleted(); - // 4. Signal half-close (Very important for reflection) - requestObserver.onCompleted(); - - latch.await(); - channel.shutdown(); + latch.await(); + } finally { + channel.shutdown(); + } } } From f636d26e5b2c9ba9e179cc6085f304a40401c442 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 25 Jan 2026 11:09:02 -0300 Subject: [PATCH 03/11] netty: redo pipeline for HTTP2 --- .../jooby/internal/netty/Http2Extension.java | 66 ------- .../jooby/internal/netty/Http2Settings.java | 24 --- .../io/jooby/internal/netty/NettyContext.java | 38 ++-- .../jooby/internal/netty/NettyPipeline.java | 177 ++++++++++++------ .../internal/netty/NettyRequestDecoder.java | 52 ----- .../internal/netty/NettyResponseEncoder.java | 21 --- .../internal/netty/NettyServerCodec.java | 136 ++++++++++++++ .../netty/http2/Http2OrHttp11Handler.java | 36 ---- .../http2/Http2PrefaceOrHttpHandler.java | 44 ----- .../netty/http2/NettyHttp2Configurer.java | 62 ------ .../test/java/io/jooby/test/FeaturedTest.java | 2 +- .../test/java/io/jooby/test/Http2Test.java | 61 +++++- 12 files changed, 333 insertions(+), 386 deletions(-) delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java create mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java deleted file mode 100644 index 1ff0bb2d4d..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - -import io.netty.channel.ChannelOutboundHandler; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.codec.http.HttpServerUpgradeHandler; - -public class Http2Extension { - - private Http2Settings settings; - - private Consumer http11; - - private BiConsumer> - http11Upgrade; - - private BiConsumer> http2; - - private BiConsumer> http2c; - - public Http2Extension( - Http2Settings settings, - Consumer http11, - BiConsumer> http11Upgrade, - BiConsumer> http2, - BiConsumer> http2c) { - this.settings = settings; - this.http11 = http11; - this.http11Upgrade = http11Upgrade; - this.http2 = http2; - this.http2c = http2c; - } - - public boolean isSecure() { - return settings.isSecure(); - } - - public void http11(ChannelPipeline pipeline) { - this.http11.accept(pipeline); - } - - public void http2( - ChannelPipeline pipeline, Function factory) { - this.http2.accept(pipeline, () -> factory.apply(settings)); - } - - public void http2c( - ChannelPipeline pipeline, Function factory) { - this.http2c.accept(pipeline, () -> factory.apply(settings)); - } - - public void http11Upgrade( - ChannelPipeline pipeline, - Function factory) { - this.http11Upgrade.accept(pipeline, () -> factory.apply(settings)); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java deleted file mode 100644 index 4a99f63d5d..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -public class Http2Settings { - private final int maxRequestSize; - private final boolean secure; - - public Http2Settings(long maxRequestSize, boolean secure) { - this.maxRequestSize = (int) maxRequestSize; - this.secure = secure; - } - - public boolean isSecure() { - return secure; - } - - public int getMaxRequestSize() { - return maxRequestSize; - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java index 0f1c34d959..91d2496e92 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java @@ -46,12 +46,7 @@ import io.jooby.value.Value; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelFuture; -import io.netty.channel.ChannelFutureListener; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.ChannelPromise; -import io.netty.channel.DefaultFileRegion; +import io.netty.channel.*; import io.netty.handler.codec.http.*; import io.netty.handler.codec.http.cookie.ServerCookieDecoder; import io.netty.handler.codec.http.multipart.*; @@ -320,7 +315,7 @@ public String getProtocol() { @NonNull @Override public List getClientCertificates() { - SslHandler sslHandler = (SslHandler) ctx.channel().pipeline().get("ssl"); + var sslHandler = ssl(); if (sslHandler != null) { try { return List.of(sslHandler.engine().getSession().getPeerCertificates()); @@ -334,11 +329,22 @@ public List getClientCertificates() { @NonNull @Override public String getScheme() { if (scheme == null) { - scheme = ctx.pipeline().get("ssl") == null ? "http" : "https"; + scheme = ssl() == null ? "http" : "https"; } return scheme; } + private SslHandler ssl() { + return (SslHandler) + Stream.of(ctx.channel(), ctx.channel().parent()) + .filter(Objects::nonNull) + .map(Channel::pipeline) + .map(it -> it.get("ssl")) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + @NonNull @Override public Context setScheme(@NonNull String scheme) { this.scheme = scheme; @@ -416,7 +422,7 @@ public Context upgrade(WebSocket.Initializer handler) { ? conf.getBytes("websocket.maxSize").intValue() : WebSocket.MAX_BUFFER_SIZE; String webSocketURL = getProtocol() + "://" + req.headers().get(HttpHeaderNames.HOST) + path; - WebSocketDecoderConfig config = + var config = WebSocketDecoderConfig.newBuilder() .allowExtensions(true) .allowMaskMismatch(false) @@ -425,7 +431,7 @@ public Context upgrade(WebSocket.Initializer handler) { .build(); webSocket = new NettyWebSocket(this); handler.init(Context.readOnly(this), webSocket); - FullHttpRequest webSocketRequest = + var webSocketRequest = new DefaultFullHttpRequest( HTTP_1_1, req.method(), @@ -433,6 +439,8 @@ public Context upgrade(WebSocket.Initializer handler) { Unpooled.EMPTY_BUFFER, req.headers(), EmptyHttpHeaders.INSTANCE); + var codec = ctx.pipeline().get(NettyServerCodec.class); + codec.webSocketHandshake(ctx); WebSocketServerHandshakerFactory factory = new WebSocketServerHandshakerFactory(webSocketURL, null, config); WebSocketServerHandshaker handshaker = factory.newHandshaker(webSocketRequest); @@ -856,15 +864,9 @@ private long responseLength() { private void prepareChunked() { responseStarted = true; // remove flusher, doesn't play well with streaming/chunked responses - ChannelPipeline pipeline = ctx.pipeline(); + var pipeline = ctx.pipeline(); if (pipeline.get("chunker") == null) { - String base = - Stream.of("compressor", "encoder", "codec", "http2") - .filter(name -> pipeline.get(name) != null) - .findFirst() - .orElseThrow( - () -> new IllegalStateException("No available handler for chunk writer")); - pipeline.addAfter(base, "chunker", new ChunkedWriteHandler()); + pipeline.addBefore("handler", "chunker", new ChunkedWriteHandler()); } if (!setHeaders.contains(CONTENT_LENGTH)) { setHeaders.set(TRANSFER_ENCODING, CHUNKED); diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java index 1fd488d2be..61437319a0 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java @@ -5,20 +5,23 @@ */ package io.jooby.internal.netty; +import java.util.List; import java.util.concurrent.ScheduledExecutorService; -import java.util.function.Supplier; import io.jooby.Context; -import io.jooby.internal.netty.http2.NettyHttp2Configurer; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOutboundHandler; -import io.netty.channel.ChannelPipeline; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.ByteToMessageDecoder; import io.netty.handler.codec.http.*; +import io.netty.handler.codec.http2.*; +import io.netty.handler.ssl.ApplicationProtocolNames; +import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; import io.netty.handler.ssl.SslContext; public class NettyPipeline extends ChannelInitializer { private static final String H2_HANDSHAKE = "h2-handshake"; + private final SslContext sslContext; private final HttpDecoderConfig decoderConfig; private final Context.Selector contextSelector; @@ -53,45 +56,61 @@ public NettyPipeline( this.compressionLevel = compressionLevel; } - private NettyHandler createHandler(ScheduledExecutorService executor) { - return new NettyHandler( - new NettyDateService(executor), - contextSelector, - maxRequestSize, - maxFormFields, - bufferSize, - defaultHeaders, - http2); - } - @Override public void initChannel(SocketChannel ch) { - var p = ch.pipeline(); + ChannelPipeline p = ch.pipeline(); + if (sslContext != null) { p.addLast("ssl", sslContext.newHandler(ch.alloc())); } - // https://github.com/jooby-project/jooby/issues/3433: - // using new FlushConsolidationHandler(DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, true) - // cause the bug, for now I'm going to remove flush consolidating handler... doesn't seem to - // help much - // p.addLast(new FlushConsolidationHandler(DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, false)); + if (http2) { - var settings = new Http2Settings(maxRequestSize, sslContext != null); - var extension = - new Http2Extension( - settings, this::http11, this::http11Upgrade, this::http2, this::http2c); - var configurer = new NettyHttp2Configurer(); - var handshake = configurer.configure(extension); - - p.addLast(H2_HANDSHAKE, handshake); - additionalHandlers(p); - p.addLast("handler", createHandler(ch.eventLoop())); + p.addLast(H2_HANDSHAKE, setupHttp2Handshake(sslContext != null)); } else { - http11(p); + setupHttp11(p); + } + } + + private void setupHttp11(ChannelPipeline p) { + p.addLast("codec", createServerCodec()); + addCommonHandlers(p); + p.addLast("handler", createHandler(p.channel().eventLoop())); + } + + private void setupHttp2(ChannelPipeline pipeline) { + var frameCodec = + Http2FrameCodecBuilder.forServer() + .initialSettings(Http2Settings.defaultSettings().maxFrameSize((int) maxRequestSize)) + .build(); + + pipeline.addLast("http2-codec", frameCodec); + pipeline.addLast( + "http2-multiplex", new Http2MultiplexHandler(new Http2StreamInitializer(this))); + } + + private void setupHttp11Upgrade(ChannelPipeline pipeline) { + var serverCodec = createServerCodec(); + pipeline.addLast("codec", serverCodec); + + pipeline.addLast( + "h2upgrade", + new HttpServerUpgradeHandler( + serverCodec, + protocol -> "h2c".equals(protocol.toString()) ? createH2CUpgradeCodec() : null, + (int) maxRequestSize)); + + addCommonHandlers(pipeline); + pipeline.addLast("handler", createHandler(pipeline.channel().eventLoop())); + } + + private ChannelInboundHandler setupHttp2Handshake(boolean secure) { + if (secure) { + return new AlpnHandler(this); } + return new Http2PrefaceOrHttpHandler(this); } - private void additionalHandlers(ChannelPipeline p) { + private void addCommonHandlers(ChannelPipeline p) { if (expectContinue) { p.addLast("expect-continue", new HttpServerExpectContinueHandler()); } @@ -101,32 +120,80 @@ private void additionalHandlers(ChannelPipeline p) { } } - private void http2(ChannelPipeline pipeline, Supplier factory) { - pipeline.addAfter(H2_HANDSHAKE, "http2", factory.get()); + private Http2ServerUpgradeCodec createH2CUpgradeCodec() { + return new Http2ServerUpgradeCodec( + Http2FrameCodecBuilder.forServer().build(), + new Http2MultiplexHandler(new Http2StreamInitializer(this))); } - private void http2c(ChannelPipeline pipeline, Supplier factory) { - pipeline.addAfter(H2_HANDSHAKE, "http2", factory.get()); + private NettyHandler createHandler(ScheduledExecutorService executor) { + return new NettyHandler( + new NettyDateService(executor), + contextSelector, + maxRequestSize, + maxFormFields, + bufferSize, + defaultHeaders, + http2); } - private void http11Upgrade( - ChannelPipeline pipeline, Supplier factory) { - // direct http1 to h2c - HttpServerCodec serverCodec = new HttpServerCodec(decoderConfig); - pipeline.addAfter(H2_HANDSHAKE, "codec", serverCodec); - pipeline.addAfter( - "codec", - "h2upgrade", - new HttpServerUpgradeHandler( - serverCodec, - protocol -> protocol.toString().equals("h2c") ? factory.get() : null, - (int) maxRequestSize)); + private NettyServerCodec createServerCodec() { + return new NettyServerCodec(decoderConfig); } - private void http11(ChannelPipeline p) { - p.addLast("decoder", new NettyRequestDecoder(decoderConfig)); - p.addLast("encoder", new NettyResponseEncoder()); - additionalHandlers(p); - p.addLast("handler", createHandler(p.channel().eventLoop())); + /** Handles the transition from ALPN to H1 or H2 */ + private static class AlpnHandler extends ApplicationProtocolNegotiationHandler { + private final NettyPipeline pipeline; + + AlpnHandler(NettyPipeline pipeline) { + super(ApplicationProtocolNames.HTTP_1_1); + this.pipeline = pipeline; + } + + @Override + protected void configurePipeline(ChannelHandlerContext ctx, String protocol) { + if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { + pipeline.setupHttp2(ctx.pipeline()); + } else { + pipeline.setupHttp11(ctx.pipeline()); + } + } + } + + /** Detects HTTP/2 connection preface or upgrades to H1/H2C */ + private static class Http2PrefaceOrHttpHandler extends ByteToMessageDecoder { + private static final int PRI = 0x50524920; // "PRI " + private final NettyPipeline pipeline; + + Http2PrefaceOrHttpHandler(NettyPipeline pipeline) { + this.pipeline = pipeline; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + if (in.readableBytes() < 4) return; + + if (in.getInt(in.readerIndex()) == PRI) { + pipeline.setupHttp2(ctx.pipeline()); + } else { + pipeline.setupHttp11Upgrade(ctx.pipeline()); + } + ctx.pipeline().remove(this); + } + } + + /** Initializes the child channels created for each HTTP/2 stream */ + private static class Http2StreamInitializer extends ChannelInitializer { + private final NettyPipeline pipeline; + + Http2StreamInitializer(NettyPipeline pipeline) { + this.pipeline = pipeline; + } + + @Override + protected void initChannel(Channel ch) { + ch.pipeline().addLast("http2", new Http2StreamFrameToHttpObjectCodec(true)); + ch.pipeline().addLast("handler", pipeline.createHandler(ch.eventLoop())); + } } } diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java deleted file mode 100644 index 71788e2c3a..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import io.netty.handler.codec.http.*; - -public class NettyRequestDecoder extends HttpRequestDecoder { - - private static final String GET = HttpMethod.GET.name(); - private static final String POST = HttpMethod.POST.name(); - private static final String PUT = HttpMethod.PUT.name(); - private static final String DELETE = HttpMethod.DELETE.name(); - - public NettyRequestDecoder(HttpDecoderConfig config) { - super(config); - } - - @Override - protected HttpMessage createMessage(String[] initialLine) throws Exception { - return new DefaultHttpRequest( - HttpVersion.valueOf(initialLine[2]), - valueOf(initialLine[0]), - initialLine[1], - headersFactory); - } - - @Override - protected boolean isContentAlwaysEmpty(HttpMessage msg) { - return false; - } - - private static HttpMethod valueOf(String name) { - // fast-path - if (name == GET) { - return HttpMethod.GET; - } - if (name == POST) { - return HttpMethod.POST; - } - if (name == DELETE) { - return HttpMethod.DELETE; - } - if (name == PUT) { - return HttpMethod.PUT; - } - // "slow"-path: ensure method is on upper case - return HttpMethod.valueOf(name.toUpperCase()); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java deleted file mode 100644 index c8c26cb2b1..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import io.netty.buffer.ByteBuf; -import io.netty.handler.codec.http.HttpHeaders; -import io.netty.handler.codec.http.HttpResponseEncoder; - -public class NettyResponseEncoder extends HttpResponseEncoder { - @Override - protected void encodeHeaders(HttpHeaders headers, ByteBuf buf) { - if (headers.getClass() == HeadersMultiMap.class) { - ((HeadersMultiMap) headers).encode(buf); - } else { - super.encodeHeaders(headers, buf); - } - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java new file mode 100644 index 0000000000..25544286dd --- /dev/null +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java @@ -0,0 +1,136 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.netty; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.Queue; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.CombinedChannelDuplexHandler; +import io.netty.handler.codec.http.*; + +/** + * Copy of {@link HttpServerCodec} with a custom request method parser and optimized header response + * writer. + */ +public class NettyServerCodec + extends CombinedChannelDuplexHandler + implements HttpServerUpgradeHandler.SourceCodec { + + /** A queue that is used for correlating a request and a response. */ + private final Queue queue = new ArrayDeque(); + + private final HttpDecoderConfig decoderConfig; + + /** Creates a new instance with the specified decoder configuration. */ + public NettyServerCodec(HttpDecoderConfig decoderConfig) { + this.decoderConfig = decoderConfig; + init(new HttpServerRequestDecoder(decoderConfig), new HttpServerResponseEncoder()); + } + + /** + * Web socket looks for these two component while doing the upgrade. + * + * @param ctx Channel context. + */ + /*package*/ void webSocketHandshake(ChannelHandlerContext ctx) { + var p = ctx.pipeline(); + var codec = p.context(getClass()).name(); + p.addBefore(codec, "encoder", new HttpServerResponseEncoder()); + p.addBefore(codec, "decoder", new HttpServerRequestDecoder(decoderConfig)); + p.remove(this); + } + + /** + * Upgrades to another protocol from HTTP. Removes the {@link HttpRequestDecoder} and {@link + * HttpResponseEncoder} from the pipeline. + */ + @Override + public void upgradeFrom(ChannelHandlerContext ctx) { + ctx.pipeline().remove(this); + } + + private final class HttpServerRequestDecoder extends HttpRequestDecoder { + HttpServerRequestDecoder(HttpDecoderConfig config) { + super(config); + } + + @Override + protected HttpMessage createMessage(String[] initialLine) { + return new DefaultHttpRequest( + // Do strict version checking + HttpVersion.valueOf(initialLine[2]), + httpMethod(initialLine[0]), + initialLine[1], + headersFactory); + } + + public static HttpMethod httpMethod(String name) { + return switch (name) { + case "OPTIONS" -> HttpMethod.OPTIONS; + case "GET" -> HttpMethod.GET; + case "HEAD" -> HttpMethod.HEAD; + case "POST" -> HttpMethod.POST; + case "PUT" -> HttpMethod.PUT; + case "PATCH" -> HttpMethod.PATCH; + case "DELETE" -> HttpMethod.DELETE; + case "TRACE" -> HttpMethod.TRACE; + case "CONNECT" -> HttpMethod.CONNECT; + default -> new HttpMethod(name.toUpperCase()); + }; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf buffer, List out) + throws Exception { + int oldSize = out.size(); + super.decode(ctx, buffer, out); + int size = out.size(); + for (int i = oldSize; i < size; i++) { + Object obj = out.get(i); + if (obj instanceof HttpRequest) { + queue.add(((HttpRequest) obj).method()); + } + } + } + } + + private final class HttpServerResponseEncoder extends HttpResponseEncoder { + + private HttpMethod method; + + @Override + protected void sanitizeHeadersBeforeEncode(HttpResponse msg, boolean isAlwaysEmpty) { + if (!isAlwaysEmpty + && HttpMethod.CONNECT.equals(method) + && msg.status().codeClass() == HttpStatusClass.SUCCESS) { + // Stripping Transfer-Encoding: + // See https://tools.ietf.org/html/rfc7230#section-3.3.1 + msg.headers().remove(HttpHeaderNames.TRANSFER_ENCODING); + return; + } + + super.sanitizeHeadersBeforeEncode(msg, isAlwaysEmpty); + } + + @Override + protected void encodeHeaders(HttpHeaders headers, ByteBuf buf) { + if (headers.getClass() == HeadersMultiMap.class) { + ((HeadersMultiMap) headers).encode(buf); + } else { + super.encodeHeaders(headers, buf); + } + } + + @Override + protected boolean isContentAlwaysEmpty(@SuppressWarnings("unused") HttpResponse msg) { + method = queue.poll(); + return HttpMethod.HEAD.equals(method) || super.isContentAlwaysEmpty(msg); + } + } +} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java deleted file mode 100644 index 0af06a6f70..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import java.util.function.Consumer; - -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.ssl.ApplicationProtocolNames; -import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; - -class Http2OrHttp11Handler extends ApplicationProtocolNegotiationHandler { - - private final Consumer http2; - private final Consumer http1; - - public Http2OrHttp11Handler(Consumer http1, Consumer http2) { - super(ApplicationProtocolNames.HTTP_1_1); - this.http2 = http2; - this.http1 = http1; - } - - @Override - public void configurePipeline(final ChannelHandlerContext ctx, final String protocol) { - if (ApplicationProtocolNames.HTTP_1_1.equals(protocol)) { - http1.accept(ctx.pipeline()); - } else if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { - http2.accept(ctx.pipeline()); - } else { - throw new IllegalStateException("Unknown protocol: " + protocol); - } - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java deleted file mode 100644 index 51900925d3..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import java.util.List; -import java.util.function.Consumer; - -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.codec.ByteToMessageDecoder; - -class Http2PrefaceOrHttpHandler extends ByteToMessageDecoder { - - private static final int PRI = 0x50524920; - - private Consumer http1; - - private Consumer http2; - - public Http2PrefaceOrHttpHandler( - Consumer http1, Consumer http2) { - this.http1 = http1; - this.http2 = http2; - } - - @Override - protected void decode(final ChannelHandlerContext ctx, final ByteBuf in, final List out) { - if (in.readableBytes() < 4) { - return; - } - - if (in.getInt(in.readerIndex()) == PRI) { - http2.accept(ctx.pipeline()); - } else { - http1.accept(ctx.pipeline()); - } - - ctx.pipeline().remove(this); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java deleted file mode 100644 index 3e0de59faf..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import static io.netty.handler.codec.http.HttpScheme.HTTP; - -import io.jooby.internal.netty.Http2Extension; -import io.netty.channel.ChannelInboundHandler; -import io.netty.handler.codec.http.HttpScheme; -import io.netty.handler.codec.http2.DefaultHttp2Connection; -import io.netty.handler.codec.http2.Http2ConnectionHandler; -import io.netty.handler.codec.http2.Http2FrameLogger; -import io.netty.handler.codec.http2.Http2ServerUpgradeCodec; -import io.netty.handler.codec.http2.HttpToHttp2ConnectionHandlerBuilder; -import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapter; -import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder; -import io.netty.handler.logging.LogLevel; - -public class NettyHttp2Configurer { - - public ChannelInboundHandler configure(Http2Extension extension) { - if (extension.isSecure()) { - return new Http2OrHttp11Handler( - extension::http11, - pipeline -> - extension.http2( - pipeline, - settings -> newHttp2Handler(settings.getMaxRequestSize(), HttpScheme.HTTPS))); - } else { - return new Http2PrefaceOrHttpHandler( - pipeline -> - extension.http11Upgrade( - pipeline, - settings -> - new Http2ServerUpgradeCodec( - newHttp2Handler(settings.getMaxRequestSize(), HTTP))), - pipeline -> - extension.http2c( - pipeline, settings -> newHttp2Handler(settings.getMaxRequestSize(), HTTP))); - } - } - - private Http2ConnectionHandler newHttp2Handler(int maxRequestSize, HttpScheme scheme) { - DefaultHttp2Connection connection = new DefaultHttp2Connection(true); - InboundHttp2ToHttpAdapter listener = - new InboundHttp2ToHttpAdapterBuilder(connection) - .propagateSettings(false) - .validateHttpHeaders(true) - .maxContentLength(maxRequestSize) - .build(); - - return new HttpToHttp2ConnectionHandlerBuilder() - .frameListener(listener) - .frameLogger(new Http2FrameLogger(LogLevel.DEBUG)) - .connection(connection) - .httpScheme(scheme) - .build(); - } -} diff --git a/tests/src/test/java/io/jooby/test/FeaturedTest.java b/tests/src/test/java/io/jooby/test/FeaturedTest.java index de84f0a75f..d780ac3d85 100644 --- a/tests/src/test/java/io/jooby/test/FeaturedTest.java +++ b/tests/src/test/java/io/jooby/test/FeaturedTest.java @@ -272,7 +272,7 @@ public void rawPath(ServerTestRunner runner) { runner .define( app -> { - app.get("/{code}", ctx -> ctx.getRequestPath()); + app.get("/{code}", Context::getRequestPath); }) .ready( client -> { diff --git a/tests/src/test/java/io/jooby/test/Http2Test.java b/tests/src/test/java/io/jooby/test/Http2Test.java index 09594ab77b..f0405aeda1 100644 --- a/tests/src/test/java/io/jooby/test/Http2Test.java +++ b/tests/src/test/java/io/jooby/test/Http2Test.java @@ -5,9 +5,13 @@ */ package io.jooby.test; +import static io.jooby.test.TestUtil._19kb; +import static okhttp3.RequestBody.create; import static org.junit.jupiter.api.Assertions.assertEquals; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Phaser; @@ -26,19 +30,62 @@ import org.junit.jupiter.api.BeforeAll; import com.google.common.collect.ImmutableMap; -import io.jooby.ServerOptions; -import io.jooby.SneakyThrows; -import io.jooby.StatusCode; +import io.jooby.*; +import io.jooby.jackson.JacksonModule; import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; +import okhttp3.*; import okhttp3.MediaType; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; public class Http2Test { + @ServerTest + public void h2body(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define( + app -> { + app.install(new JacksonModule()); + app.post( + "/h2/multipart", + ctx -> { + try (var f = ctx.file("f")) { + return ctx.getScheme() + + ":" + + ctx.getProtocol() + + ":" + + new String(f.bytes(), StandardCharsets.UTF_8); + } + }); + + app.post( + "/h2/body", + ctx -> { + return ctx.getScheme() + ":" + ctx.getProtocol() + ":" + ctx.body(Map.class); + }); + }) + .ready( + (http, https) -> { + https.post( + "/h2/multipart", + new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "f", "19kb.txt", create(_19kb, MediaType.parse("text/plain"))) + .build(), + rsp -> { + assertEquals("https:HTTP/2.0:" + _19kb, rsp.body().string()); + }); + + https.post( + "/h2/body", + create("{\"foo\": \"bar\"}", MediaType.parse("application/json")), + rsp -> { + assertEquals("https:HTTP/2.0:" + "{foo=bar}", rsp.body().string()); + }); + }); + } + @ServerTest public void http2(ServerTestRunner runner) { runner From ae1a9406d872985021376344aa7a3282335e286c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 11 Mar 2026 21:00:19 -0300 Subject: [PATCH 04/11] feat(grpc): reactive zero-copy bridge with backpressure and metadata propagation - **Refactor to fully reactive I/O:** Removed the blocking `GrpcHandler` (which relied on `InputStream.readNBytes`) and promoted `UnifiedGrpcBridge` to the primary `Route.Handler`. - **Zero-copy ByteBuffer pipeline:** Migrated `GrpcDeframer` and `GrpcRequestBridge` to consume `Flow.Subscriber` instead of `byte[]`. This allows Netty, Undertow, and Jetty to pipe native socket buffers directly into the gRPC state machine without intermediate array allocations. - **Backpressure integration:** Wired gRPC's `ClientCallStreamObserver.setOnReadyHandler` directly to Jooby's `Flow.Subscription`, ensuring the server only demands more data when the internal gRPC buffers are ready. - **Context propagation:** Added parsing and propagation of the `grpc-timeout` header into gRPC `CallOptions` (deadlines). - **Metadata propagation:** Added HTTP header to gRPC `Metadata` mapping via `ClientInterceptors`, including base64 decoding for `-bin` suffixed headers. - **Server Reflection upgrade:** Swapped the deprecated `v1alpha` reflection service for the stable `ProtoReflectionServiceV1`. - **Testing:** Added comprehensive unit tests for `GrpcDeframer` fragmentation/coalescing logic and `GrpcRequestBridge` backpressure state handling. --- .../java/io/jooby/grpc/GrpcDeframerTest.java | 124 ++++++++++++++++++ .../io/jooby/grpc/GrpcRequestBridgeTest.java | 124 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcDeframerTest.java create mode 100644 modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcRequestBridgeTest.java diff --git a/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcDeframerTest.java b/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcDeframerTest.java new file mode 100644 index 0000000000..6ef53d2c4b --- /dev/null +++ b/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcDeframerTest.java @@ -0,0 +1,124 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class GrpcDeframerTest { + + private GrpcDeframer deframer; + private List outputMessages; + + @BeforeEach + public void setUp() { + deframer = new GrpcDeframer(); + outputMessages = new ArrayList<>(); + } + + @Test + public void shouldParseSingleCompleteMessage() { + byte[] payload = "hello grpc".getBytes(); + ByteBuffer frame = createGrpcFrame(payload); + + deframer.process(frame, msg -> outputMessages.add(msg)); + + assertEquals(1, outputMessages.size()); + assertArrayEquals(payload, outputMessages.get(0)); + } + + @Test + public void shouldParseFragmentedHeader() { + byte[] payload = "fragmented header".getBytes(); + byte[] frame = createGrpcFrame(payload).array(); + + // Send the first 2 bytes of the header + deframer.process(ByteBuffer.wrap(frame, 0, 2), msg -> outputMessages.add(msg)); + assertEquals(0, outputMessages.size(), "Should not emit message yet"); + + // Send the rest of the header and the payload + deframer.process(ByteBuffer.wrap(frame, 2, frame.length - 2), msg -> outputMessages.add(msg)); + + assertEquals(1, outputMessages.size()); + assertArrayEquals(payload, outputMessages.get(0)); + } + + @Test + public void shouldParseFragmentedPayload() { + byte[] payload = "this is a very long payload that gets split".getBytes(); + byte[] frame = createGrpcFrame(payload).array(); + + // Send the 5-byte header + first 10 bytes of payload (15 bytes total) + deframer.process(ByteBuffer.wrap(frame, 0, 15), msg -> outputMessages.add(msg)); + assertEquals(0, outputMessages.size(), "Should not emit message until full payload arrives"); + + // Send the remainder of the payload + deframer.process(ByteBuffer.wrap(frame, 15, frame.length - 15), msg -> outputMessages.add(msg)); + + assertEquals(1, outputMessages.size()); + assertArrayEquals(payload, outputMessages.get(0)); + } + + @Test + public void shouldParseMultipleMessagesInSingleBuffer() { + byte[] payload1 = "message 1".getBytes(); + byte[] payload2 = "message 2".getBytes(); + + ByteBuffer frame1 = createGrpcFrame(payload1); + ByteBuffer frame2 = createGrpcFrame(payload2); + + // Combine both frames into a single buffer + ByteBuffer combined = ByteBuffer.allocate(frame1.capacity() + frame2.capacity()); + combined.put(frame1).put(frame2); + combined.flip(); + + deframer.process(combined, msg -> outputMessages.add(msg)); + + assertEquals(2, outputMessages.size()); + assertArrayEquals(payload1, outputMessages.get(0)); + assertArrayEquals(payload2, outputMessages.get(1)); + } + + @Test + public void shouldHandleZeroLengthPayload() { + byte[] payload = new byte[0]; + ByteBuffer frame = createGrpcFrame(payload); + + deframer.process(frame, msg -> outputMessages.add(msg)); + + assertEquals(1, outputMessages.size()); + assertArrayEquals(payload, outputMessages.get(0)); + } + + @Test + public void shouldHandleExtremeFragmentationByteByByte() { + byte[] payload = "byte by byte".getBytes(); + byte[] frame = createGrpcFrame(payload).array(); + + for (byte b : frame) { + deframer.process(ByteBuffer.wrap(new byte[] {b}), msg -> outputMessages.add(msg)); + } + + assertEquals(1, outputMessages.size()); + assertArrayEquals(payload, outputMessages.get(0)); + } + + /** Helper to wrap a raw payload in the standard 5-byte gRPC framing. */ + private ByteBuffer createGrpcFrame(byte[] payload) { + ByteBuffer buffer = ByteBuffer.allocate(5 + payload.length); + buffer.put((byte) 0); // Compressed flag + buffer.putInt(payload.length); // Length + buffer.put(payload); // Data + buffer.flip(); + return buffer; + } +} diff --git a/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcRequestBridgeTest.java b/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcRequestBridgeTest.java new file mode 100644 index 0000000000..70846cab01 --- /dev/null +++ b/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcRequestBridgeTest.java @@ -0,0 +1,124 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.ByteBuffer; +import java.util.concurrent.Flow.Subscription; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import io.grpc.stub.ClientCallStreamObserver; + +public class GrpcRequestBridgeTest { + + private ClientCallStreamObserver grpcObserver; + private Subscription subscription; + private GrpcRequestBridge bridge; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setUp() { + grpcObserver = mock(ClientCallStreamObserver.class); + subscription = mock(Subscription.class); + bridge = new GrpcRequestBridge(grpcObserver); + } + + @Test + public void shouldRequestInitialDemandOnSubscribe() { + bridge.onSubscribe(subscription); + + // Verify gRPC readiness handler is registered + verify(grpcObserver).setOnReadyHandler(any(Runnable.class)); + + // Verify initial demand of 1 is requested from Jooby + verify(subscription).request(1); + } + + @Test + public void shouldDelegateOnNextAndRequestMoreIfReady() { + bridge.onSubscribe(subscription); + + // Reset the mock to clear the initial request(1) from onSubscribe + reset(subscription); + + // Simulate gRPC being ready to receive more data + when(grpcObserver.isReady()).thenReturn(true); + + // Send a complete gRPC frame: Compressed Flag (0) + Length (4) + Payload ("test") + byte[] payload = "test".getBytes(); + ByteBuffer frame = ByteBuffer.allocate(5 + payload.length); + frame.put((byte) 0).putInt(payload.length).put(payload).flip(); + + bridge.onNext(frame); + + // Verify the deframed payload was passed to gRPC + verify(grpcObserver).onNext(payload); + + // Verify backpressure: since gRPC was ready, it should request the next chunk + verify(subscription).request(1); + } + + @Test + public void shouldDelegateOnNextButSuspendDemandIfNotReady() { + bridge.onSubscribe(subscription); + reset(subscription); + + // Simulate gRPC internal buffer being full (not ready) + when(grpcObserver.isReady()).thenReturn(false); + + byte[] payload = "test".getBytes(); + ByteBuffer frame = ByteBuffer.allocate(5 + payload.length); + frame.put((byte) 0).putInt(payload.length).put(payload).flip(); + + bridge.onNext(frame); + + verify(grpcObserver).onNext(payload); + + // Verify backpressure: gRPC is NOT ready, so we MUST NOT request more from Jooby + verify(subscription, never()).request(anyLong()); + } + + @Test + public void shouldResumeDemandWhenGrpcBecomesReady() { + bridge.onSubscribe(subscription); + reset(subscription); + + // Capture the readiness handler registered during onSubscribe + ArgumentCaptor handlerCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(grpcObserver).setOnReadyHandler(handlerCaptor.capture()); + Runnable onReadyHandler = handlerCaptor.getValue(); + + // Simulate gRPC signaling that it is now ready + when(grpcObserver.isReady()).thenReturn(true); + onReadyHandler.run(); + + // Verify backpressure: the handler should resume demanding data + verify(subscription).request(1); + } + + @Test + public void shouldCancelSubscriptionAndDelegateOnError() { + bridge.onSubscribe(subscription); + + Throwable error = new RuntimeException("Network failure"); + bridge.onError(error); + + verify(grpcObserver).onError(error); + } + + @Test + public void shouldDelegateOnCompleteIdempotently() { + bridge.onComplete(); + bridge.onComplete(); // Second call should be ignored + + verify(grpcObserver, times(1)).onCompleted(); + } +} From a38e881433cc54001e82a1f1d651ca242e4d3791 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 12 Mar 2026 14:14:16 -0300 Subject: [PATCH 05/11] Refactor: Decouple gRPC module using server-agnostic SPI This commit removes the gRPC module's tight coupling to Jooby's core request lifecycle (`Context` and `Sender`) and introduces a clean, zero-dependency Service Provider Interface (SPI). This prevents gRPC specifics (like HTTP/2 trailers and framing) from polluting the standard HTTP/1.1 pipeline. Core changes: - Add `GrpcExchange` and `GrpcProcessor` SPI to `jooby-core`. - Refactor `UnifiedGrpcBridge` to act as a pure protocol bridge using Java `Flow` and `ByteBuffer`, entirely isolated from web core classes. - Fix circular dependencies in the reactive `GrpcRequestBridge`. Server Implementations: - Jetty: Implement `JettyGrpcExchange` and `JettyGrpcInputBridge`. Fixed early commit issues by eagerly registering `TrailersSupplier`. - Netty: Implement `NettyGrpcExchange` and `NettyGrpcInputBridge`. Map trailing headers natively via `LastHttpContent` and properly intercept HTTP/2 pipeline streams. - Undertow: Implement `UndertowGrpcExchange`, `UndertowGrpcInputBridge`, and remove hardcoded gRPC checks from standard `UndertowHandler`. Fixed XNIO non-blocking event loop stalls using `wakeupReads()` and implemented manual, synchronous `StreamSinkChannel` flushing to prevent bidirectional deadlocks. All three servers now handle reactive backpressure natively and achieve 100% compliance with strict HTTP/2 clients like `grpcurl`. --- jooby/src/main/java/io/jooby/Context.java | 16 -- .../main/java/io/jooby/DefaultContext.java | 5 - .../main/java/io/jooby/ForwardingContext.java | 11 -- .../src/main/java/io/jooby/GrpcExchange.java | 31 ++++ .../src/main/java/io/jooby/GrpcProcessor.java | 20 ++ jooby/src/main/java/io/jooby/Sender.java | 9 - .../java/io/jooby/internal/HeadContext.java | 5 - modules/jooby-grpc/pom.xml | 5 + .../main/java/io/jooby/grpc/GrpcModule.java | 21 ++- .../java/io/jooby/grpc/GrpcRequestBridge.java | 87 ++++++--- .../java/io/jooby/grpc/UnifiedGrpcBridge.java | 173 ++++++------------ .../internal/jetty/JettyGrpcExchange.java | 100 ++++++++++ .../internal/jetty/JettyGrpcHandler.java | 43 +++-- .../internal/jetty/JettyGrpcInputBridge.java | 92 ++++++++++ .../io/jooby/internal/jetty/JettyHandler.java | 12 +- .../main/java/io/jooby/jetty/JettyServer.java | 11 +- .../internal/netty/NettyGrpcExchange.java | 115 ++++++++++++ .../internal/netty/NettyGrpcHandler.java | 104 +++++++++++ .../internal/netty/NettyGrpcInputBridge.java | 82 +++++++++ .../jooby/internal/netty/NettyPipeline.java | 24 ++- .../main/java/io/jooby/netty/NettyServer.java | 21 ++- .../main/java/io/jooby/test/MockContext.java | 17 +- .../internal/undertow/UndertowContext.java | 18 +- .../undertow/UndertowGrpcExchange.java | 173 ++++++++++++++++++ .../undertow/UndertowGrpcHandler.java | 70 +++---- .../undertow/UndertowGrpcInputBridge.java | 96 ++++++++++ .../internal/undertow/UndertowHandler.java | 19 +- .../internal/undertow/UndertowSender.java | 46 +---- .../io/jooby/undertow/UndertowServer.java | 8 + .../test/java/examples/grpc/GrpcServer.java | 5 +- tests/src/test/resources/logback.xml | 12 +- 31 files changed, 1101 insertions(+), 350 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/GrpcExchange.java create mode 100644 jooby/src/main/java/io/jooby/GrpcProcessor.java create mode 100644 modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcExchange.java create mode 100644 modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcInputBridge.java create mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcExchange.java create mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcHandler.java create mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcInputBridge.java create mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcExchange.java create mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcInputBridge.java diff --git a/jooby/src/main/java/io/jooby/Context.java b/jooby/src/main/java/io/jooby/Context.java index bee9b634d5..5017687243 100644 --- a/jooby/src/main/java/io/jooby/Context.java +++ b/jooby/src/main/java/io/jooby/Context.java @@ -1101,15 +1101,6 @@ default Value lookup(String name) { */ Context setResponseHeader(@NonNull String name, @NonNull String value); - /** - * Set response trailer header. - * - * @param name Header name. - * @param value Header value. - * @return This context. - */ - Context setResponseTrailer(@NonNull String name, @NonNull String value); - /** * Remove a response header. * @@ -1260,13 +1251,6 @@ Context responseStream( * * @return HTTP channel as chunker. Usually for chunked response. */ - Sender responseSender(boolean startResponse); - - /** - * HTTP response channel as chunker. Mark the response as started. - * - * @return HTTP channel as chunker. Usually for chunked response. - */ Sender responseSender(); /** diff --git a/jooby/src/main/java/io/jooby/DefaultContext.java b/jooby/src/main/java/io/jooby/DefaultContext.java index 64366d491b..eec97ffd45 100644 --- a/jooby/src/main/java/io/jooby/DefaultContext.java +++ b/jooby/src/main/java/io/jooby/DefaultContext.java @@ -559,11 +559,6 @@ default Context render(@NonNull Object value) { } } - @Override - default Sender responseSender() { - return responseSender(true); - } - @Override default OutputStream responseStream(@NonNull MediaType contentType) { setResponseType(contentType); diff --git a/jooby/src/main/java/io/jooby/ForwardingContext.java b/jooby/src/main/java/io/jooby/ForwardingContext.java index 74b156d99c..f271c61d11 100644 --- a/jooby/src/main/java/io/jooby/ForwardingContext.java +++ b/jooby/src/main/java/io/jooby/ForwardingContext.java @@ -1096,12 +1096,6 @@ public Context setResponseHeader(@NonNull String name, @NonNull Date value) { return this; } - @Override - public Context setResponseTrailer(@NonNull String name, @NonNull String value) { - ctx.setResponseHeader(name, value); - return this; - } - @Override public Context setResponseHeader(@NonNull String name, @NonNull Instant value) { ctx.setResponseHeader(name, value); @@ -1228,11 +1222,6 @@ public Sender responseSender() { return ctx.responseSender(); } - @Override - public Sender responseSender(boolean startResponse) { - return ctx.responseSender(startResponse); - } - @Override public PrintWriter responseWriter() { return ctx.responseWriter(); diff --git a/jooby/src/main/java/io/jooby/GrpcExchange.java b/jooby/src/main/java/io/jooby/GrpcExchange.java new file mode 100644 index 0000000000..71957e3653 --- /dev/null +++ b/jooby/src/main/java/io/jooby/GrpcExchange.java @@ -0,0 +1,31 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.function.Consumer; + +/** Server-agnostic abstraction for HTTP/2 trailing-header exchanges. */ +public interface GrpcExchange { + + String getRequestPath(); + + String getHeader(String name); + + Map getHeaders(); + + /** Write framed bytes to the underlying non-blocking socket. */ + void send(ByteBuffer payload, Consumer onFailure); + + /** + * Closes the HTTP/2 stream with trailing headers. + * + * @param statusCode The gRPC status code (e.g., 0 for OK, 12 for UNIMPLEMENTED). + * @param description Optional status message. + */ + void close(int statusCode, String description); +} diff --git a/jooby/src/main/java/io/jooby/GrpcProcessor.java b/jooby/src/main/java/io/jooby/GrpcProcessor.java new file mode 100644 index 0000000000..192d3d0b26 --- /dev/null +++ b/jooby/src/main/java/io/jooby/GrpcProcessor.java @@ -0,0 +1,20 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import java.nio.ByteBuffer; +import java.util.concurrent.Flow; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** Intercepts and processes gRPC exchanges. */ +public interface GrpcProcessor { + /** + * @return A subscriber that the server will feed ByteBuffer chunks into, or null if the exchange + * was rejected/unimplemented. + */ + Flow.Subscriber process(@NonNull GrpcExchange exchange); +} diff --git a/jooby/src/main/java/io/jooby/Sender.java b/jooby/src/main/java/io/jooby/Sender.java index 90dae3eef8..7db97c811d 100644 --- a/jooby/src/main/java/io/jooby/Sender.java +++ b/jooby/src/main/java/io/jooby/Sender.java @@ -73,15 +73,6 @@ default Sender write(@NonNull String data, @NonNull Callback callback) { return write(data, StandardCharsets.UTF_8, callback); } - /** - * Set response trailer header. - * - * @param name Header name. - * @param value Header value. - * @return This context. - */ - Sender setTrailer(@NonNull String name, @NonNull String value); - /** * Write a string chunk. Chunk is flushed immediately. * diff --git a/jooby/src/main/java/io/jooby/internal/HeadContext.java b/jooby/src/main/java/io/jooby/internal/HeadContext.java index 06f66e9d22..c4dc99aec9 100644 --- a/jooby/src/main/java/io/jooby/internal/HeadContext.java +++ b/jooby/src/main/java/io/jooby/internal/HeadContext.java @@ -190,11 +190,6 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { return this; } - @Override - public Sender setTrailer(@NonNull String name, @NonNull String value) { - return this; - } - @Override public void close() {} } diff --git a/modules/jooby-grpc/pom.xml b/modules/jooby-grpc/pom.xml index e7c6462400..f215d25fab 100644 --- a/modules/jooby-grpc/pom.xml +++ b/modules/jooby-grpc/pom.xml @@ -18,6 +18,11 @@ ${jooby.version} + + org.slf4j + jul-to-slf4j + ${slf4j.version} + io.grpc grpc-protobuf diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java index 6385f9b865..800175e801 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java @@ -7,14 +7,15 @@ import java.util.List; +import org.slf4j.bridge.SLF4JBridgeHandler; + import edu.umd.cs.findbugs.annotations.NonNull; import io.grpc.BindableService; import io.grpc.Server; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.protobuf.services.ProtoReflectionServiceV1; -import io.jooby.Extension; -import io.jooby.Jooby; +import io.jooby.*; public class GrpcModule implements Extension { private final List services; @@ -22,6 +23,13 @@ public class GrpcModule implements Extension { private final String serverName = "jooby-internal-" + System.nanoTime(); private Server grpcServer; + static { + // Optionally remove existing handlers attached to the j.u.l root logger + SLF4JBridgeHandler.removeHandlersForRootLogger(); + // Install the SLF4J bridge + SLF4JBridgeHandler.install(); + } + public GrpcModule(BindableService... services) { this.services = List.of(services); } @@ -36,7 +44,6 @@ public void install(@NonNull Jooby app) throws Exception { methodRegistry.registerService(service); } - // 2. Register stable gRPC Server Reflection (v1) BindableService reflectionService = ProtoReflectionServiceV1.newInstance(); builder.addService(reflectionService); methodRegistry.registerService(reflectionService); @@ -45,10 +52,14 @@ public void install(@NonNull Jooby app) throws Exception { var channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); - UnifiedGrpcBridge bridge = new UnifiedGrpcBridge(channel, methodRegistry); + var bridge = new UnifiedGrpcBridge(channel, methodRegistry); + // Register it in the Service Registry so the server layer can find it + app.getServices().put(UnifiedGrpcBridge.class, bridge); + + app.getServices().put(GrpcProcessor.class, bridge); // Mount the bridge. - app.post("/*", bridge); + // app.post("/*", bridge); app.onStop( () -> { diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java index 6ac71f4976..85f6d4a58f 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java @@ -13,51 +13,77 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.grpc.ClientCall; +import io.grpc.MethodDescriptor; import io.grpc.stub.ClientCallStreamObserver; -import io.grpc.stub.StreamObserver; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.ClientResponseObserver; public class GrpcRequestBridge implements Subscriber { private final Logger log = LoggerFactory.getLogger(getClass()); - private final ClientCallStreamObserver internalObserver; - private final GrpcDeframer deframer; - private Subscription subscription; + private final GrpcDeframer deframer = new GrpcDeframer(); private final AtomicBoolean completed = new AtomicBoolean(false); - public GrpcRequestBridge(StreamObserver internalObserver) { - this.deframer = new GrpcDeframer(); - this.internalObserver = (ClientCallStreamObserver) internalObserver; + private final ClientCall call; + private final MethodDescriptor.MethodType methodType; + private final boolean isUnaryOrServerStreaming; + + private ClientResponseObserver responseObserver; + private ClientCallStreamObserver requestObserver; + private Subscription subscription; + private byte[] singlePayload; + + public GrpcRequestBridge( + ClientCall call, MethodDescriptor.MethodType methodType) { + this.call = call; + this.methodType = methodType; + this.isUnaryOrServerStreaming = + methodType == MethodDescriptor.MethodType.UNARY + || methodType == MethodDescriptor.MethodType.SERVER_STREAMING; + } + + public void setResponseObserver(ClientResponseObserver responseObserver) { + this.responseObserver = responseObserver; + } + + public void setRequestObserver(ClientCallStreamObserver requestObserver) { + this.requestObserver = requestObserver; + } + + public void onGrpcReady() { + if (subscription != null + && requestObserver != null + && requestObserver.isReady() + && !completed.get()) { + subscription.request(1); + } } @Override public void onSubscribe(Subscription subscription) { this.subscription = subscription; - - // Wire gRPC readiness to Jooby's Flow.Subscription - internalObserver.setOnReadyHandler( - () -> { - if (internalObserver.isReady() && !completed.get()) { - subscription.request(1); - } - }); - - // Initial demand + // Initial demand to kick off the network body reader subscription.request(1); } @Override public void onNext(ByteBuffer item) { try { - // Pass the zero-copy buffer straight to the deframer deframer.process( item, msg -> { - internalObserver.onNext(msg); + if (isUnaryOrServerStreaming) { + singlePayload = msg; + } else { + requestObserver.onNext(msg); + } }); - // Only request more from the server if gRPC is ready - if (internalObserver.isReady()) { - subscription.request(1); + if (isUnaryOrServerStreaming) { + subscription.request(1); // Keep reading until EOF for unary/server-streaming + } else if (requestObserver != null && requestObserver.isReady()) { + subscription.request(1); // Ask for more if the streaming gRPC buffer is ready } } catch (Throwable t) { subscription.cancel(); @@ -69,14 +95,27 @@ public void onNext(ByteBuffer item) { public void onError(Throwable throwable) { if (completed.compareAndSet(false, true)) { log.error("Error in gRPC request stream", throwable); - internalObserver.onError(throwable); + if (requestObserver != null) { + requestObserver.onError(throwable); + } else if (responseObserver != null) { + responseObserver.onError(throwable); + } } } @Override public void onComplete() { if (completed.compareAndSet(false, true)) { - internalObserver.onCompleted(); + if (isUnaryOrServerStreaming) { + byte[] payload = singlePayload == null ? new byte[0] : singlePayload; + if (methodType == MethodDescriptor.MethodType.UNARY) { + ClientCalls.asyncUnaryCall(call, payload, responseObserver); + } else { + ClientCalls.asyncServerStreamingCall(call, payload, responseObserver); + } + } else if (requestObserver != null) { + requestObserver.onCompleted(); + } } } } diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java index 0d2522a51c..a05c91c567 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java @@ -9,23 +9,25 @@ import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.Flow; import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; -import io.grpc.*; +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.ManagedChannel; +import io.grpc.MethodDescriptor; +import io.grpc.Status; import io.grpc.stub.ClientCallStreamObserver; import io.grpc.stub.ClientCalls; import io.grpc.stub.ClientResponseObserver; -import io.grpc.stub.StreamObserver; -import io.jooby.Context; -import io.jooby.Route; -import io.jooby.Sender; +import io.jooby.GrpcExchange; +import io.jooby.GrpcProcessor; -public class UnifiedGrpcBridge implements Route.Handler { +public class UnifiedGrpcBridge implements GrpcProcessor { // Minimal Marshaller to pass raw bytes through the bridge private static class RawMarshaller implements MethodDescriptor.Marshaller { @@ -54,20 +56,16 @@ public UnifiedGrpcBridge(ManagedChannel channel, GrpcMethodRegistry methodRegist } @Override - public Object apply(@NonNull Context ctx) { - // Setup gRPC response headers - ctx.setResponseType("application/grpc"); - + public Flow.Subscriber process(GrpcExchange exchange) { // Route paths: /{package.Service}/{Method} - String path = ctx.getRequestPath(); + String path = exchange.getRequestPath(); // Remove the leading slash to match the gRPC method registry format var descriptor = methodRegistry.get(path.substring(1)); if (descriptor == null) { log.warn("Method not found in bridge registry: {}", path); - ctx.setResponseTrailer("grpc-status", String.valueOf(Status.UNIMPLEMENTED.getCode().value())); - ctx.setResponseTrailer("grpc-message", "Method not found"); - return ctx.send(""); + exchange.close(Status.UNIMPLEMENTED.getCode().value(), "Method not found"); + return null; } var method = @@ -78,44 +76,47 @@ public Object apply(@NonNull Context ctx) { .setResponseMarshaller(new RawMarshaller()) .build(); - // 1. Propagate Call Options (Deadlines) - CallOptions callOptions = extractCallOptions(ctx); - - // 2. Propagate HTTP Headers to gRPC Metadata - Metadata metadata = extractMetadata(ctx); + CallOptions callOptions = extractCallOptions(exchange); + io.grpc.Metadata metadata = extractMetadata(exchange); - // Attach the metadata to the channel using an interceptor io.grpc.Channel interceptedChannel = io.grpc.ClientInterceptors.intercept( channel, io.grpc.stub.MetadataUtils.newAttachHeadersInterceptor(metadata)); - // Create the call using the intercepted channel and the configured options ClientCall call = interceptedChannel.newCall(method, callOptions); - - Sender sender = ctx.responseSender(false); AtomicBoolean isFinished = new AtomicBoolean(false); - // Unified Response Observer (Handles data coming BACK from the server) + boolean isUnaryOrServerStreaming = + method.getType() == MethodDescriptor.MethodType.UNARY + || method.getType() == MethodDescriptor.MethodType.SERVER_STREAMING; + + // 1. Create the effectively final bridge + GrpcRequestBridge requestBridge = new GrpcRequestBridge(call, method.getType()); + ClientResponseObserver responseObserver = new ClientResponseObserver<>() { + @Override public void beforeStart(ClientCallStreamObserver requestStream) { - requestStream.disableAutoInboundFlowControl(); + if (!isUnaryOrServerStreaming) { + // requestStream.disableAutoInboundFlowControl(); + // Wire the readiness callback securely to the bridge + requestStream.setOnReadyHandler(requestBridge::onGrpcReady); + requestBridge.setRequestObserver(requestStream); + } } @Override public void onNext(byte[] value) { if (isFinished.get()) return; - byte[] framed = addGrpcHeader(value); - sender.write( + ByteBuffer framed = addGrpcHeader(value); + + exchange.send( framed, - new Sender.Callback() { - @Override - public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) { - if (cause != null) { - onError(cause); - } + cause -> { + if (cause != null) { + onError(cause); } }); } @@ -125,50 +126,32 @@ public void onError(Throwable t) { if (isFinished.compareAndSet(false, true)) { log.debug("gRPC stream error", t); Status status = Status.fromThrowable(t); - sender.setTrailer("grpc-status", String.valueOf(status.getCode().value())); - if (status.getDescription() != null) { - sender.setTrailer("grpc-message", status.getDescription()); - } - sender.close(); + exchange.close(status.getCode().value(), status.getDescription()); } } @Override public void onCompleted() { if (isFinished.compareAndSet(false, true)) { - sender.setTrailer("grpc-status", "0"); - sender.close(); + exchange.close(Status.OK.getCode().value(), null); } } }; - // Map gRPC Method Type to the correct ClientCalls utility - StreamObserver requestObserver = - switch (method.getType()) { - case UNARY -> ClientCalls.asyncBidiStreamingCall(call, responseObserver); - case BIDI_STREAMING, CLIENT_STREAMING -> - ClientCalls.asyncBidiStreamingCall(call, responseObserver); - case SERVER_STREAMING -> wrapServerStreaming(call, responseObserver); - default -> null; - }; + // 2. Inject the observer to break the circular dependency + requestBridge.setResponseObserver(responseObserver); - if (requestObserver == null) { - ctx.setResponseTrailer("grpc-status", String.valueOf(Status.INTERNAL.getCode().value())); - ctx.setResponseTrailer("grpc-message", "Unsupported method type"); - return ctx.send(""); + if (!isUnaryOrServerStreaming) { + ClientCalls.asyncBidiStreamingCall(call, responseObserver); } - // Return the reactive subscriber to let Jooby pipe the request stream into it - return new GrpcRequestBridge(requestObserver); + return requestBridge; } - /** - * Extracts the grpc-timeout header and applies it to CallOptions. gRPC timeout format: - * {TimeoutValue}{TimeoutUnit} (e.g., 100m = 100 milliseconds, 10S = 10 seconds). - */ - private CallOptions extractCallOptions(Context ctx) { + /** Extracts the grpc-timeout header and applies it to CallOptions. */ + private CallOptions extractCallOptions(GrpcExchange exchange) { CallOptions options = CallOptions.DEFAULT; - String timeout = ctx.header("grpc-timeout").valueOrNull(); + String timeout = exchange.getHeader("grpc-timeout"); if (timeout == null || timeout.isEmpty()) { return options; @@ -199,17 +182,13 @@ private CallOptions extractCallOptions(Context ctx) { return options; } - /** - * Maps standard HTTP headers from Jooby into gRPC Metadata. Skips HTTP/2 pseudo-headers and gRPC - * internal headers. - */ - private Metadata extractMetadata(Context ctx) { - Metadata metadata = new Metadata(); + /** Maps standard HTTP headers from the GrpcExchange into gRPC Metadata. */ + private io.grpc.Metadata extractMetadata(GrpcExchange exchange) { + io.grpc.Metadata metadata = new io.grpc.Metadata(); - for (java.util.Map.Entry header : ctx.headerMap().entrySet()) { + for (Map.Entry header : exchange.getHeaders().entrySet()) { String key = header.getKey().toLowerCase(); - // Ignore internal HTTP/2 and gRPC headers if (key.startsWith(":") || key.startsWith("grpc-") || key.equals("content-type") @@ -217,61 +196,27 @@ private Metadata extractMetadata(Context ctx) { continue; } - // If binary header (ends with -bin), gRPC requires base64 decoding. - // Standard string headers are passed directly. if (key.endsWith("-bin")) { - Metadata.Key metaKey = Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER); + io.grpc.Metadata.Key metaKey = + io.grpc.Metadata.Key.of(key, io.grpc.Metadata.BINARY_BYTE_MARSHALLER); byte[] decoded = java.util.Base64.getDecoder().decode(header.getValue()); metadata.put(metaKey, decoded); } else { - Metadata.Key metaKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + io.grpc.Metadata.Key metaKey = + io.grpc.Metadata.Key.of(key, io.grpc.Metadata.ASCII_STRING_MARSHALLER); metadata.put(metaKey, header.getValue()); } } return metadata; } - private StreamObserver wrapServerStreaming( - ClientCall call, StreamObserver responseObserver) { - // Server streaming takes 1 request and returns an observer for the result stream - return new StreamObserver<>() { - private boolean sent = false; - - @Override - public void onNext(byte[] value) { - if (!sent) { - ClientCalls.asyncServerStreamingCall(call, value, responseObserver); - sent = true; - } - } - - @Override - public void onError(Throwable t) { - responseObserver.onError(t); - } - - @Override - public void onCompleted() { - /* Server side handles completion */ - } - }; - } - - /** - * Prepends the 5-byte gRPC header to the payload using a ByteBuffer. - * - * @param payload The raw binary message from the internal gRPC service. - * @return A new byte array containing [Flag][Length][Payload]. - */ - private byte[] addGrpcHeader(byte[] payload) { + /** Prepends the 5-byte gRPC header and returns a ready-to-write ByteBuffer. */ + private ByteBuffer addGrpcHeader(byte[] payload) { ByteBuffer buffer = ByteBuffer.allocate(5 + payload.length); - // 1. Compression Flag (0 = none) - buffer.put((byte) 0); - // 2. Encode Length as 4-byte Big Endian integer + buffer.put((byte) 0); // Compressed flag (0 = none) buffer.putInt(payload.length); - // 3. Copy the actual payload buffer.put(payload); - - return buffer.array(); + buffer.flip(); // Prepare the buffer for reading by the server socket + return buffer; } } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcExchange.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcExchange.java new file mode 100644 index 0000000000..8d2d0ebdea --- /dev/null +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcExchange.java @@ -0,0 +1,100 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jetty; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +import io.jooby.GrpcExchange; + +public class JettyGrpcExchange implements GrpcExchange { + + private final Request request; + private final Response response; + private final Callback jettyCallback; + private boolean headersSent = false; + + // Create a mutable trailers object that Jetty will pull from at the end of the stream + private final HttpFields.Mutable trailers = HttpFields.build(); + + public JettyGrpcExchange(Request request, Response response, Callback jettyCallback) { + this.request = request; + this.response = response; + this.jettyCallback = jettyCallback; + + response.getHeaders().put("Content-Type", "application/grpc"); + + // CRITICAL FIX: Register the supplier BEFORE the response commits + response.setTrailersSupplier(() -> trailers); + } + + @Override + public String getRequestPath() { + return request.getHttpURI().getPath(); + } + + @Override + public String getHeader(String name) { + return request.getHeaders().get(name); + } + + @Override + public Map getHeaders() { + Map map = new HashMap<>(); + for (var field : request.getHeaders()) { + map.put(field.getName(), field.getValue()); + } + return map; + } + + @Override + public void send(ByteBuffer payload, Consumer callback) { + headersSent = true; + + response.write( + false, + payload, + new Callback() { + @Override + public void succeeded() { + callback.accept(null); + } + + @Override + public void failed(Throwable x) { + callback.accept(x); + } + }); + } + + @Override + public void close(int statusCode, String description) { + if (headersSent) { + // Trailers-Appended: Data was sent, populate the mutable trailers object + trailers.add("grpc-status", String.valueOf(statusCode)); + if (description != null) { + trailers.add("grpc-message", description); + } + + // Complete stream. Jetty will automatically read from the supplier we registered earlier. + response.write(true, ByteBuffer.allocate(0), jettyCallback); + } else { + // Trailers-Only: No data was sent, trailers become standard HTTP headers. + response.getHeaders().put("grpc-status", String.valueOf(statusCode)); + if (description != null) { + response.getHeaders().put("grpc-message", description); + } + response.write(true, ByteBuffer.allocate(0), jettyCallback); + } + } +} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java index ded27ef280..d444932347 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java @@ -5,35 +5,48 @@ */ package io.jooby.internal.jetty; +import java.nio.ByteBuffer; import java.util.concurrent.Flow; -import java.util.function.Function; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.util.Callback; -import io.jooby.Context; +import io.jooby.GrpcExchange; +import io.jooby.GrpcProcessor; -/** A professional Jetty Handler that bridges HTTP/2 streams to a gRPC Subscriber. */ -public class JettyGrpcHandler extends Handler.Abstract { +public class JettyGrpcHandler extends Handler.Wrapper { - private final Function> subscriberFactory; - private final Context ctx; + private final GrpcProcessor processor; - public JettyGrpcHandler( - io.jooby.Context ctx, Function> subscriberFactory) { - this.ctx = ctx; - this.subscriberFactory = subscriberFactory; + public JettyGrpcHandler(Handler next, GrpcProcessor processor) { + this.processor = processor; + setHandler(next); } @Override - public boolean handle(Request request, Response response, Callback callback) { - Flow.Subscriber subscriber = subscriberFactory.apply(ctx); + public boolean handle(Request request, Response response, Callback callback) throws Exception { + String contentType = request.getHeaders().get("Content-Type"); - JettyRequestPublisher publisher = new JettyRequestPublisher(request); - publisher.subscribe(subscriber); + if (contentType != null && contentType.startsWith("application/grpc")) { - return true; + if (!"HTTP/2.0".equals(request.getConnectionMetaData().getProtocol())) { + response.setStatus(426); // Upgrade Required + response.getHeaders().put("Connection", "Upgrade"); + response.getHeaders().put("Upgrade", "h2c"); + callback.succeeded(); + return true; + } + + GrpcExchange exchange = new JettyGrpcExchange(request, response, callback); + Flow.Subscriber subscriber = processor.process(exchange); + + new JettyGrpcInputBridge(request, subscriber, callback).start(); + + return true; + } + + return super.handle(request, response, callback); } } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcInputBridge.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcInputBridge.java new file mode 100644 index 0000000000..910906cf70 --- /dev/null +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcInputBridge.java @@ -0,0 +1,92 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jetty; + +import java.nio.ByteBuffer; +import java.util.concurrent.CancellationException; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicLong; + +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.util.Callback; + +public class JettyGrpcInputBridge implements Flow.Subscription, Runnable { + + private final Request request; + private final Flow.Subscriber subscriber; + private final Callback callback; + private final AtomicLong demand = new AtomicLong(); + + public JettyGrpcInputBridge( + Request request, Flow.Subscriber subscriber, Callback callback) { + this.request = request; + this.subscriber = subscriber; + this.callback = callback; + } + + public void start() { + subscriber.onSubscribe(this); + } + + @Override + public void request(long n) { + if (n <= 0) { + subscriber.onError(new IllegalArgumentException("Demand must be positive")); + return; + } + + if (demand.getAndAdd(n) == 0) { + run(); + } + } + + @Override + public void cancel() { + demand.set(0); + callback.failed(new CancellationException("gRPC stream cancelled by client")); + } + + @Override + public void run() { + try { + while (demand.get() > 0) { + Content.Chunk chunk = request.read(); + + if (chunk == null) { + request.demand(this); + return; + } + + try { + Throwable failure = chunk.getFailure(); + if (failure != null) { + subscriber.onError(failure); + callback.failed(failure); + return; + } + + ByteBuffer buffer = chunk.getByteBuffer(); + if (buffer != null && buffer.hasRemaining()) { + subscriber.onNext(buffer); + demand.decrementAndGet(); + } + + if (chunk.isLast()) { + subscriber.onComplete(); + return; + } + + } finally { + chunk.release(); + } + } + } catch (Throwable t) { + subscriber.onError(t); + callback.failed(t); + } + } +} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java index ebe378783b..c1aeba9972 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java @@ -5,16 +5,12 @@ */ package io.jooby.internal.jetty; -import java.util.function.Function; - -import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.util.Callback; import io.jooby.Router; -import io.jooby.ServiceKey; import io.jooby.internal.jetty.http2.JettyHeaders; public class JettyHandler extends Handler.Abstract { @@ -47,13 +43,7 @@ public boolean handle(Request request, Response response, Callback callback) { var context = new JettyContext( getInvocationType(), request, response, callback, router, bufferSize, maxRequestSize); - if (!"POST".equalsIgnoreCase(request.getMethod()) - || !request.getHeaders().contains(HttpHeader.CONTENT_TYPE, "application/grpc")) { - router.match(context).execute(context); - } else { - var subscriber = router.require(ServiceKey.key(Function.class, "gRPC")); - new JettyGrpcHandler(context, subscriber).handle(request, response, callback); - } + router.match(context).execute(context); } catch (JettyStopPipeline ignored) { // handled already, } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java b/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java index 6188177e03..ab25f22b3a 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java @@ -32,9 +32,7 @@ import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.*; import io.jooby.exception.StartupException; -import io.jooby.internal.jetty.JettyHandler; -import io.jooby.internal.jetty.JettyHttpExpectAndContinueHandler; -import io.jooby.internal.jetty.PrefixHandler; +import io.jooby.internal.jetty.*; import io.jooby.internal.jetty.http2.JettyHttp2Configurer; import io.jooby.output.OutputFactory; @@ -303,12 +301,17 @@ private List> createHandler( if (options.isExpectContinue() == Boolean.TRUE) { handler = new JettyHttpExpectAndContinueHandler(handler); } + GrpcProcessor grpcProcessor = + application.getServices().getOrNull(GrpcProcessor.class); + if (grpcProcessor != null) { + handler = new JettyGrpcHandler(handler, grpcProcessor); + } return Map.entry(application.getContextPath(), handler); }) .toList(); } - @NonNull @Override + @Override public List getLoggerOff() { return List.of( "org.eclipse.jetty.server.Server", diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcExchange.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcExchange.java new file mode 100644 index 0000000000..5a4b4adb1b --- /dev/null +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcExchange.java @@ -0,0 +1,115 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.netty; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import io.jooby.GrpcExchange; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.DefaultHttpResponse; +import io.netty.handler.codec.http.DefaultLastHttpContent; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.LastHttpContent; + +public class NettyGrpcExchange implements GrpcExchange { + + private final ChannelHandlerContext ctx; + private final HttpRequest request; + private boolean headersSent = false; + + public NettyGrpcExchange(ChannelHandlerContext ctx, HttpRequest request) { + this.ctx = ctx; + this.request = request; + } + + @Override + public String getRequestPath() { + String uri = request.uri(); + int queryIndex = uri.indexOf('?'); + return queryIndex > 0 ? uri.substring(0, queryIndex) : uri; + } + + @Override + public String getHeader(String name) { + return request.headers().get(name); + } + + @Override + public Map getHeaders() { + Map map = new HashMap<>(); + for (Map.Entry entry : request.headers()) { + map.put(entry.getKey(), entry.getValue()); + } + return map; + } + + private void sendHeadersIfNecessary() { + if (!headersSent) { + // Send the initial HTTP/2 HEADERS frame (Status 200) + HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/grpc"); + ctx.write(response); + headersSent = true; + } + } + + @Override + public void send(ByteBuffer payload, Consumer callback) { + sendHeadersIfNecessary(); + + // Wrap the NIO ByteBuffer in a Netty ByteBuf without copying + HttpContent chunk = new DefaultHttpContent(Unpooled.wrappedBuffer(payload)); + + // Write and flush, then map Netty's Future to your single-lambda callback + ctx.writeAndFlush(chunk) + .addListener( + future -> { + if (future.isSuccess()) { + callback.accept(null); + } else { + callback.accept(future.cause()); + } + }); + } + + @Override + public void close(int statusCode, String description) { + if (headersSent) { + // Trailers-Appended: Send the final HTTP/2 HEADERS frame with END_STREAM flag + LastHttpContent lastContent = new DefaultLastHttpContent(); + lastContent.trailingHeaders().set("grpc-status", String.valueOf(statusCode)); + if (description != null) { + lastContent.trailingHeaders().set("grpc-message", description); + } + // writeAndFlush the LastHttpContent, then close the Netty stream channel + ctx.writeAndFlush(lastContent).addListener(ChannelFutureListener.CLOSE); + } else { + // Trailers-Only: No body was sent, so standard headers become the trailers + HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/grpc"); + response.headers().set("grpc-status", String.valueOf(statusCode)); + if (description != null) { + response.headers().set("grpc-message", description); + } + ctx.write(response); + + // Close out the stream with an empty DATA frame possessing the END_STREAM flag + ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) + .addListener(ChannelFutureListener.CLOSE); + } + } +} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcHandler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcHandler.java new file mode 100644 index 0000000000..8217ba97e1 --- /dev/null +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcHandler.java @@ -0,0 +1,104 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.netty; + +import java.nio.ByteBuffer; +import java.util.concurrent.Flow; + +import io.jooby.GrpcExchange; +import io.jooby.GrpcProcessor; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.handler.codec.http.*; +import io.netty.util.ReferenceCountUtil; + +public class NettyGrpcHandler extends ChannelInboundHandlerAdapter { + + private final GrpcProcessor processor; + private final boolean isHttp2; + + // State for the current stream + private boolean isGrpc = false; + private NettyGrpcInputBridge inputBridge; + + public NettyGrpcHandler(GrpcProcessor processor, boolean isHttp2) { + this.processor = processor; + this.isHttp2 = isHttp2; + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + + // 1. Intercept the initial Request headers + if (msg instanceof HttpRequest req) { + String contentType = req.headers().get(HttpHeaderNames.CONTENT_TYPE); + + if (contentType != null && contentType.startsWith("application/grpc")) { + isGrpc = true; + + if (!isHttp2) { + // gRPC requires HTTP/2. Reject HTTP/1.1 calls immediately. + var response = + new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, HttpResponseStatus.UPGRADE_REQUIRED); + response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE); + response.headers().set(HttpHeaderNames.UPGRADE, "h2c"); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + ReferenceCountUtil.release(msg); + return; + } + + // We will implement NettyGrpcExchange in the next step + GrpcExchange exchange = new NettyGrpcExchange(ctx, req); + Flow.Subscriber subscriber = processor.process(exchange); + + if (subscriber != null) { + inputBridge = new NettyGrpcInputBridge(ctx, subscriber); + inputBridge.start(); + } else { + // Exchange was rejected/closed internally by processor (e.g. Unimplemented) + } + + ReferenceCountUtil.release(msg); // We consumed the headers + return; + } + } + + // 2. Intercept subsequent body chunks for this gRPC stream + if (isGrpc && msg instanceof HttpContent chunk) { + try { + if (inputBridge != null) { + inputBridge.onChunk(chunk); + } + } finally { + // Always release Netty's direct memory buffers + ReferenceCountUtil.release(chunk); + } + return; + } + + // Not a gRPC request. Pass down the pipeline to Jooby's NettyHandler + super.channelRead(ctx, msg); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + if (isGrpc && inputBridge != null) { + inputBridge.cancel(); // Client disconnected abruptly + } + super.channelInactive(ctx); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + if (isGrpc) { + ctx.close(); + } else { + super.exceptionCaught(ctx, cause); + } + } +} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcInputBridge.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcInputBridge.java new file mode 100644 index 0000000000..48457c2aff --- /dev/null +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcInputBridge.java @@ -0,0 +1,82 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.netty; + +import java.nio.ByteBuffer; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicLong; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.LastHttpContent; + +public class NettyGrpcInputBridge implements Flow.Subscription { + + private final ChannelHandlerContext ctx; + private final Flow.Subscriber subscriber; + private final AtomicLong demand = new AtomicLong(); + + public NettyGrpcInputBridge(ChannelHandlerContext ctx, Flow.Subscriber subscriber) { + this.ctx = ctx; + this.subscriber = subscriber; + } + + public void start() { + // Disable auto-read. We will manually request reads based on gRPC demand. + ctx.channel().config().setAutoRead(false); + subscriber.onSubscribe(this); + } + + @Override + public void request(long n) { + if (n <= 0) { + subscriber.onError(new IllegalArgumentException("Demand must be positive")); + return; + } + + if (demand.getAndAdd(n) == 0) { + // We transitioned from 0 to n demand, trigger a read from the socket + ctx.read(); + } + } + + @Override + public void cancel() { + demand.set(0); + ctx.close(); // Abort the connection + } + + /** Called by the NettyGrpcHandler when a new chunk arrives from the network. */ + public void onChunk(HttpContent chunk) { + try { + ByteBuf content = chunk.content(); + if (content.isReadable()) { + // Convert Netty ByteBuf to standard Java ByteBuffer + ByteBuffer buffer = content.nioBuffer(); + + // Pass to the gRPC deframer + subscriber.onNext(buffer); + + long currentDemand = demand.decrementAndGet(); + if (currentDemand > 0) { + // Still have demand, ask Netty for the next chunk + ctx.read(); + } + } + + if (chunk instanceof LastHttpContent) { + subscriber.onComplete(); + } else if (demand.get() > 0 && !content.isReadable()) { + // Edge case: Empty chunk but not LastHttpContent, read next + ctx.read(); + } + } catch (Throwable t) { + subscriber.onError(t); + ctx.close(); + } + } +} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java index c20a6bcea5..bfafa63fdb 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java @@ -9,6 +9,7 @@ import java.util.concurrent.ScheduledExecutorService; import io.jooby.Context; +import io.jooby.GrpcProcessor; import io.netty.buffer.ByteBuf; import io.netty.channel.*; import io.netty.channel.socket.SocketChannel; @@ -33,6 +34,7 @@ public class NettyPipeline extends ChannelInitializer { private final boolean expectContinue; private final Integer compressionLevel; private final NettyDateService dateService; + private final GrpcProcessor grpcProcessor; public NettyPipeline( SslContext sslContext, @@ -45,7 +47,8 @@ public NettyPipeline( boolean http2, boolean expectContinue, Integer compressionLevel, - NettyDateService dateService) { + NettyDateService dateService, + GrpcProcessor grpcProcessor) { this.sslContext = sslContext; this.decoderConfig = decoderConfig; this.contextSelector = contextSelector; @@ -57,6 +60,7 @@ public NettyPipeline( this.expectContinue = expectContinue; this.compressionLevel = compressionLevel; this.dateService = dateService; + this.grpcProcessor = grpcProcessor; } @Override @@ -77,6 +81,12 @@ public void initChannel(SocketChannel ch) { private void setupHttp11(ChannelPipeline p) { p.addLast("codec", createServerCodec()); addCommonHandlers(p); + + // Inject gRPC handler (isHttp2 = false to trigger 426 Upgrade Required) + if (grpcProcessor != null) { + p.addLast("grpc", new NettyGrpcHandler(grpcProcessor, false)); + } + p.addLast("handler", createHandler(p.channel().eventLoop())); } @@ -103,6 +113,12 @@ private void setupHttp11Upgrade(ChannelPipeline pipeline) { (int) maxRequestSize)); addCommonHandlers(pipeline); + + // Inject gRPC handler (isHttp2 = false to trigger 426 Upgrade Required) + if (grpcProcessor != null) { + pipeline.addLast("grpc", new NettyGrpcHandler(grpcProcessor, false)); + } + pipeline.addLast("handler", createHandler(pipeline.channel().eventLoop())); } @@ -196,6 +212,12 @@ private static class Http2StreamInitializer extends ChannelInitializer @Override protected void initChannel(Channel ch) { ch.pipeline().addLast("http2", new Http2StreamFrameToHttpObjectCodec(true)); + + // Inject gRPC handler (isHttp2 = true). This handles the actual multiplexed gRPC traffic. + if (pipeline.grpcProcessor != null) { + ch.pipeline().addLast("grpc", new NettyGrpcHandler(pipeline.grpcProcessor, true)); + } + ch.pipeline().addLast("handler", pipeline.createHandler(ch.eventLoop())); } } diff --git a/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java b/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java index 2f62da8fb2..f798a43c1d 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java +++ b/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java @@ -155,9 +155,16 @@ public Server start(@NonNull Jooby... application) { var outputFactory = (NettyOutputFactory) getOutputFactory(); var allocator = outputFactory.getAllocator(); var http2 = options.isHttp2() == Boolean.TRUE; + + // Retrieve the GrpcProcessor from the application's service registry + GrpcProcessor grpcProcessor = + http2 ? applications.get(0).getServices().getOrNull(GrpcProcessor.class) : null; + /* Bootstrap: */ if (!options.isHttpsOnly()) { - var http = newBootstrap(allocator, transport, newPipeline(options, null, http2), eventLoop); + var http = + newBootstrap( + allocator, transport, newPipeline(options, null, http2, grpcProcessor), eventLoop); http.bind(options.getHost(), options.getPort()).get(); } @@ -170,7 +177,11 @@ public Server start(@NonNull Jooby... application) { var clientAuth = sslOptions.getClientAuth(); var sslContext = wrap(javaSslContext, toClientAuth(clientAuth), protocol, http2); var https = - newBootstrap(allocator, transport, newPipeline(options, sslContext, http2), eventLoop); + newBootstrap( + allocator, + transport, + newPipeline(options, sslContext, http2, grpcProcessor), + eventLoop); portInUse = options.getSecurePort(); https.bind(options.getHost(), portInUse).get(); } else if (options.isHttpsOnly()) { @@ -216,7 +227,8 @@ private ClientAuth toClientAuth(SslOptions.ClientAuth clientAuth) { }; } - private NettyPipeline newPipeline(ServerOptions options, SslContext sslContext, boolean http2) { + private NettyPipeline newPipeline( + ServerOptions options, SslContext sslContext, boolean http2, GrpcProcessor grpcProcessor) { var decoderConfig = new HttpDecoderConfig() .setMaxInitialLineLength(_4KB) @@ -235,7 +247,8 @@ private NettyPipeline newPipeline(ServerOptions options, SslContext sslContext, http2, options.isExpectContinue() == Boolean.TRUE, options.getCompressionLevel(), - dateLoop); + dateLoop, + grpcProcessor); } @Override diff --git a/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java b/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java index e8ab24cf6a..66686d4189 100644 --- a/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java +++ b/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java @@ -87,8 +87,6 @@ public class MockContext implements DefaultContext { private Map responseHeaders = new HashMap<>(); - private Map responseTrailers = new HashMap<>(); - private Map attributes = new HashMap<>(); private MockResponse response = new MockResponse(); @@ -499,12 +497,6 @@ public MockContext setResponseHeader(@NonNull String name, @NonNull String value return this; } - @Override - public MockContext setResponseTrailer(@NonNull String name, @NonNull String value) { - responseTrailers.put(name, value); - return this; - } - @Override public MockContext setResponseLength(long length) { response.setContentLength(length); @@ -565,8 +557,8 @@ public OutputStream responseStream() { } @Override - public Sender responseSender(boolean startResponse) { - responseStarted = startResponse; + public Sender responseSender() { + responseStarted = true; return new Sender() { @Override public Sender write(@NonNull byte[] data, @NonNull Callback callback) { @@ -575,11 +567,6 @@ public Sender write(@NonNull byte[] data, @NonNull Callback callback) { return this; } - @Override - public Sender setTrailer(@NonNull String name, @NonNull String value) { - return this; - } - @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { response.setResult(output); diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java index 46b2134c65..7eba730fce 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java @@ -46,14 +46,12 @@ import io.undertow.server.RenegotiationRequiredException; import io.undertow.server.SSLSessionInfo; import io.undertow.server.handlers.form.FormData; -import io.undertow.server.protocol.http.HttpAttachments; import io.undertow.util.*; public class UndertowContext implements DefaultContext, IoCallback { private static final ByteBuffer EMPTY = ByteBuffer.wrap(new byte[0]); private Route route; HttpServerExchange exchange; - HeaderMap trailers; private Router router; private QueryString query; private Formdata formdata; @@ -333,16 +331,6 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } - @Override - public Context setResponseTrailer(@NonNull String name, @NonNull String value) { - if (trailers == null) { - trailers = new HeaderMap(); - exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, trailers); - } - trailers.put(HttpString.tryFromString(name), value); - return this; - } - @NonNull @Override public Context removeResponseHeader(@NonNull String name) { exchange.getResponseHeaders().remove(name); @@ -424,8 +412,8 @@ public OutputStream responseStream() { } @NonNull @Override - public io.jooby.Sender responseSender(boolean startResponse) { - return new UndertowSender(this); + public io.jooby.Sender responseSender() { + return new UndertowSender(this, exchange); } @NonNull @Override @@ -487,7 +475,7 @@ public Context send(@NonNull ByteBuffer[] data) { public Context send(@NonNull ByteBuffer data) { ifUnDispatch(data); exchange.setResponseContentLength(data.remaining()); - // exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, Long.toString(data.remaining())); + exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, Long.toString(data.remaining())); exchange.getResponseSender().send(data, this); return this; } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcExchange.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcExchange.java new file mode 100644 index 0000000000..139061f54d --- /dev/null +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcExchange.java @@ -0,0 +1,173 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.undertow; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.xnio.channels.StreamSinkChannel; + +import io.jooby.GrpcExchange; +import io.undertow.server.HttpServerExchange; +import io.undertow.server.protocol.http.HttpAttachments; +import io.undertow.util.HeaderMap; +import io.undertow.util.HeaderValues; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; + +public class UndertowGrpcExchange implements GrpcExchange { + + private final HttpServerExchange exchange; + private boolean headersSent = false; + private StreamSinkChannel responseChannel; + + public UndertowGrpcExchange(HttpServerExchange exchange) { + this.exchange = exchange; + } + + @Override + public String getRequestPath() { + return exchange.getRequestPath(); + } + + @Override + public String getHeader(String name) { + return exchange.getRequestHeaders().getFirst(name); + } + + @Override + public Map getHeaders() { + Map map = new HashMap<>(); + for (HeaderValues values : exchange.getRequestHeaders()) { + map.put(values.getHeaderName().toString(), values.getFirst()); + } + return map; + } + + @Override + public void send(ByteBuffer payload, Consumer callback) { + if (!headersSent) { + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/grpc"); + this.responseChannel = exchange.getResponseChannel(); + headersSent = true; + } + + // Write and immediately flush to prevent bidirectional deadlocks + doWriteAndFlush(payload, callback); + } + + private void doWriteAndFlush(ByteBuffer payload, Consumer callback) { + try { + int res = responseChannel.write(payload); + + if (payload.hasRemaining()) { + // Wait for socket to become writable + responseChannel + .getWriteSetter() + .set( + ch -> { + try { + ch.write(payload); + if (!payload.hasRemaining()) { + ch.suspendWrites(); + doFlush(callback); // Proceed to flush + } + } catch (IOException e) { + ch.suspendWrites(); + callback.accept(e); + } + }); + responseChannel.resumeWrites(); + } else { + // Written fully, proceed to flush immediately + doFlush(callback); + } + } catch (IOException e) { + callback.accept(e); + } + } + + private void doFlush(Consumer callback) { + try { + if (responseChannel.flush()) { + callback.accept(null); // Fully flushed to network + } else { + // Wait for socket to become flushable + responseChannel + .getWriteSetter() + .set( + ch -> { + try { + if (ch.flush()) { + ch.suspendWrites(); + callback.accept(null); + } + } catch (IOException e) { + ch.suspendWrites(); + callback.accept(e); + } + }); + responseChannel.resumeWrites(); + } + } catch (IOException e) { + callback.accept(e); + } + } + + @Override + public void close(int statusCode, String description) { + if (headersSent) { + exchange.putAttachment( + HttpAttachments.RESPONSE_TRAILER_SUPPLIER, + () -> { + HeaderMap trailers = new HeaderMap(); + trailers.put(HttpString.tryFromString("grpc-status"), String.valueOf(statusCode)); + if (description != null) { + trailers.put(HttpString.tryFromString("grpc-message"), description); + } + return trailers; + }); + + try { + responseChannel.shutdownWrites(); + if (!responseChannel.flush()) { + responseChannel + .getWriteSetter() + .set( + ch -> { + try { + if (ch.flush()) { + ch.suspendWrites(); + exchange.endExchange(); + } + } catch (IOException ignored) { + ch.suspendWrites(); + exchange.endExchange(); + } + }); + responseChannel.resumeWrites(); + } else { + exchange.endExchange(); + } + } catch (IOException e) { + exchange.endExchange(); + } + + } else { + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/grpc"); + exchange + .getResponseHeaders() + .put(HttpString.tryFromString("grpc-status"), String.valueOf(statusCode)); + if (description != null) { + exchange.getResponseHeaders().put(HttpString.tryFromString("grpc-message"), description); + } + exchange.endExchange(); + } + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java index 4700325fd0..850a81eb1e 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java @@ -5,52 +5,56 @@ */ package io.jooby.internal.undertow; +import java.nio.ByteBuffer; import java.util.concurrent.Flow; -import java.util.function.Function; -import io.jooby.Context; -import io.jooby.Router; +import io.jooby.GrpcExchange; +import io.jooby.GrpcProcessor; import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; import io.undertow.util.Headers; +import io.undertow.util.Protocols; public class UndertowGrpcHandler implements HttpHandler { + private final HttpHandler next; - private final Router router; - private final int bufferSize; - private final Function> subscriberFactory; - - public UndertowGrpcHandler( - HttpHandler next, - Router router, - int bufferSize, - Function> subscriberFactory) { + private final GrpcProcessor processor; + + public UndertowGrpcHandler(HttpHandler next, GrpcProcessor processor) { this.next = next; - this.router = router; - this.bufferSize = bufferSize; - this.subscriberFactory = subscriberFactory; + this.processor = processor; } @Override public void handleRequest(HttpServerExchange exchange) throws Exception { - if (!exchange - .getRequestHeaders() - .get(Headers.CONTENT_TYPE) - .getFirst() - .contains("application/grpc")) { - next.handleRequest(exchange); - } else { - // Prevents Undertow from automatically closing/draining the request - // exchange.setPersistent(true); - - // 2. IMPORTANT: Dispatch to a worker thread so we don't block the IO thread - exchange.dispatch( - () -> { - // Ensure we don't trigger the default draining behavior - var context = new UndertowContext(exchange, router, bufferSize); - var subscriber = subscriberFactory.apply(context); - new UndertowRequestPublisher(exchange).subscribe(subscriber); - }); + String contentType = exchange.getRequestHeaders().getFirst(Headers.CONTENT_TYPE); + + if (contentType != null && contentType.startsWith("application/grpc")) { + + // gRPC strictly requires HTTP/2 + if (!exchange.getProtocol().equals(Protocols.HTTP_2_0)) { + exchange.setStatusCode(426); // Upgrade Required + exchange.getResponseHeaders().put(Headers.CONNECTION, "Upgrade"); + exchange.getResponseHeaders().put(Headers.UPGRADE, "h2c"); + exchange.endExchange(); + return; + } + + // Note: We DO NOT call exchange.dispatch() here. + // Undertow knows we are handling this asynchronously because + // the InputBridge will acquire the RequestChannel natively. + + GrpcExchange grpcExchange = new UndertowGrpcExchange(exchange); + Flow.Subscriber subscriber = processor.process(grpcExchange); + + // Starts the reactive pipeline and acquires the XNIO channel + UndertowGrpcInputBridge inputBridge = new UndertowGrpcInputBridge(exchange, subscriber); + inputBridge.start(); + + return; // Fully handled, do not pass to the standard router } + + // Not a gRPC request, delegate to the next handler in the chain + next.handleRequest(exchange); } } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcInputBridge.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcInputBridge.java new file mode 100644 index 0000000000..84d05a0293 --- /dev/null +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcInputBridge.java @@ -0,0 +1,96 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.undertow; + +import java.nio.ByteBuffer; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicLong; + +import org.xnio.ChannelListener; +import org.xnio.IoUtils; +import org.xnio.channels.StreamSourceChannel; + +import io.undertow.server.HttpServerExchange; + +public class UndertowGrpcInputBridge + implements Flow.Subscription, ChannelListener { + + private final HttpServerExchange exchange; + private final Flow.Subscriber subscriber; + private final AtomicLong demand = new AtomicLong(); + private StreamSourceChannel channel; + + private final ByteBuffer buffer = ByteBuffer.allocate(8192); + + public UndertowGrpcInputBridge( + HttpServerExchange exchange, Flow.Subscriber subscriber) { + this.exchange = exchange; + this.subscriber = subscriber; + } + + public void start() { + this.channel = exchange.getRequestChannel(); + this.channel.getReadSetter().set(this); + subscriber.onSubscribe(this); + } + + @Override + public void request(long n) { + if (n <= 0) { + subscriber.onError(new IllegalArgumentException("Demand must be positive")); + return; + } + + if (demand.getAndAdd(n) == 0 && channel != null) { + // CRITICAL FIX: wakeupReads() forces the listener to fire immediately, + // draining any data that arrived in the same packet as the headers. + channel.wakeupReads(); + } + } + + @Override + public void cancel() { + demand.set(0); + IoUtils.safeClose(channel); + exchange.endExchange(); + } + + @Override + public void handleEvent(StreamSourceChannel channel) { + try { + while (demand.get() > 0) { + buffer.clear(); + int res = channel.read(buffer); + + if (res == -1) { + channel.suspendReads(); + subscriber.onComplete(); + return; + } else if (res == 0) { + // Buffer drained, waiting for more data from the network + return; + } + + buffer.flip(); + ByteBuffer chunk = ByteBuffer.allocate(buffer.remaining()); + chunk.put(buffer); + chunk.flip(); + + subscriber.onNext(chunk); + demand.decrementAndGet(); + } + + if (demand.get() == 0) { + channel.suspendReads(); + } + + } catch (Throwable t) { + subscriber.onError(t); + IoUtils.safeClose(channel); + exchange.endExchange(); + } + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java index 04460ad7c0..9e33f66609 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java @@ -7,7 +7,6 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; -import java.util.function.Function; import io.jooby.*; import io.undertow.io.Receiver; @@ -20,7 +19,6 @@ import io.undertow.util.HeaderMap; import io.undertow.util.Headers; import io.undertow.util.ParameterLimitException; -import io.undertow.util.Protocols; public class UndertowHandler implements HttpHandler { private final long maxRequestSize; @@ -56,18 +54,9 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } else { // possibly HTTP body HeaderMap headers = exchange.getRequestHeaders(); - if (exchange - .getRequestHeaders() - .get(Headers.CONTENT_TYPE) - .getFirst() - .contains("application/grpc")) { - var subscriber = router.require(ServiceKey.key(Function.class, "gRPC")); - new UndertowGrpcHandler(this, router, bufferSize, subscriber).handleRequest(exchange); - return; - } long len = parseLen(headers.getFirst(Headers.CONTENT_LENGTH)); String chunked = headers.getFirst(Headers.TRANSFER_ENCODING); - if (len > 0 || chunked != null || exchange.getProtocol().equals(Protocols.HTTP_2_0)) { + if (len > 0 || chunked != null) { if (len > maxRequestSize) { Router.Match route = router.match(context); if (route.matches()) { @@ -99,11 +88,7 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { if (len > 0 && len <= bufferSize) { receiver.receiveFullBytes(reader); } else { - if (exchange.getProtocol().equals(Protocols.HTTP_2_0)) { - receiver.receiveFullBytes(reader); - } else { - receiver.receivePartialBytes(reader); - } + receiver.receivePartialBytes(reader); } } else { try { diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java index 2de98bc591..a3844cfc6f 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java @@ -13,61 +13,25 @@ import io.jooby.output.Output; import io.undertow.io.IoCallback; import io.undertow.server.HttpServerExchange; -import io.undertow.server.protocol.http.HttpAttachments; -import io.undertow.util.HeaderMap; -import io.undertow.util.HttpString; public class UndertowSender implements Sender { private final UndertowContext ctx; private final HttpServerExchange exchange; - private HeaderMap trailers; - public UndertowSender(UndertowContext ctx) { + public UndertowSender(UndertowContext ctx, HttpServerExchange exchange) { this.ctx = ctx; - this.exchange = ctx.exchange; - this.trailers = ctx.trailers; - } - - @Override - public Sender setTrailer(@NonNull String name, @NonNull String value) { - if (trailers == null) { - trailers = new HeaderMap(); - } - trailers.put(HttpString.tryFromString(name), value); - return this; + this.exchange = exchange; } @Override public Sender write(@NonNull byte[] data, @NonNull Callback callback) { - return write(ByteBuffer.wrap(data), callback); + exchange.getResponseSender().send(ByteBuffer.wrap(data), newIoCallback(ctx, callback)); + return this; } @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { - return write(output.asByteBuffer(), callback); - } - - private Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { - if (trailers != null) { - var copy = new HeaderMap(); - copy.putAll(trailers); - trailers = null; - exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, copy); - } - exchange - .getResponseSender() - .send( - buffer, - new IoCallback() { - @Override - public void onComplete(HttpServerExchange exchange, io.undertow.io.Sender sender) {} - - @Override - public void onException( - HttpServerExchange exchange, - io.undertow.io.Sender sender, - IOException exception) {} - }); + new UndertowOutputCallback(output, newIoCallback(ctx, callback)).send(exchange); return this; } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java index 026c68ec74..c918a4acb9 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java @@ -18,6 +18,7 @@ import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.*; import io.jooby.exception.StartupException; +import io.jooby.internal.undertow.UndertowGrpcHandler; import io.jooby.internal.undertow.UndertowHandler; import io.jooby.internal.undertow.UndertowWebSocket; import io.jooby.output.OutputFactory; @@ -101,6 +102,13 @@ public Server start(@NonNull Jooby... application) { options.getMaxRequestSize(), options.getDefaultHeaders()); + GrpcProcessor grpcProcessor = + applications.get(0).getServices().getOrNull(GrpcProcessor.class); + + if (grpcProcessor != null) { + handler = new UndertowGrpcHandler(handler, grpcProcessor); + } + if (options.getCompressionLevel() != null) { int compressionLevel = options.getCompressionLevel(); handler = diff --git a/tests/src/test/java/examples/grpc/GrpcServer.java b/tests/src/test/java/examples/grpc/GrpcServer.java index 827506bb48..523e11466d 100644 --- a/tests/src/test/java/examples/grpc/GrpcServer.java +++ b/tests/src/test/java/examples/grpc/GrpcServer.java @@ -8,7 +8,6 @@ import java.io.IOException; import java.util.List; -import io.grpc.protobuf.services.ProtoReflectionServiceV1; import io.jooby.Jooby; import io.jooby.ServerOptions; import io.jooby.StartupSummary; @@ -21,9 +20,7 @@ public class GrpcServer extends Jooby { { setStartupSummary(List.of(StartupSummary.VERBOSE)); use(new AccessLogHandler()); - install( - new GrpcModule( - new GreeterService(), new ChatServiceImpl(), ProtoReflectionServiceV1.newInstance())); + install(new GrpcModule(new GreeterService(), new ChatServiceImpl())); } // INFO [2026-01-15 10:19:29,307] [worker-55] UnifiedGrpcBridge method type: BIDI_STREAMING diff --git a/tests/src/test/resources/logback.xml b/tests/src/test/resources/logback.xml index 7427344940..36266fe72c 100644 --- a/tests/src/test/resources/logback.xml +++ b/tests/src/test/resources/logback.xml @@ -2,12 +2,22 @@ - %-5p [%d{ISO8601}] [%thread] %logger{0} %msg %ex{0}%n + [%d{ISO8601}]-[%thread] %-5level %logger - %msg%n + + + + + + + + + + From 473539f8275c3f862b0b57df27802387af35f2e3 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 12 Mar 2026 19:28:57 -0300 Subject: [PATCH 06/11] - code cleanup, bug fixing - ref #3875 --- .../src/main/java/io/jooby/GrpcExchange.java | 4 +- .../src/main/java/io/jooby/GrpcProcessor.java | 4 + .../io/jooby/grpc/GrpcMethodRegistry.java | 28 -- .../main/java/io/jooby/grpc/GrpcModule.java | 58 +-- .../grpc/DefaultGrpcProcessor.java} | 68 ++-- .../{ => internal}/grpc/GrpcDeframer.java | 2 +- .../grpc/GrpcRequestBridge.java | 2 +- .../jooby/grpc/DefaultGrpcProcessorTest.java | 220 +++++++++++ .../java/io/jooby/grpc/GrpcDeframerTest.java | 2 + .../io/jooby/grpc/GrpcRequestBridgeTest.java | 131 ++++--- .../io/jooby/internal/jetty/JettyContext.java | 23 +- .../internal/jetty/JettyGrpcExchange.java | 2 +- .../internal/jetty/JettyGrpcHandler.java | 15 +- .../internal/jetty/JettyGrpcInputBridge.java | 7 +- .../internal/jetty/JettyRequestPublisher.java | 136 ------- .../io/jooby/internal/jetty/JettySender.java | 89 +---- .../io/jooby/internal/netty/NettyContext.java | 26 +- .../internal/netty/NettyGrpcHandler.java | 29 +- .../io/jooby/internal/netty/NettySender.java | 21 +- .../undertow/UndertowGrpcHandler.java | 20 +- .../undertow/UndertowOutputCallback.java | 44 +++ .../undertow/UndertowRequestPublisher.java | 267 ------------- tests/src/main/proto/chat.proto | 4 +- tests/src/main/proto/hello.proto | 4 +- .../test/java/examples/grpc/ChatClient.java | 81 ---- .../test/java/examples/grpc/GrpcClient.java | 26 -- .../test/java/examples/grpc/GrpcServer.java | 63 --- .../java/examples/grpc/JettyTrailerTest.java | 255 ------------ .../java/examples/grpc/ReflectionClient.java | 70 ---- .../jooby/i3875/EchoChatService.java} | 13 +- .../jooby/i3875/EchoGreeterService.java} | 7 +- .../test/java/io/jooby/i3875/GrpcTest.java | 366 ++++++++++++++++++ .../src/test/java/io/jooby/test/GrpcTest.java | 55 --- tests/src/test/resources/logback.xml | 12 +- 34 files changed, 865 insertions(+), 1289 deletions(-) delete mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java rename modules/jooby-grpc/src/main/java/io/jooby/{grpc/UnifiedGrpcBridge.java => internal/grpc/DefaultGrpcProcessor.java} (75%) rename modules/jooby-grpc/src/main/java/io/jooby/{ => internal}/grpc/GrpcDeframer.java (98%) rename modules/jooby-grpc/src/main/java/io/jooby/{ => internal}/grpc/GrpcRequestBridge.java (99%) create mode 100644 modules/jooby-grpc/src/test/java/io/jooby/grpc/DefaultGrpcProcessorTest.java delete mode 100644 modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java create mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java delete mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java delete mode 100644 tests/src/test/java/examples/grpc/ChatClient.java delete mode 100644 tests/src/test/java/examples/grpc/GrpcClient.java delete mode 100644 tests/src/test/java/examples/grpc/GrpcServer.java delete mode 100644 tests/src/test/java/examples/grpc/JettyTrailerTest.java delete mode 100644 tests/src/test/java/examples/grpc/ReflectionClient.java rename tests/src/test/java/{examples/grpc/ChatServiceImpl.java => io/jooby/i3875/EchoChatService.java} (66%) rename tests/src/test/java/{examples/grpc/GreeterService.java => io/jooby/i3875/EchoGreeterService.java} (68%) create mode 100644 tests/src/test/java/io/jooby/i3875/GrpcTest.java delete mode 100644 tests/src/test/java/io/jooby/test/GrpcTest.java diff --git a/jooby/src/main/java/io/jooby/GrpcExchange.java b/jooby/src/main/java/io/jooby/GrpcExchange.java index 71957e3653..217a1aa887 100644 --- a/jooby/src/main/java/io/jooby/GrpcExchange.java +++ b/jooby/src/main/java/io/jooby/GrpcExchange.java @@ -9,12 +9,14 @@ import java.util.Map; import java.util.function.Consumer; +import edu.umd.cs.findbugs.annotations.Nullable; + /** Server-agnostic abstraction for HTTP/2 trailing-header exchanges. */ public interface GrpcExchange { String getRequestPath(); - String getHeader(String name); + @Nullable String getHeader(String name); Map getHeaders(); diff --git a/jooby/src/main/java/io/jooby/GrpcProcessor.java b/jooby/src/main/java/io/jooby/GrpcProcessor.java index 192d3d0b26..d2dd5ed99d 100644 --- a/jooby/src/main/java/io/jooby/GrpcProcessor.java +++ b/jooby/src/main/java/io/jooby/GrpcProcessor.java @@ -12,6 +12,10 @@ /** Intercepts and processes gRPC exchanges. */ public interface GrpcProcessor { + + /** Checks if the given URI path exactly matches a registered gRPC method. */ + boolean isGrpcMethod(String path); + /** * @return A subscriber that the server will feed ByteBuffer chunks into, or null if the exchange * was rejected/unimplemented. diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java deleted file mode 100644 index 2d7e39ba12..0000000000 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.grpc; - -import java.util.HashMap; -import java.util.Map; - -import io.grpc.BindableService; -import io.grpc.MethodDescriptor; - -public class GrpcMethodRegistry { - private final Map> registry = new HashMap<>(); - - public void registerService(BindableService service) { - var serviceDef = service.bindService(); - for (var methodDef : serviceDef.getMethods()) { - MethodDescriptor descriptor = methodDef.getMethodDescriptor(); - registry.put(descriptor.getFullMethodName(), descriptor); - } - } - - public MethodDescriptor get(String fullMethodName) { - return registry.get(fullMethodName); - } -} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java index 800175e801..d1ca215446 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java @@ -5,22 +5,24 @@ */ package io.jooby.grpc; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.slf4j.bridge.SLF4JBridgeHandler; import edu.umd.cs.findbugs.annotations.NonNull; import io.grpc.BindableService; +import io.grpc.MethodDescriptor; import io.grpc.Server; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; -import io.grpc.protobuf.services.ProtoReflectionServiceV1; import io.jooby.*; +import io.jooby.internal.grpc.DefaultGrpcProcessor; public class GrpcModule implements Extension { private final List services; - private final GrpcMethodRegistry methodRegistry = new GrpcMethodRegistry(); - private final String serverName = "jooby-internal-" + System.nanoTime(); + private final Map> registry = new HashMap<>(); private Server grpcServer; static { @@ -36,35 +38,47 @@ public GrpcModule(BindableService... services) { @Override public void install(@NonNull Jooby app) throws Exception { - var builder = InProcessServerBuilder.forName(serverName).directExecutor(); + var serverName = app.getName(); + var builder = InProcessServerBuilder.forName(serverName); // 1. Register user-provided services - for (BindableService service : services) { + for (var service : services) { builder.addService(service); - methodRegistry.registerService(service); - } + for (var method : service.bindService().getMethods()) { + var descriptor = method.getMethodDescriptor(); + String methodFullName = descriptor.getFullMethodName(); + registry.put(methodFullName, descriptor); + String routePath = "/" + methodFullName; - BindableService reflectionService = ProtoReflectionServiceV1.newInstance(); - builder.addService(reflectionService); - methodRegistry.registerService(reflectionService); + // + app.post( + routePath, + ctx -> { + throw new IllegalStateException( + "gRPC request reached the standard HTTP router for path: " + + routePath + + ". " + + "This means the native gRPC server interceptor was bypassed. " + + "Ensure you are running Jetty, Netty, or Undertow with HTTP/2 enabled, " + + "and that the GrpcProcessor SPI is correctly loaded."); + }); + } + } this.grpcServer = builder.build().start(); + // KEEP .directExecutor() here! + // This ensures that when the background gRPC worker finishes, it instantly pushes + // the response back to Undertow/Netty without wasting time on another thread hop. var channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + var services = app.getServices(); + var bridge = new DefaultGrpcProcessor(channel, registry); - var bridge = new UnifiedGrpcBridge(channel, methodRegistry); // Register it in the Service Registry so the server layer can find it - app.getServices().put(UnifiedGrpcBridge.class, bridge); - - app.getServices().put(GrpcProcessor.class, bridge); - - // Mount the bridge. - // app.post("/*", bridge); + services.put(DefaultGrpcProcessor.class, bridge); + services.put(GrpcProcessor.class, bridge); - app.onStop( - () -> { - channel.shutdownNow(); - grpcServer.shutdownNow(); - }); + app.onStop(channel::shutdownNow); + app.onStop(grpcServer::shutdownNow); } } diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/DefaultGrpcProcessor.java similarity index 75% rename from modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java rename to modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/DefaultGrpcProcessor.java index a05c91c567..c99c4774c8 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/DefaultGrpcProcessor.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.grpc; +package io.jooby.internal.grpc; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -16,8 +16,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import edu.umd.cs.findbugs.annotations.NonNull; import io.grpc.CallOptions; -import io.grpc.ClientCall; import io.grpc.ManagedChannel; import io.grpc.MethodDescriptor; import io.grpc.Status; @@ -27,7 +27,7 @@ import io.jooby.GrpcExchange; import io.jooby.GrpcProcessor; -public class UnifiedGrpcBridge implements GrpcProcessor { +public class DefaultGrpcProcessor implements GrpcProcessor { // Minimal Marshaller to pass raw bytes through the bridge private static class RawMarshaller implements MethodDescriptor.Marshaller { @@ -48,24 +48,39 @@ public byte[] parse(InputStream stream) { private final Logger log = LoggerFactory.getLogger(getClass()); private final ManagedChannel channel; - private final GrpcMethodRegistry methodRegistry; + private final Map> registry; - public UnifiedGrpcBridge(ManagedChannel channel, GrpcMethodRegistry methodRegistry) { + public DefaultGrpcProcessor( + ManagedChannel channel, Map> registry) { this.channel = channel; - this.methodRegistry = methodRegistry; + this.registry = registry; } @Override - public Flow.Subscriber process(GrpcExchange exchange) { + public boolean isGrpcMethod(String path) { + // gRPC paths typically come in as "/package.Service/Method" + // Our registry stores them as "package.Service/Method" + String methodName = path.startsWith("/") ? path.substring(1) : path; + + // Quick O(1) hash map lookup + return registry.get(methodName) != null; + } + + @Override + public @NonNull Flow.Subscriber process(@NonNull GrpcExchange exchange) { // Route paths: /{package.Service}/{Method} String path = exchange.getRequestPath(); // Remove the leading slash to match the gRPC method registry format - var descriptor = methodRegistry.get(path.substring(1)); + var descriptor = registry.get(path.substring(1)); if (descriptor == null) { - log.warn("Method not found in bridge registry: {}", path); - exchange.close(Status.UNIMPLEMENTED.getCode().value(), "Method not found"); - return null; + // MUST never occur, it is guarded by {@link #isGrpcMethod} + throw new IllegalStateException( + "Unregistered gRPC method: '" + + path + + "'. " + + "This request bypassed the GrpcProcessor.isGrpcMethod() guard, " + + "indicating a bug or misconfiguration in the native server interceptor."); } var method = @@ -76,25 +91,24 @@ public Flow.Subscriber process(GrpcExchange exchange) { .setResponseMarshaller(new RawMarshaller()) .build(); - CallOptions callOptions = extractCallOptions(exchange); - io.grpc.Metadata metadata = extractMetadata(exchange); + var callOptions = extractCallOptions(exchange); + var metadata = extractMetadata(exchange); - io.grpc.Channel interceptedChannel = + var interceptedChannel = io.grpc.ClientInterceptors.intercept( channel, io.grpc.stub.MetadataUtils.newAttachHeadersInterceptor(metadata)); - ClientCall call = interceptedChannel.newCall(method, callOptions); - AtomicBoolean isFinished = new AtomicBoolean(false); + var call = interceptedChannel.newCall(method, callOptions); + var isFinished = new AtomicBoolean(false); boolean isUnaryOrServerStreaming = method.getType() == MethodDescriptor.MethodType.UNARY || method.getType() == MethodDescriptor.MethodType.SERVER_STREAMING; - // 1. Create the effectively final bridge - GrpcRequestBridge requestBridge = new GrpcRequestBridge(call, method.getType()); + var requestBridge = new GrpcRequestBridge(call, method.getType()); - ClientResponseObserver responseObserver = - new ClientResponseObserver<>() { + var responseObserver = + new ClientResponseObserver() { @Override public void beforeStart(ClientCallStreamObserver requestStream) { @@ -158,10 +172,10 @@ private CallOptions extractCallOptions(GrpcExchange exchange) { } try { - char unit = timeout.charAt(timeout.length() - 1); - long value = Long.parseLong(timeout.substring(0, timeout.length() - 1)); + var unit = timeout.charAt(timeout.length() - 1); + var value = Long.parseLong(timeout.substring(0, timeout.length() - 1)); - java.util.concurrent.TimeUnit timeUnit = + var timeUnit = switch (unit) { case 'H' -> java.util.concurrent.TimeUnit.HOURS; case 'M' -> java.util.concurrent.TimeUnit.MINUTES; @@ -184,10 +198,10 @@ private CallOptions extractCallOptions(GrpcExchange exchange) { /** Maps standard HTTP headers from the GrpcExchange into gRPC Metadata. */ private io.grpc.Metadata extractMetadata(GrpcExchange exchange) { - io.grpc.Metadata metadata = new io.grpc.Metadata(); + var metadata = new io.grpc.Metadata(); - for (Map.Entry header : exchange.getHeaders().entrySet()) { - String key = header.getKey().toLowerCase(); + for (var header : exchange.getHeaders().entrySet()) { + var key = header.getKey().toLowerCase(); if (key.startsWith(":") || key.startsWith("grpc-") @@ -212,7 +226,7 @@ private io.grpc.Metadata extractMetadata(GrpcExchange exchange) { /** Prepends the 5-byte gRPC header and returns a ready-to-write ByteBuffer. */ private ByteBuffer addGrpcHeader(byte[] payload) { - ByteBuffer buffer = ByteBuffer.allocate(5 + payload.length); + var buffer = ByteBuffer.allocate(5 + payload.length); buffer.put((byte) 0); // Compressed flag (0 = none) buffer.putInt(payload.length); buffer.put(payload); diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java b/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/GrpcDeframer.java similarity index 98% rename from modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java rename to modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/GrpcDeframer.java index 740b9a16e9..e011293c0f 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/GrpcDeframer.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.grpc; +package io.jooby.internal.grpc; import java.nio.ByteBuffer; import java.util.function.Consumer; diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/GrpcRequestBridge.java similarity index 99% rename from modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java rename to modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/GrpcRequestBridge.java index 85f6d4a58f..4a65e74bd3 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/GrpcRequestBridge.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.grpc; +package io.jooby.internal.grpc; import java.nio.ByteBuffer; import java.util.concurrent.Flow.Subscriber; diff --git a/modules/jooby-grpc/src/test/java/io/jooby/grpc/DefaultGrpcProcessorTest.java b/modules/jooby-grpc/src/test/java/io/jooby/grpc/DefaultGrpcProcessorTest.java new file mode 100644 index 0000000000..506453e71c --- /dev/null +++ b/modules/jooby-grpc/src/test/java/io/jooby/grpc/DefaultGrpcProcessorTest.java @@ -0,0 +1,220 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.ByteBuffer; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.Flow; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.jooby.GrpcExchange; +import io.jooby.internal.grpc.DefaultGrpcProcessor; +import io.jooby.internal.grpc.GrpcRequestBridge; + +public class DefaultGrpcProcessorTest { + + private ManagedChannel channel; + private Map> registry; + private GrpcExchange exchange; + private ClientCall call; + private DefaultGrpcProcessor bridge; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setUp() { + channel = mock(ManagedChannel.class); + registry = mock(Map.class); + exchange = mock(GrpcExchange.class); + call = mock(ClientCall.class); + + // The interceptor wraps the channel, but eventually delegates to the real one + when(channel.newCall(any(MethodDescriptor.class), any(CallOptions.class))).thenReturn(call); + + bridge = new DefaultGrpcProcessor(channel, registry); + } + + @Test + @DisplayName( + "Should throw IllegalStateException if an unknown method bypasses the isGrpcMethod guard") + public void shouldRejectUnknownMethod() { + when(exchange.getRequestPath()).thenReturn("/unknown.Service/Method"); + when(registry.get("unknown.Service/Method")).thenReturn(null); + + // Assert that the bridge correctly identifies the illegal state + IllegalStateException exception = + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, () -> bridge.process(exchange)); + + // Ensure the exchange wasn't manipulated or closed, because the framework + // should crash the thread instead of trying to gracefully close a gRPC stream. + verify(exchange, org.mockito.Mockito.never()).close(anyInt(), any()); + } + + @Test + @DisplayName("Should successfully bridge a valid Bidi-Streaming gRPC call") + public void shouldProcessValidStreamingCall() { + setupValidMethod("test.Chat/Stream", MethodDescriptor.MethodType.BIDI_STREAMING); + + Flow.Subscriber subscriber = bridge.process(exchange); + + assertNotNull(subscriber); + assertTrue(subscriber instanceof GrpcRequestBridge); + + // Verify call was actually created + verify(channel).newCall(any(MethodDescriptor.class), any(CallOptions.class)); + } + + @Test + @DisplayName("Should parse grpc-timeout header into CallOptions deadline") + public void shouldParseGrpcTimeout() { + setupValidMethod("test.Chat/TimeoutCall", MethodDescriptor.MethodType.UNARY); + + // 1000m = 1000 milliseconds + when(exchange.getHeader("grpc-timeout")).thenReturn("1000m"); + + bridge.process(exchange); + + ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(CallOptions.class); + verify(channel).newCall(any(MethodDescriptor.class), optionsCaptor.capture()); + + CallOptions options = optionsCaptor.getValue(); + assertNotNull(options.getDeadline()); + assertTrue(options.getDeadline().isExpired() == false, "Deadline should be in the future"); + } + + @Test + @DisplayName("Should correctly deframe and send gRPC payload to the client") + public void shouldFrameAndSendResponsePayload() { + setupValidMethod("test.Chat/Stream", MethodDescriptor.MethodType.BIDI_STREAMING); + bridge.process(exchange); + + // Capture the internal listener that gRPC uses to push data back to us + ArgumentCaptor> listenerCaptor = + ArgumentCaptor.forClass(ClientCall.Listener.class); + verify(call).start(listenerCaptor.capture(), any(Metadata.class)); + ClientCall.Listener responseListener = listenerCaptor.getValue(); + + // Simulate the server pushing a payload back + byte[] serverResponse = "hello".getBytes(); + responseListener.onMessage(serverResponse); + + // Verify our bridge framed it with the 5-byte header and sent it to Jooby + ArgumentCaptor bufferCaptor = ArgumentCaptor.forClass(ByteBuffer.class); + verify(exchange).send(bufferCaptor.capture(), any()); + + ByteBuffer framedBuffer = bufferCaptor.getValue(); + assertEquals(5 + serverResponse.length, framedBuffer.limit()); + assertEquals((byte) 0, framedBuffer.get(), "Compressed flag should be 0"); + assertEquals(serverResponse.length, framedBuffer.getInt(), "Length should match payload"); + + byte[] capturedPayload = new byte[serverResponse.length]; + framedBuffer.get(capturedPayload); + assertArrayEquals(serverResponse, capturedPayload); + } + + @Test + @DisplayName("Should close exchange with HTTP/2 trailing status when server throws error") + public void shouldCloseExchangeOnError() { + setupValidMethod("test.Chat/Stream", MethodDescriptor.MethodType.BIDI_STREAMING); + bridge.process(exchange); + + ArgumentCaptor> listenerCaptor = + ArgumentCaptor.forClass(ClientCall.Listener.class); + verify(call).start(listenerCaptor.capture(), any(Metadata.class)); + ClientCall.Listener responseListener = listenerCaptor.getValue(); + + // Simulate an internal server error from the gRPC engine + Status errorStatus = Status.INVALID_ARGUMENT.withDescription("Bad data"); + responseListener.onClose(errorStatus, new Metadata()); + + // Verify it mapped down to the core exchange SPI + verify(exchange).close(errorStatus.getCode().value(), "Bad data"); + } + + @Test + @DisplayName("Should gracefully close exchange with Status 0 on completion") + public void shouldCloseExchangeOnComplete() { + setupValidMethod("test.Chat/Stream", MethodDescriptor.MethodType.BIDI_STREAMING); + bridge.process(exchange); + + ArgumentCaptor> listenerCaptor = + ArgumentCaptor.forClass(ClientCall.Listener.class); + verify(call).start(listenerCaptor.capture(), any(Metadata.class)); + ClientCall.Listener responseListener = listenerCaptor.getValue(); + + // Simulate clean completion + responseListener.onClose(Status.OK, new Metadata()); + + verify(exchange).close(Status.OK.getCode().value(), null); + } + + @Test + @DisplayName("Should extract and decode custom metadata headers") + public void shouldExtractMetadata() { + // CRITICAL FIX: Use BIDI_STREAMING instead of UNARY. + // Unary delays call.start() until the request stream completes. Bidi starts immediately. + setupValidMethod("test.Chat/Headers", MethodDescriptor.MethodType.BIDI_STREAMING); + + Map incomingHeaders = + Map.of( + "x-custom-id", + "12345", + "x-custom-bin", + Base64.getEncoder().encodeToString("binary_data".getBytes())); + when(exchange.getHeaders()).thenReturn(incomingHeaders); + + bridge.process(exchange); + + // Verify that the metadata was extracted and attached to the call + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + verify(call).start(any(), metadataCaptor.capture()); + + Metadata metadata = metadataCaptor.getValue(); + + assertEquals( + "12345", metadata.get(Metadata.Key.of("x-custom-id", Metadata.ASCII_STRING_MARSHALLER))); + assertArrayEquals( + "binary_data".getBytes(), + metadata.get(Metadata.Key.of("x-custom-bin", Metadata.BINARY_BYTE_MARSHALLER))); + } + + /** Helper to mock out a valid MethodDescriptor in the registry. */ + @SuppressWarnings("unchecked") + private void setupValidMethod(String methodPath, MethodDescriptor.MethodType type) { + MethodDescriptor descriptor = + MethodDescriptor.newBuilder() + .setType(type) + .setFullMethodName(methodPath) + .setRequestMarshaller(mock(MethodDescriptor.Marshaller.class)) + .setResponseMarshaller(mock(MethodDescriptor.Marshaller.class)) + .build(); + + when(exchange.getRequestPath()).thenReturn("/" + methodPath); + //noinspection rawtypes + when(registry.get(methodPath)).thenReturn((MethodDescriptor) descriptor); + } +} diff --git a/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcDeframerTest.java b/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcDeframerTest.java index 6ef53d2c4b..5239681cc9 100644 --- a/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcDeframerTest.java +++ b/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcDeframerTest.java @@ -14,6 +14,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import io.jooby.internal.grpc.GrpcDeframer; + public class GrpcDeframerTest { private GrpcDeframer deframer; diff --git a/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcRequestBridgeTest.java b/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcRequestBridgeTest.java index 70846cab01..2d6ed11699 100644 --- a/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcRequestBridgeTest.java +++ b/modules/jooby-grpc/src/test/java/io/jooby/grpc/GrpcRequestBridgeTest.java @@ -5,120 +5,155 @@ */ package io.jooby.grpc; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.nio.ByteBuffer; import java.util.concurrent.Flow.Subscription; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import io.grpc.ClientCall; +import io.grpc.MethodDescriptor; import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientResponseObserver; +import io.jooby.internal.grpc.GrpcRequestBridge; public class GrpcRequestBridgeTest { - private ClientCallStreamObserver grpcObserver; + private ClientCall call; private Subscription subscription; + private ClientCallStreamObserver requestObserver; + private ClientResponseObserver responseObserver; private GrpcRequestBridge bridge; @BeforeEach @SuppressWarnings("unchecked") public void setUp() { - grpcObserver = mock(ClientCallStreamObserver.class); + call = mock(ClientCall.class); subscription = mock(Subscription.class); - bridge = new GrpcRequestBridge(grpcObserver); + requestObserver = mock(ClientCallStreamObserver.class); + responseObserver = mock(ClientResponseObserver.class); + + // Default to BIDI_STREAMING to test standard flow-control and backpressure + bridge = new GrpcRequestBridge(call, MethodDescriptor.MethodType.BIDI_STREAMING); + bridge.setRequestObserver(requestObserver); + bridge.setResponseObserver(responseObserver); } @Test + @DisplayName("Should request initial demand (1) upon subscription") public void shouldRequestInitialDemandOnSubscribe() { bridge.onSubscribe(subscription); - // Verify gRPC readiness handler is registered - verify(grpcObserver).setOnReadyHandler(any(Runnable.class)); - - // Verify initial demand of 1 is requested from Jooby verify(subscription).request(1); } @Test - public void shouldDelegateOnNextAndRequestMoreIfReady() { + @DisplayName("Should forward payload to requestObserver and request more if gRPC buffer is ready") + public void shouldSendMessageAndRequestMoreIfReady() { bridge.onSubscribe(subscription); + reset(subscription); // Clear the initial request(1) counter - // Reset the mock to clear the initial request(1) from onSubscribe - reset(subscription); - - // Simulate gRPC being ready to receive more data - when(grpcObserver.isReady()).thenReturn(true); + when(requestObserver.isReady()).thenReturn(true); - // Send a complete gRPC frame: Compressed Flag (0) + Length (4) + Payload ("test") byte[] payload = "test".getBytes(); - ByteBuffer frame = ByteBuffer.allocate(5 + payload.length); - frame.put((byte) 0).putInt(payload.length).put(payload).flip(); + ByteBuffer frame = createFrame(payload); bridge.onNext(frame); - // Verify the deframed payload was passed to gRPC - verify(grpcObserver).onNext(payload); + ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); + verify(requestObserver).onNext(captor.capture()); + assertArrayEquals(payload, captor.getValue(), "The deframed payload should match exactly"); - // Verify backpressure: since gRPC was ready, it should request the next chunk + // Because isReady() is true, it should demand the next network chunk verify(subscription).request(1); } @Test - public void shouldDelegateOnNextButSuspendDemandIfNotReady() { + @DisplayName( + "Should forward payload but apply backpressure (do not request) if gRPC is not ready") + public void shouldNotRequestMoreIfNotReady() { bridge.onSubscribe(subscription); reset(subscription); - // Simulate gRPC internal buffer being full (not ready) - when(grpcObserver.isReady()).thenReturn(false); + when(requestObserver.isReady()).thenReturn(false); byte[] payload = "test".getBytes(); - ByteBuffer frame = ByteBuffer.allocate(5 + payload.length); - frame.put((byte) 0).putInt(payload.length).put(payload).flip(); - - bridge.onNext(frame); + bridge.onNext(createFrame(payload)); - verify(grpcObserver).onNext(payload); + verify(requestObserver).onNext(any()); - // Verify backpressure: gRPC is NOT ready, so we MUST NOT request more from Jooby + // Since isReady() is false, it should NOT request more data, effectively applying backpressure verify(subscription, never()).request(anyLong()); } @Test - public void shouldResumeDemandWhenGrpcBecomesReady() { + @DisplayName("Should complete the requestObserver when the network stream completes") + public void shouldCompleteRequestObserverOnComplete() { bridge.onSubscribe(subscription); - reset(subscription); + bridge.onComplete(); - // Capture the readiness handler registered during onSubscribe - ArgumentCaptor handlerCaptor = ArgumentCaptor.forClass(Runnable.class); - verify(grpcObserver).setOnReadyHandler(handlerCaptor.capture()); - Runnable onReadyHandler = handlerCaptor.getValue(); + verify(requestObserver).onCompleted(); + } - // Simulate gRPC signaling that it is now ready - when(grpcObserver.isReady()).thenReturn(true); - onReadyHandler.run(); + @Test + @DisplayName("Should propagate network errors to the requestObserver") + public void shouldPropagateErrorToObserver() { + bridge.onSubscribe(subscription); + Throwable error = new RuntimeException("Stream network failure"); - // Verify backpressure: the handler should resume demanding data - verify(subscription).request(1); + bridge.onError(error); + + verify(requestObserver).onError(error); } @Test - public void shouldCancelSubscriptionAndDelegateOnError() { + @DisplayName("Unary calls should accumulate payload without forwarding until EOF") + public void shouldHandleUnaryCallsDifferently() { + bridge = new GrpcRequestBridge(call, MethodDescriptor.MethodType.UNARY); + bridge.setResponseObserver(responseObserver); bridge.onSubscribe(subscription); + reset(subscription); - Throwable error = new RuntimeException("Network failure"); - bridge.onError(error); + byte[] payload = "unary".getBytes(); + bridge.onNext(createFrame(payload)); + + // For Unary and Server Streaming, chunks are NOT passed via onNext + verify(requestObserver, never()).onNext(any()); - verify(grpcObserver).onError(error); + // It should keep requesting data from the network until EOF is reached + verify(subscription).request(1); } @Test - public void shouldDelegateOnCompleteIdempotently() { - bridge.onComplete(); - bridge.onComplete(); // Second call should be ignored + @DisplayName("onGrpcReady callback should trigger network demand if stream is active") + public void shouldRequestMoreOnGrpcReady() { + bridge.onSubscribe(subscription); + reset(subscription); + + when(requestObserver.isReady()).thenReturn(true); - verify(grpcObserver, times(1)).onCompleted(); + bridge.onGrpcReady(); + + verify(subscription).request(1); + } + + private ByteBuffer createFrame(byte[] payload) { + ByteBuffer frame = ByteBuffer.allocate(5 + payload.length); + frame.put((byte) 0); // Uncompressed flag + frame.putInt(payload.length); + frame.put(payload); + frame.flip(); // Prepare buffer for reading + return frame; } } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java index ec68ae6c34..f9d0d1d81b 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java @@ -81,7 +81,6 @@ static DeleteFileTask of(FileDownload file) { private final long maxRequestSize; Request request; Response response; - HttpFields.Mutable trailers; private QueryString query; private Formdata formdata; @@ -440,16 +439,6 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } - @Override - public Context setResponseTrailer(@NonNull String name, @NonNull String value) { - if (trailers == null) { - trailers = HttpFields.build(); - response.setTrailersSupplier(() -> trailers); - } - trailers.put(name, value); - return this; - } - @NonNull @Override public Context removeResponseHeader(@NonNull String name) { response.getHeaders().remove(name); @@ -491,13 +480,11 @@ public long getResponseLength() { return this; } - @Override - public Sender responseSender(boolean startResponse) { - responseStarted = startResponse; - if (startResponse) { - ifSetChunked(); - } - return new JettySender(this); + @NonNull @Override + public Sender responseSender() { + responseStarted = true; + ifSetChunked(); + return new JettySender(this, response); } @NonNull @Override diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcExchange.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcExchange.java index 8d2d0ebdea..7abd91364d 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcExchange.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcExchange.java @@ -34,7 +34,7 @@ public JettyGrpcExchange(Request request, Response response, Callback jettyCallb response.getHeaders().put("Content-Type", "application/grpc"); - // CRITICAL FIX: Register the supplier BEFORE the response commits + // Register the supplier BEFORE the response commits response.setTrailersSupplier(() -> trailers); } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java index d444932347..9ae74a03a6 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java @@ -5,15 +5,11 @@ */ package io.jooby.internal.jetty; -import java.nio.ByteBuffer; -import java.util.concurrent.Flow; - import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.util.Callback; -import io.jooby.GrpcExchange; import io.jooby.GrpcProcessor; public class JettyGrpcHandler extends Handler.Wrapper { @@ -27,9 +23,11 @@ public JettyGrpcHandler(Handler next, GrpcProcessor processor) { @Override public boolean handle(Request request, Response response, Callback callback) throws Exception { - String contentType = request.getHeaders().get("Content-Type"); + var contentType = request.getHeaders().get("Content-Type"); - if (contentType != null && contentType.startsWith("application/grpc")) { + if (processor.isGrpcMethod(request.getHttpURI().getPath()) + && contentType != null + && contentType.startsWith("application/grpc")) { if (!"HTTP/2.0".equals(request.getConnectionMetaData().getProtocol())) { response.setStatus(426); // Upgrade Required @@ -39,14 +37,15 @@ public boolean handle(Request request, Response response, Callback callback) thr return true; } - GrpcExchange exchange = new JettyGrpcExchange(request, response, callback); - Flow.Subscriber subscriber = processor.process(exchange); + var exchange = new JettyGrpcExchange(request, response, callback); + var subscriber = processor.process(exchange); new JettyGrpcInputBridge(request, subscriber, callback).start(); return true; } + // not grpc, move next return super.handle(request, response, callback); } } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcInputBridge.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcInputBridge.java index 910906cf70..a78101553a 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcInputBridge.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcInputBridge.java @@ -10,7 +10,6 @@ import java.util.concurrent.Flow; import java.util.concurrent.atomic.AtomicLong; -import org.eclipse.jetty.io.Content; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.util.Callback; @@ -54,7 +53,7 @@ public void cancel() { public void run() { try { while (demand.get() > 0) { - Content.Chunk chunk = request.read(); + var chunk = request.read(); if (chunk == null) { request.demand(this); @@ -62,14 +61,14 @@ public void run() { } try { - Throwable failure = chunk.getFailure(); + var failure = chunk.getFailure(); if (failure != null) { subscriber.onError(failure); callback.failed(failure); return; } - ByteBuffer buffer = chunk.getByteBuffer(); + var buffer = chunk.getByteBuffer(); if (buffer != null && buffer.hasRemaining()) { subscriber.onNext(buffer); demand.decrementAndGet(); diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java deleted file mode 100644 index 97228e26bd..0000000000 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.jetty; - -import java.util.HexFormat; -import java.util.concurrent.Flow; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; - -import org.eclipse.jetty.io.Content; -import org.eclipse.jetty.server.Request; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class JettyRequestPublisher implements Flow.Publisher { - private final Logger log = LoggerFactory.getLogger(getClass()); - private final Request request; - - public JettyRequestPublisher(Request request) { - this.request = request; - } - - @Override - public void subscribe(Flow.Subscriber subscriber) { - var subscription = new JettySubscription(request, subscriber); - subscriber.onSubscribe(subscription); - } -} - -/** - * Professional Jetty 12 Core Subscription. Uses the demand-callback pattern to satisfy gRPC stream - * requirements. - */ -class JettySubscription implements Flow.Subscription { - - private static final Logger log = LoggerFactory.getLogger(JettySubscription.class); - private final Request request; - private final Flow.Subscriber subscriber; - - private final AtomicLong demand = new AtomicLong(); - private final AtomicBoolean cancelled = new AtomicBoolean(false); - private final AtomicBoolean completed = new AtomicBoolean(false); - - public JettySubscription(Request request, Flow.Subscriber subscriber) { - this.request = request; - this.subscriber = subscriber; - } - - private final AtomicBoolean demandPending = new AtomicBoolean(false); - - private void process(String call) { - log.info("{}- start reading request", call); - try { - var demandMore = false; - while (true) { - // 2. Check for data. We MUST read if the deframer is "hungry," - // even if application demand is 0. - var chunk = request.read(); - - if (chunk == null) { - log.info("{}- demanding more", call); - request.demand( - () -> { - process(call + ".demand"); - }); - return; - } - - if (Content.Chunk.isFailure(chunk)) { - log.info("{}- bad chunk: {}", call, chunk); - boolean fatal = chunk.isLast(); - if (fatal) { - handleComplete(); - return; - } else { - handleError(chunk.getFailure()); - return; - } - } - var buffer = chunk.getByteBuffer(); - - if (buffer != null && buffer.hasRemaining()) { - byte[] bytes = new byte[buffer.remaining()]; - buffer.get(bytes); - - log.info("{}- byte read: {}", call, HexFormat.of().formatHex(bytes)); - subscriber.onNext(bytes); - } - chunk.release(); - - if (chunk.isLast()) { - log.info("{}- last reach", call); - // Even if we have 0 demand, we must finish the stream - handleComplete(); - return; - } - } - } catch (Throwable t) { - handleError(t); - } finally { - log.info("{}- finish reading request", call); - } - } - - private void handleComplete() { - if (completed.compareAndSet(false, true) && !cancelled.get()) { - log.info("handle complete"); - subscriber.onComplete(); - } - } - - private void handleError(Throwable t) { - if (completed.compareAndSet(false, true) && !cancelled.get()) { - log.info("handle error", t); - subscriber.onError(t); - } - } - - long c = 0; - - @Override - public void request(long n) { - if (n <= 0) return; - log.info("init request({})", n); - c += n; - process(Long.toString(c)); - } - - @Override - public void cancel() { - cancelled.set(true); - } -} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java index a3c38b84fa..c8f235041f 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java @@ -5,11 +5,11 @@ */ package io.jooby.internal.jetty; +import static io.jooby.internal.jetty.JettyCallbacks.fromOutput; + import java.nio.ByteBuffer; -import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.server.Response; -import org.eclipse.jetty.util.Callback; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Sender; @@ -18,94 +18,24 @@ public class JettySender implements Sender { private final JettyContext ctx; private final Response response; - private HttpFields.Mutable trailers; - private ByteBuffer pending; - private org.eclipse.jetty.util.Callback pendingCallback; - public JettySender(JettyContext ctx) { + public JettySender(JettyContext ctx, Response response) { this.ctx = ctx; - this.response = ctx.response; - this.trailers = ctx.trailers; - } - - @Override - public Sender setTrailer(@NonNull String name, @NonNull String value) { - if (trailers == null) { - trailers = HttpFields.build(); - } - trailers.put(name, value); - return this; + this.response = response; } @Override public Sender write(@NonNull byte[] data, @NonNull Callback callback) { - return write(ByteBuffer.wrap(data), callback); + response.write(false, ByteBuffer.wrap(data), toJettyCallback(ctx, callback)); + return this; } @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { - return write(output.asByteBuffer(), callback); - } - - public Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { - if (trailers != null) { - var copy = HttpFields.build(trailers); - response.setTrailersSupplier(() -> copy); - this.trailers = null; - } - response.write( - false, - buffer, - new org.eclipse.jetty.util.Callback() { - @Override - public void succeeded() { - org.eclipse.jetty.util.Callback.super.succeeded(); - } - - @Override - public void failed(Throwable x) { - org.eclipse.jetty.util.Callback.super.failed(x); - } - }); - // if (trailers == null) { - // response.write(false, buffer, toJettyCallback(ctx, callback)); - // } else { - // if (pending != null) { - // response.write(false, pending, pendingCallback); - // } - // pending = buffer; - // pendingCallback = toJettyCallback(ctx, callback); - // } + fromOutput(response, toJettyCallback(ctx, callback), output).send(false); return this; } - @Override - public void close() { - // if (trailers != null) { - // response.setTrailersSupplier(() -> trailers); - // response.write(true, null, new org.eclipse.jetty.util.Callback() { - // @Override - // public void succeeded() { - // System.out.println("Succeed"); - // } - // - // @Override - // public void failed(Throwable throwable) { - // System.out.println("Failed"); - // throwable.printStackTrace(); - // } - // }); - // } else { - response.write(true, null, ctx); - // } - // if (pending != null) { - // response.setTrailersSupplier(() -> trailers); - // response.write(true, pending, ctx); - // } else { - // response.write(true, null, ctx); - // } - } - private static org.eclipse.jetty.util.Callback toJettyCallback( JettyContext ctx, Callback callback) { return new org.eclipse.jetty.util.Callback() { @@ -121,4 +51,9 @@ public void failed(Throwable x) { } }; } + + @Override + public void close() { + response.write(false, null, ctx); + } } diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java index ae761ec307..91d2496e92 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java @@ -113,7 +113,6 @@ public void operationComplete(ChannelFuture future) { private static final String STREAM_ID = "x-http2-stream-id"; private String streamId; - HeadersMultiMap trailers; HeadersMultiMap setHeaders = HEADERS.newHeaders(); private int bufferSize; InterfaceHttpPostRequestDecoder decoder; @@ -382,9 +381,7 @@ public Body body() { if (decoder != null && decoder.hasNext()) { return new NettyBody(this, (HttpData) decoder.next(), HttpUtil.getContentLength(req, -1L)); } - return (req instanceof DefaultFullHttpRequest full) - ? new NettyByteBufBody(this, full.content()) - : Body.empty(this); + return Body.empty(this); } @Override @@ -498,15 +495,6 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } - @NonNull @Override - public Context setResponseTrailer(@NonNull String name, @NonNull String value) { - if (trailers == null) { - trailers = HEADERS.newHeaders(); - } - trailers.set(name, value); - return this; - } - @NonNull @Override public Context removeResponseHeader(@NonNull String name) { setHeaders.remove(name); @@ -587,11 +575,9 @@ public PrintWriter responseWriter(MediaType type) { new NettyWriter(newOutputStream(), ofNullable(type.getCharset()).orElse(UTF_8))); } - @Override - public Sender responseSender(boolean startResponse) { - if (startResponse) { - prepareChunked(); - } + @NonNull @Override + public Sender responseSender() { + prepareChunked(); ctx.write(new DefaultHttpResponse(HTTP_1_1, status, setHeaders)); return new NettySender(this); } @@ -645,9 +631,7 @@ Context send(@NonNull ByteBuf data, CharSequence contentLength) { try { responseStarted = true; setHeaders.set(CONTENT_LENGTH, contentLength); - var response = - new DefaultFullHttpResponse( - HTTP_1_1, status, data, setHeaders, trailers == null ? NO_TRAILING : trailers); + var response = new DefaultFullHttpResponse(HTTP_1_1, status, data, setHeaders, NO_TRAILING); connection.writeMessage(response, promise()); return this; } finally { diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcHandler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcHandler.java index 8217ba97e1..b7254e1a34 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcHandler.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyGrpcHandler.java @@ -5,10 +5,6 @@ */ package io.jooby.internal.netty; -import java.nio.ByteBuffer; -import java.util.concurrent.Flow; - -import io.jooby.GrpcExchange; import io.jooby.GrpcProcessor; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; @@ -35,9 +31,14 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception // 1. Intercept the initial Request headers if (msg instanceof HttpRequest req) { - String contentType = req.headers().get(HttpHeaderNames.CONTENT_TYPE); - - if (contentType != null && contentType.startsWith("application/grpc")) { + var contentType = req.headers().get(HttpHeaderNames.CONTENT_TYPE); + var path = req.uri(); + int queryIndex = path.indexOf('?'); + path = queryIndex > 0 ? path.substring(0, queryIndex) : path; + + if (processor.isGrpcMethod(path) + && contentType != null + && contentType.startsWith("application/grpc")) { isGrpc = true; if (!isHttp2) { @@ -53,15 +54,11 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception } // We will implement NettyGrpcExchange in the next step - GrpcExchange exchange = new NettyGrpcExchange(ctx, req); - Flow.Subscriber subscriber = processor.process(exchange); - - if (subscriber != null) { - inputBridge = new NettyGrpcInputBridge(ctx, subscriber); - inputBridge.start(); - } else { - // Exchange was rejected/closed internally by processor (e.g. Unimplemented) - } + var exchange = new NettyGrpcExchange(ctx, req); + var subscriber = processor.process(exchange); + + inputBridge = new NettyGrpcInputBridge(ctx, subscriber); + inputBridge.start(); ReferenceCountUtil.release(msg); // We consumed the headers return; diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java index 47daa6bff0..1fb339fae0 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java @@ -6,7 +6,6 @@ package io.jooby.internal.netty; import static io.jooby.internal.netty.NettyByteBufRef.byteBuf; -import static io.jooby.internal.netty.NettyHeadersFactory.HEADERS; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Sender; @@ -15,28 +14,16 @@ import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.http.DefaultHttpContent; -import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.LastHttpContent; public class NettySender implements Sender { private final NettyContext ctx; private final ChannelHandlerContext context; - private HeadersMultiMap trailers; public NettySender(NettyContext ctx) { this.ctx = ctx; this.context = ctx.ctx; - this.trailers = ctx.trailers; - } - - @Override - public Sender setTrailer(@NonNull String name, @NonNull String value) { - if (trailers == null) { - trailers = HEADERS.newHeaders(); - } - trailers.set(name, value); - return this; } @Override @@ -57,13 +44,7 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { @Override public void close() { - LastHttpContent lastContent; - if (trailers != null) { - lastContent = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER, trailers); - } else { - lastContent = LastHttpContent.EMPTY_LAST_CONTENT; - } - context.writeAndFlush(lastContent, ctx.promise()); + context.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT, ctx.promise()); ctx.requestComplete(); } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java index 850a81eb1e..36643bcd08 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java @@ -5,10 +5,6 @@ */ package io.jooby.internal.undertow; -import java.nio.ByteBuffer; -import java.util.concurrent.Flow; - -import io.jooby.GrpcExchange; import io.jooby.GrpcProcessor; import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; @@ -27,9 +23,11 @@ public UndertowGrpcHandler(HttpHandler next, GrpcProcessor processor) { @Override public void handleRequest(HttpServerExchange exchange) throws Exception { - String contentType = exchange.getRequestHeaders().getFirst(Headers.CONTENT_TYPE); + var contentType = exchange.getRequestHeaders().getFirst(Headers.CONTENT_TYPE); - if (contentType != null && contentType.startsWith("application/grpc")) { + if (processor.isGrpcMethod(exchange.getRequestPath()) + && contentType != null + && contentType.startsWith("application/grpc")) { // gRPC strictly requires HTTP/2 if (!exchange.getProtocol().equals(Protocols.HTTP_2_0)) { @@ -40,15 +38,11 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { return; } - // Note: We DO NOT call exchange.dispatch() here. - // Undertow knows we are handling this asynchronously because - // the InputBridge will acquire the RequestChannel natively. - - GrpcExchange grpcExchange = new UndertowGrpcExchange(exchange); - Flow.Subscriber subscriber = processor.process(grpcExchange); + var grpcExchange = new UndertowGrpcExchange(exchange); + var subscriber = processor.process(grpcExchange); // Starts the reactive pipeline and acquires the XNIO channel - UndertowGrpcInputBridge inputBridge = new UndertowGrpcInputBridge(exchange, subscriber); + var inputBridge = new UndertowGrpcInputBridge(exchange, subscriber); inputBridge.start(); return; // Fully handled, do not pass to the standard router diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java new file mode 100644 index 0000000000..0a2222f6cc --- /dev/null +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java @@ -0,0 +1,44 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.undertow; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Iterator; + +import io.jooby.output.Output; +import io.undertow.io.IoCallback; +import io.undertow.io.Sender; +import io.undertow.server.HttpServerExchange; + +public class UndertowOutputCallback implements IoCallback { + + private Iterator iterator; + private IoCallback callback; + + public UndertowOutputCallback(Output buffer, IoCallback callback) { + this.iterator = buffer.iterator(); + this.callback = callback; + } + + public void send(HttpServerExchange exchange) { + exchange.getResponseSender().send(iterator.next(), this); + } + + @Override + public void onComplete(HttpServerExchange exchange, Sender sender) { + if (iterator.hasNext()) { + sender.send(iterator.next(), this); + } else { + callback.onComplete(exchange, sender); + } + } + + @Override + public void onException(HttpServerExchange exchange, Sender sender, IOException exception) { + callback.onException(exchange, sender, exception); + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java deleted file mode 100644 index 847e88fcab..0000000000 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.undertow; - -import static io.undertow.io.IoCallback.END_EXCHANGE; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.HexFormat; -import java.util.concurrent.Flow; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.xnio.channels.StreamSourceChannel; - -import io.undertow.UndertowLogger; -import io.undertow.UndertowMessages; -import io.undertow.connector.PooledByteBuffer; -import io.undertow.io.Receiver; -import io.undertow.server.Connectors; -import io.undertow.server.HttpServerExchange; -import io.undertow.util.AttachmentKey; -import io.undertow.util.Headers; -import io.undertow.util.StatusCodes; - -public class UndertowRequestPublisher implements Flow.Publisher { - - public static final AttachmentKey REQUEST_CHANNEL = - AttachmentKey.create(StreamSourceChannel.class); - - private final HttpServerExchange exchange; - - public UndertowRequestPublisher(HttpServerExchange exchange) { - this.exchange = exchange; - } - - @Override - public void subscribe(Flow.Subscriber subscriber) { - // We use the Subscription to manage the state between Undertow and the Subscriber - UndertowReceiverSubscription sub = new UndertowReceiverSubscription(exchange, subscriber); - subscriber.onSubscribe(sub); - } -} - -class UndertowReceiverSubscription implements Flow.Subscription { - private static final Logger log = LoggerFactory.getLogger(UndertowReceiverSubscription.class); - private final HttpServerExchange exchange; - private final Flow.Subscriber subscriber; - private final AtomicBoolean started = new AtomicBoolean(false); - private final AtomicLong demand = new AtomicLong(0); - private final AtomicBoolean readingStarted = new AtomicBoolean(false); - private UndertowReceiver receiver; - - public UndertowReceiverSubscription( - HttpServerExchange exchange, Flow.Subscriber subscriber) { - this.exchange = exchange; - this.subscriber = subscriber; - } - - @Override - public void request(long n) { - if (n <= 0) return; - log.info("init request({})", n); - // if (receiver == null) { - // receiver = new UndertowReceiver(exchange, () -> {}); - process(); - // } else { - // receiver.resume(); - // } - } - - private void process() { - var call = new AtomicInteger(0); - - // var receiver = exchange.getRequestReceiver(); - exchange - .getRequestReceiver() - .receivePartialBytes( - (exch, message, last) -> { - call.incrementAndGet(); - log.info("{}- byte len: {}", call, message.length); - if (message.length > 0) { - // Pass bytes to De-framer - log.info("{}- byte read: {}", call, HexFormat.of().formatHex(message)); - subscriber.onNext(message); - } - // THE KEY FIX: - // 1. If 'last' is true, the stream is definitely over. - // 2. If 'isRequestComplete' is true, Undertow's internal state knows it's over. - if (last) { - log.info("{}- last reach", call); - subscriber.onComplete(); - } - }, - (exch, err) -> { - subscriber.onError(err); - }); - } - - @Override - public void cancel() { - exchange.getRequestReceiver().pause(); - } -} - -class UndertowReceiver { - private final Logger log = LoggerFactory.getLogger(UndertowReceiver.class); - private final HttpServerExchange exchange; - private final StreamSourceChannel channel; - private final Runnable runnable; - private int maxBufferSize = -1; - private boolean paused = false; - private boolean done = false; - public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; - private static final Receiver.ErrorCallback END_EXCHANGE = - new Receiver.ErrorCallback() { - @Override - public void error(HttpServerExchange exchange, IOException e) { - e.printStackTrace(); - exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR); - UndertowLogger.REQUEST_IO_LOGGER.ioException(e); - exchange.endExchange(); - } - }; - - public UndertowReceiver(HttpServerExchange exchange, Runnable runnable) { - this.exchange = exchange; - this.channel = exchange.getRequestChannel(); - exchange.putAttachment(UndertowRequestPublisher.REQUEST_CHANNEL, this.channel); - this.runnable = runnable; - } - - public void receivePartialBytes( - final Receiver.PartialBytesCallback callback, final Receiver.ErrorCallback errorCallback) { - if (done) { - throw UndertowMessages.MESSAGES.requestBodyAlreadyRead(); - } - final Receiver.ErrorCallback error = errorCallback == null ? END_EXCHANGE : errorCallback; - if (callback == null) { - throw UndertowMessages.MESSAGES.argumentCannotBeNull("callback"); - } - if (exchange.isRequestComplete()) { - log.info("request complete"); - callback.handle(exchange, EMPTY_BYTE_ARRAY, true); - return; - } - String contentLengthString = exchange.getRequestHeaders().getFirst(Headers.CONTENT_LENGTH); - if (contentLengthString == null) { - contentLengthString = exchange.getRequestHeaders().getFirst(Headers.X_CONTENT_LENGTH); - } - long contentLength; - if (contentLengthString != null) { - contentLength = Long.parseLong(contentLengthString); - if (contentLength > Integer.MAX_VALUE) { - error.error(exchange, new Receiver.RequestToLargeException()); - return; - } - } else { - contentLength = -1; - } - if (maxBufferSize > 0) { - if (contentLength > maxBufferSize) { - error.error(exchange, new Receiver.RequestToLargeException()); - return; - } - } - PooledByteBuffer pooled = exchange.getConnection().getByteBufferPool().allocate(); - final ByteBuffer buffer = pooled.getBuffer(); - - channel - .getReadSetter() - .set( - channel -> { - if (done || paused) { - log.info("request done: {} or paused: {}", done, paused); - return; - } - PooledByteBuffer pooled1 = exchange.getConnection().getByteBufferPool().allocate(); - final ByteBuffer buffer1 = pooled1.getBuffer(); - try { - int res2; - do { - if (paused) { - return; - } - try { - buffer1.clear(); - res2 = channel.read(buffer1); - if (res2 == -1) { - done = true; - log.info("INSIDE request read done: {} ", res2); - Connectors.executeRootHandler( - exchange -> callback.handle(exchange, EMPTY_BYTE_ARRAY, true), exchange); - return; - } else if (res2 == 0) { - log.info("INSIDE resume reads: {}", res2); - // channel.resumeReads(); - return; - } else { - buffer1.flip(); - final byte[] data = new byte[buffer1.remaining()]; - buffer1.get(data); - - Connectors.executeRootHandler( - exchange -> { - callback.handle(exchange, data, false); - channel.resumeReads(); - }, - exchange); - } - } catch (final IOException e) { - log.info("INSIDE error reading from {}", exchange, e); - Connectors.executeRootHandler(exchange -> error.error(exchange, e), exchange); - return; - } - } while (true); - } finally { - pooled1.close(); - } - }); - - try { - int res; - do { - try { - buffer.clear(); - res = channel.read(buffer); - if (res == -1) { - log.info("request read out-of listener: {} ", res); - done = true; - callback.handle(exchange, EMPTY_BYTE_ARRAY, true); - return; - } else if (res == 0) { - log.info("request resume reads out-of listener: {} ", res); - channel.resumeReads(); - return; - } else { - buffer.flip(); - byte[] data = new byte[buffer.remaining()]; - buffer.get(data); - log.info("request read done out-of listener: {} ", res); - callback.handle(exchange, data, false); - if (paused) { - return; - } - } - } catch (IOException e) { - error.error(exchange, e); - return; - } - } while (true); - } finally { - log.info("channel open: {} ", channel.isOpen()); - pooled.close(); - } - } - - public void resume() { - channel.wakeupReads(); - } -} diff --git a/tests/src/main/proto/chat.proto b/tests/src/main/proto/chat.proto index 9f5f87d317..2c1e7c36d5 100644 --- a/tests/src/main/proto/chat.proto +++ b/tests/src/main/proto/chat.proto @@ -1,8 +1,8 @@ syntax = "proto3"; -package test; +package io.jooby.i3875; -option java_package = "com.example.grpc"; +option java_package = "io.jooby.i3875"; option java_multiple_files = true; service ChatService { diff --git a/tests/src/main/proto/hello.proto b/tests/src/main/proto/hello.proto index 6efa27221e..80f776555b 100644 --- a/tests/src/main/proto/hello.proto +++ b/tests/src/main/proto/hello.proto @@ -1,6 +1,8 @@ syntax = "proto3"; -option java_package = "com.example.grpc"; +package io.jooby.i3875; + +option java_package = "io.jooby.i3875"; option java_multiple_files = true; // The request message containing the user's name. diff --git a/tests/src/test/java/examples/grpc/ChatClient.java b/tests/src/test/java/examples/grpc/ChatClient.java deleted file mode 100644 index 2078e0abf5..0000000000 --- a/tests/src/test/java/examples/grpc/ChatClient.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package examples.grpc; - -import java.util.concurrent.CountDownLatch; - -import com.example.grpc.*; -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; -import io.grpc.stub.StreamObserver; - -public class ChatClient { - public static void main(String[] args) throws InterruptedException { - // 1. Create a channel to your JOOBY server - ManagedChannel channel = - ManagedChannelBuilder.forAddress("localhost", 8080) - .usePlaintext() // Assuming the bridge is HTTP/2 Cleartext - .build(); - - // 2. Create an ASYNC stub (BiDi requires the async stub) - ChatServiceGrpc.ChatServiceStub asyncStub = ChatServiceGrpc.newStub(channel); - - // This latch helps the main thread wait until the stream is fully finished - CountDownLatch latch = new CountDownLatch(3); - - // 3. Define the observer to handle responses coming BACK from the Bridge - StreamObserver responseObserver = - new StreamObserver<>() { - @Override - public void onNext(ChatMessage value) { - System.out.println( - "Received from Bridge: [" + value.getUser() + "] " + value.getText()); - latch.countDown(); - } - - @Override - public void onError(Throwable t) { - System.err.println("Bridge Error: " + t.getMessage()); - t.printStackTrace(); - latch.countDown(); - latch.countDown(); - latch.countDown(); - } - - @Override - public void onCompleted() { - System.out.println("Bridge closed the stream (Trailers received successfully)."); - latch.countDown(); - } - }; - - // 4. Start the call. Returns the observer we use to SEND messages TO the Bridge. - StreamObserver requestObserver = asyncStub.chatStream(responseObserver); - - try { - System.out.println("Connecting to Bridge and sending messages..."); - - // 5. Send a stream of messages over time - requestObserver.onNext( - ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 1").build()); - - Thread.sleep(1000); // Simulate network/processing delay - - requestObserver.onNext( - ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 2").build()); - - // 6. Tell the Bridge we are done sending data - requestObserver.onCompleted(); - - } catch (Exception e) { - requestObserver.onError(e); - } - latch.await(); - - // Wait for the server to finish responding (timeout after 10 seconds) - channel.shutdown(); - } -} diff --git a/tests/src/test/java/examples/grpc/GrpcClient.java b/tests/src/test/java/examples/grpc/GrpcClient.java deleted file mode 100644 index 68d0b3c383..0000000000 --- a/tests/src/test/java/examples/grpc/GrpcClient.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package examples.grpc; - -import com.example.grpc.GreeterGrpc; -import com.example.grpc.HelloReply; -import com.example.grpc.HelloRequest; -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; - -public class GrpcClient { - public static void main(String[] args) { - ManagedChannel channel = - ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build(); - - GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel); - - HelloReply response = stub.sayHello(HelloRequest.newBuilder().setName("Edgar").build()); - System.out.println(response.getMessage()); - - channel.shutdown(); - } -} diff --git a/tests/src/test/java/examples/grpc/GrpcServer.java b/tests/src/test/java/examples/grpc/GrpcServer.java deleted file mode 100644 index 523e11466d..0000000000 --- a/tests/src/test/java/examples/grpc/GrpcServer.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package examples.grpc; - -import java.io.IOException; -import java.util.List; - -import io.jooby.Jooby; -import io.jooby.ServerOptions; -import io.jooby.StartupSummary; -import io.jooby.grpc.GrpcModule; -import io.jooby.handler.AccessLogHandler; -import io.jooby.undertow.UndertowServer; - -public class GrpcServer extends Jooby { - - { - setStartupSummary(List.of(StartupSummary.VERBOSE)); - use(new AccessLogHandler()); - install(new GrpcModule(new GreeterService(), new ChatServiceImpl())); - } - - // INFO [2026-01-15 10:19:29,307] [worker-55] UnifiedGrpcBridge method type: BIDI_STREAMING - // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription init request(1) - // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- start reading request - // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- byte read: 00000000033a012a - // INFO [2026-01-15 10:19:29,308] [worker-55] GrpcRequestBridge deframe 3a012a - // INFO [2026-01-15 10:19:29,308] [worker-55] UnifiedGrpcBridge onNext Send - // 12033a012a32460a120a10746573742e43686174536572766963650a250a23677270632e7265666c656374696f6e2e76312e5365727665725265666c656374696f6e0a090a0747726565746572 - // INFO [2026-01-15 10:19:29,308] [worker-55] GrpcRequestBridge asking for more request(1) - // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- demanding more - // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- finish reading request - // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- start reading request - // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- last reach - // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription handle complete - // INFO [2026-01-15 10:19:29,309] [worker-52] UnifiedGrpcBridge onCompleted - // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- finish reading request - // INFO [2026-01-15 10:20:08,267] [Thread-0] GrpcServer Stopped GrpcServer - - public static void main(final String[] args) throws InterruptedException, IOException { - runApp( - args, - new UndertowServer(new ServerOptions().setSecurePort(8443).setHttp2(true)), - GrpcServer::new); - - // Build the server - // Server server = io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.forPort(9090) - // .addService(new GreeterService()) - // .addService(ProtoReflectionServiceV1.newInstance())// Your generated service - // implementation - // .build(); - // - // // Start the server - // server.start(); - // System.out.println("Server started on port 9090"); - // - // // Keep the main thread alive until the server is shut down - // server.awaitTermination(); - } -} diff --git a/tests/src/test/java/examples/grpc/JettyTrailerTest.java b/tests/src/test/java/examples/grpc/JettyTrailerTest.java deleted file mode 100644 index 7d149bc6ab..0000000000 --- a/tests/src/test/java/examples/grpc/JettyTrailerTest.java +++ /dev/null @@ -1,255 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package examples.grpc; - -import static org.junit.jupiter.api.Assertions.fail; - -import java.net.InetSocketAddress; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -import org.eclipse.jetty.http.*; -import org.eclipse.jetty.http2.api.Session; -import org.eclipse.jetty.http2.api.Stream; -import org.eclipse.jetty.http2.client.HTTP2Client; -import org.eclipse.jetty.http2.frames.DataFrame; -import org.eclipse.jetty.http2.frames.HeadersFrame; -import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; -import org.eclipse.jetty.server.*; -import org.eclipse.jetty.util.BufferUtil; -import org.eclipse.jetty.util.Callback; -import org.eclipse.jetty.util.Promise; -import org.junit.jupiter.api.Test; - -public class JettyTrailerTest { - - @Test - public void testEmptyWriteWithTrailersCausesUnexpectedEOS() throws Exception { - // 1. Setup Server - Server server = new Server(); - HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(); - ServerConnector connector = new ServerConnector(server, h2); - connector.setPort(0); - server.addConnector(connector); - - server.setHandler( - new Handler.Abstract() { - @Override - public boolean handle(Request request, Response response, Callback callback) { - // Set the trailers that gRPC-Java expects - response.setTrailersSupplier(() -> HttpFields.build().put("grpc-status", "0")); - - // The core of the issue: sending a "last" write with no data - // This triggers Data.eof() in Jetty 12 instead of a HEADERS frame - response.write(true, null, callback); - return true; - } - }); - server.start(); - - // 2. Setup Client - HTTP2Client client = new HTTP2Client(); - client.start(); - - CompletableFuture resultFuture = new CompletableFuture<>(); - InetSocketAddress address = new InetSocketAddress("localhost", connector.getLocalPort()); - - client.connect( - address, - new Session.Listener() {}, - new Promise<>() { - @Override - public void succeeded(Session session) { - HttpURI uri = HttpURI.from("http://localhost:" + connector.getLocalPort() + "/"); - MetaData.Request metaData = - new MetaData.Request("GET", uri, HttpVersion.HTTP_2, HttpFields.build()); - HeadersFrame requestFrame = new HeadersFrame(metaData, null, true); - - session.newStream( - requestFrame, - Promise.noop(), - new Stream.Listener() { - @Override - public void onDataAvailable(Stream stream) { - // Access the Data wrapper you identified - Stream.Data data = stream.readData(); - if (data != null) { - DataFrame frame = data.frame(); - - // If we receive an empty DATA frame with END_STREAM, - // Jetty has closed the stream before sending trailers. - if (frame.isEndStream()) { - resultFuture.completeExceptionally( - new RuntimeException( - "BUG REPRODUCED: Received Data.EOF (DATA frame with END_STREAM)." - + " This violates gRPC protocol as trailers in HEADERS were" - + " expected.")); - return; - } - } - stream.demand(); - } - - @Override - public void onHeaders(Stream stream, HeadersFrame frame) { - MetaData metaData = frame.getMetaData(); - HttpFields fields = metaData.getHttpFields(); - // If trailers arrive correctly with END_STREAM, the test passes - if (frame.isEndStream() && fields.contains("grpc-status")) { - resultFuture.complete(null); - } - } - - @Override - public void onFailure( - Stream stream, - int error, - String reason, - Throwable failure, - Callback callback) { - resultFuture.completeExceptionally(failure); - callback.succeeded(); - } - }); - } - - @Override - public void failed(Throwable x) { - resultFuture.completeExceptionally(x); - } - }); - - // 3. Evaluation - try { - // We expect this to time out or fail if the bug exists - resultFuture.get(5, TimeUnit.SECONDS); - System.out.println("SUCCESS: Trailers arrived correctly on a HEADERS frame."); - } catch (Exception e) { - // In the bug scenario, e.getCause() will contain our RuntimeException - String message = e.getMessage() != null ? e.getMessage() : e.getCause().getMessage(); - fail("Test failed due to Jetty behavior: " + message); - } finally { - server.stop(); - client.stop(); - } - } - - @Test - public void testBidiExchangeWithTrailers() throws Exception { - // 1. Setup Server - Server server = new Server(); - ServerConnector connector = new ServerConnector(server, new HTTP2ServerConnectionFactory()); - server.addConnector(connector); - - server.setHandler( - new Handler.Abstract() { - @Override - public boolean handle(Request request, Response response, Callback callback) { - // Set trailers supplier - response.setTrailersSupplier(() -> HttpFields.build().put("grpc-status", "0")); - - // Obtain the content source to read client data - - request.demand( - () -> { - var chunk = request.read(); - if (chunk != null) { - // If we received data from client - if (BufferUtil.hasContent(chunk.getByteBuffer())) { - // Send an echo response back - var echo = ByteBuffer.wrap("Server Echo".getBytes(StandardCharsets.UTF_8)); - response.write(false, echo, Callback.NOOP); - } - - // Check if this was the last chunk from client - if (chunk.isLast()) { - // SIGNAL END OF SERVER STREAM - // This triggers the Data.EOF bug in 12.1.5 - response.write(true, null, callback); - } - chunk.release(); - } - }); - return true; - } - }); - server.start(); - - // 2. Setup Client - HTTP2Client client = new HTTP2Client(); - client.start(); - CompletableFuture resultFuture = new CompletableFuture<>(); - int port = connector.getLocalPort(); - - client.connect( - new InetSocketAddress("localhost", port), - new Session.Listener() {}, - new Promise<>() { - @Override - public void succeeded(Session session) { - HttpURI uri = HttpURI.from("http://localhost:" + port + "/"); - MetaData.Request metaData = - new MetaData.Request("POST", uri, HttpVersion.HTTP_2, HttpFields.build()); - - // Client starts stream - HeadersFrame headers = new HeadersFrame(metaData, null, false); - - session.newStream( - headers, - Promise.noop(), - new Stream.Listener() { - @Override - public void onDataAvailable(Stream stream) { - Stream.Data data = stream.readData(); - if (data != null) { - if (data.frame().isEndStream()) { - // If Jetty sends a DATA frame with END_STREAM, the bug is reproduced - resultFuture.completeExceptionally( - new RuntimeException( - "Received DATA frame with END_STREAM flag. Expected Trailers in" - + " HEADERS.")); - return; - } - } - stream.demand(); - } - - @Override - public void onHeaders(Stream stream, HeadersFrame frame) { - HttpFields fields = frame.getMetaData().getHttpFields(); - if (frame.isEndStream() && fields.contains("grpc-status")) { - resultFuture.complete(null); - } - } - }); - - // Client sends one message and closes client-side stream - session - .getStreams() - .forEach( - s -> { - ByteBuffer clientMsg = - ByteBuffer.wrap("Client Hello".getBytes(StandardCharsets.UTF_8)); - s.data(new DataFrame(s.getId(), clientMsg, true), Callback.NOOP); - }); - } - }); - - // 3. Evaluation - try { - resultFuture.get(5, TimeUnit.SECONDS); - System.out.println("SUCCESS: Bidi exchange completed correctly."); - } catch (Exception e) { - String msg = (e.getCause() != null) ? e.getCause().getMessage() : e.getMessage(); - fail("Reproduced: " + msg); - } finally { - server.stop(); - client.stop(); - } - } -} diff --git a/tests/src/test/java/examples/grpc/ReflectionClient.java b/tests/src/test/java/examples/grpc/ReflectionClient.java deleted file mode 100644 index bdcc61795a..0000000000 --- a/tests/src/test/java/examples/grpc/ReflectionClient.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package examples.grpc; - -import java.util.concurrent.CountDownLatch; - -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; -import io.grpc.reflection.v1.ServerReflectionGrpc; -import io.grpc.reflection.v1.ServerReflectionRequest; -import io.grpc.reflection.v1.ServerReflectionResponse; -import io.grpc.stub.StreamObserver; - -public class ReflectionClient { - public static void main(String[] args) throws InterruptedException { - ManagedChannel channel = - ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build(); - try { - var latch = new CountDownLatch(1); - ServerReflectionGrpc.ServerReflectionStub stub = ServerReflectionGrpc.newStub(channel); - - // 1. Prepare the response observer - StreamObserver responseObserver = - new StreamObserver<>() { - @Override - public void onNext(ServerReflectionResponse response) { - // This is the part that returns the list of services - response - .getListServicesResponse() - .getServiceList() - .forEach( - s -> { - System.out.println("Service: " + s.getName()); - }); - } - - @Override - public void onError(Throwable t) { - t.printStackTrace(); - } - - @Override - public void onCompleted() { - latch.countDown(); - } - }; - - // 2. Open the bidirectional stream - StreamObserver requestObserver = - stub.serverReflectionInfo(responseObserver); - - // 3. Send the "List Services" request - requestObserver.onNext( - ServerReflectionRequest.newBuilder() - .setListServices("") // The trigger for 'list' - .setHost("localhost") - .build()); - - // 4. Signal half-close (Very important for reflection) - requestObserver.onCompleted(); - - latch.await(); - } finally { - channel.shutdown(); - } - } -} diff --git a/tests/src/test/java/examples/grpc/ChatServiceImpl.java b/tests/src/test/java/io/jooby/i3875/EchoChatService.java similarity index 66% rename from tests/src/test/java/examples/grpc/ChatServiceImpl.java rename to tests/src/test/java/io/jooby/i3875/EchoChatService.java index 280511763a..676bf7baba 100644 --- a/tests/src/test/java/examples/grpc/ChatServiceImpl.java +++ b/tests/src/test/java/io/jooby/i3875/EchoChatService.java @@ -3,25 +3,17 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package examples.grpc; +package io.jooby.i3875; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.example.grpc.ChatMessage; -import com.example.grpc.ChatServiceGrpc; import io.grpc.stub.StreamObserver; -public class ChatServiceImpl extends ChatServiceGrpc.ChatServiceImplBase { - private final Logger log = LoggerFactory.getLogger(getClass()); +public class EchoChatService extends ChatServiceGrpc.ChatServiceImplBase { @Override public StreamObserver chatStream(StreamObserver responseObserver) { return new StreamObserver() { @Override public void onNext(ChatMessage request) { - log.info("Got message: {}", request.getTextBytes()); - // Logic: Echo back the text with a prefix ChatMessage response = ChatMessage.newBuilder() .setUser("Server") @@ -38,7 +30,6 @@ public void onError(Throwable t) { @Override public void onCompleted() { - log.info("Chat closed"); responseObserver.onCompleted(); } }; diff --git a/tests/src/test/java/examples/grpc/GreeterService.java b/tests/src/test/java/io/jooby/i3875/EchoGreeterService.java similarity index 68% rename from tests/src/test/java/examples/grpc/GreeterService.java rename to tests/src/test/java/io/jooby/i3875/EchoGreeterService.java index fa6976499c..1e70594ba7 100644 --- a/tests/src/test/java/examples/grpc/GreeterService.java +++ b/tests/src/test/java/io/jooby/i3875/EchoGreeterService.java @@ -3,14 +3,11 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package examples.grpc; +package io.jooby.i3875; -import com.example.grpc.GreeterGrpc; -import com.example.grpc.HelloReply; -import com.example.grpc.HelloRequest; import io.grpc.stub.StreamObserver; -public class GreeterService extends GreeterGrpc.GreeterImplBase { +public class EchoGreeterService extends GreeterGrpc.GreeterImplBase { @Override public void sayHello(HelloRequest req, StreamObserver responseObserver) { HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build(); diff --git a/tests/src/test/java/io/jooby/i3875/GrpcTest.java b/tests/src/test/java/io/jooby/i3875/GrpcTest.java new file mode 100644 index 0000000000..1d6ea05394 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3875/GrpcTest.java @@ -0,0 +1,366 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3875; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.protobuf.services.ProtoReflectionServiceV1; +import io.grpc.reflection.v1.ServerReflectionGrpc; +import io.grpc.reflection.v1.ServerReflectionRequest; +import io.grpc.reflection.v1.ServerReflectionResponse; +import io.grpc.stub.StreamObserver; +import io.jooby.Jooby; +import io.jooby.ServerOptions; +import io.jooby.grpc.GrpcModule; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; + +public class GrpcTest { + + private void setupApp(Jooby app) { + app.install( + new GrpcModule( + new EchoGreeterService(), + new EchoChatService(), + ProtoReflectionServiceV1.newInstance())); + } + + @ServerTest + void shouldHandleUnaryRequests(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define(this::setupApp) + .ready( + http -> { + var channel = + ManagedChannelBuilder.forAddress("localhost", runner.getAllocatedPort()) + .usePlaintext() + .build(); + + try { + var stub = GreeterGrpc.newBlockingStub(channel); + var response = stub.sayHello(HelloRequest.newBuilder().setName("Edgar").build()); + + assertThat(response.getMessage()).isEqualTo("Hello Edgar"); + } finally { + channel.shutdown(); + } + }); + } + + @ServerTest + void shouldHandleDeadlineExceeded(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define(this::setupApp) + .ready( + http -> { + var channel = + ManagedChannelBuilder.forAddress("localhost", runner.getAllocatedPort()) + .usePlaintext() + .build(); + + try { + // Attach an impossibly short deadline (1 millisecond) to the stub + var stub = + GreeterGrpc.newBlockingStub(channel) + .withDeadlineAfter(1, TimeUnit.MILLISECONDS); + + var exception = + org.junit.jupiter.api.Assertions.assertThrows( + StatusRuntimeException.class, + () -> stub.sayHello(HelloRequest.newBuilder().setName("Edgar").build())); + + // Assert that the bridge correctly caught the timeout and returned Status 4 + assertThat(exception.getStatus().getCode()) + .isEqualTo(Status.Code.DEADLINE_EXCEEDED); + } finally { + channel.shutdown(); + } + }); + } + + @ServerTest + void shouldHandleBidiStreaming(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define(this::setupApp) + .ready( + http -> { + var channel = + ManagedChannelBuilder.forAddress("localhost", runner.getAllocatedPort()) + .usePlaintext() + .build(); + + try { + var asyncStub = ChatServiceGrpc.newStub(channel); + var responses = new CopyOnWriteArrayList(); + var latch = new CountDownLatch(1); + + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ChatMessage value) { + responses.add(value.getText()); + } + + @Override + public void onError(Throwable t) { + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + var requestObserver = asyncStub.chatStream(responseObserver); + + requestObserver.onNext( + ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 1").build()); + requestObserver.onNext( + ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 2").build()); + requestObserver.onCompleted(); + + boolean completed = latch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(responses).containsExactly("Echo: Ping 1", "Echo: Ping 2"); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + channel.shutdown(); + } + }); + } + + @ServerTest + void shouldHandleServerStreaming(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define(this::setupApp) + .ready( + http -> { + var channel = + ManagedChannelBuilder.forAddress("localhost", runner.getAllocatedPort()) + .usePlaintext() + .build(); + + try { + var asyncStub = ChatServiceGrpc.newStub(channel); + var responses = new CopyOnWriteArrayList(); + var latch = new CountDownLatch(1); + + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ChatMessage value) { + responses.add(value.getText()); + } + + @Override + public void onError(Throwable t) { + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + // Assuming a server-streaming method exists: rpc ServerStream(ChatMessage) returns + // (stream ChatMessage) + // asyncStub.serverStream(ChatMessage.newBuilder().setText("Trigger").build(), + // responseObserver); + // + // boolean completed = latch.await(5, TimeUnit.SECONDS); + // assertThat(completed).isTrue(); + // assertThat(responses.size()).isGreaterThan(1); + + } finally { + channel.shutdown(); + } + }); + } + + @ServerTest + void shouldHandleReflection(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define(this::setupApp) + .ready( + http -> { + var channel = + ManagedChannelBuilder.forAddress("localhost", runner.getAllocatedPort()) + .usePlaintext() + .build(); + + try { + var stub = ServerReflectionGrpc.newStub(channel); + var registeredServices = new CopyOnWriteArrayList(); + var latch = new CountDownLatch(1); + + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ServerReflectionResponse response) { + response + .getListServicesResponse() + .getServiceList() + .forEach(s -> registeredServices.add(s.getName())); + } + + @Override + public void onError(Throwable t) { + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + var requestObserver = stub.serverReflectionInfo(responseObserver); + + requestObserver.onNext( + ServerReflectionRequest.newBuilder() + .setListServices("") + .setHost("localhost") + .build()); + requestObserver.onCompleted(); + + boolean completed = latch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(registeredServices) + .contains( + "io.jooby.i3875.Greeter", + "io.jooby.i3875.ChatService", + "grpc.reflection.v1.ServerReflection"); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + channel.shutdown(); + } + }); + } + + @ServerTest + void shouldHandleGrpcurlReflection(ServerTestRunner runner) { + org.junit.jupiter.api.Assumptions.assumeTrue( + isGrpcurlInstalled(), "grpcurl is not installed. Skipping strict HTTP/2 compliance test."); + + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define(this::setupApp) + .ready( + http -> { + try { + var pb = + new ProcessBuilder( + "grpcurl", "-plaintext", "localhost:" + runner.getAllocatedPort(), "list"); + + var process = pb.start(); + var output = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var error = + new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + + int exitCode = process.waitFor(); + + assertThat(exitCode) + .withFailMessage("grpcurl failed with error: " + error) + .isEqualTo(0); + + assertThat(output) + .contains( + "io.jooby.i3875.Greeter", + "io.jooby.i3875.ChatService", + "grpc.reflection.v1.ServerReflection"); + + } catch (Exception e) { + throw new RuntimeException("Failed to execute grpcurl test", e); + } + }); + } + + /** + * When a gRPC client requests a method that doesn't exist, our native handlers will ignore it. + * Jooby's core router will then catch it and return a standard HTTP 404 Not Found. According to + * the gRPC-over-HTTP/2 specification, the gRPC client will automatically translate a pure HTTP + * 404 into a gRPC UNIMPLEMENTED (Status Code 12) exception. + * + * @param runner Sever runner. + */ + @ServerTest + void shouldHandleMethodNotFound(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define(this::setupApp) + .ready( + http -> { + var channel = + ManagedChannelBuilder.forAddress("localhost", runner.getAllocatedPort()) + .usePlaintext() + .build(); + + try { + // 1. Create a fake method descriptor for a non-existent method + var unknownMethod = + io.grpc.MethodDescriptor.newBuilder() + .setType(io.grpc.MethodDescriptor.MethodType.UNARY) + .setFullMethodName("io.jooby.i3875.Greeter/UnknownMethod") + .setRequestMarshaller( + io.grpc.protobuf.ProtoUtils.marshaller( + HelloRequest.getDefaultInstance())) + .setResponseMarshaller( + io.grpc.protobuf.ProtoUtils.marshaller(HelloReply.getDefaultInstance())) + .build(); + + // 2. Execute the call manually and expect an exception + var exception = + org.junit.jupiter.api.Assertions.assertThrows( + io.grpc.StatusRuntimeException.class, + () -> + io.grpc.stub.ClientCalls.blockingUnaryCall( + channel, + unknownMethod, + io.grpc.CallOptions.DEFAULT, + HelloRequest.newBuilder().setName("Edgar").build())); + + // 3. Assert that Jooby's HTTP 404 is correctly translated by the gRPC client into + // UNIMPLEMENTED + assertThat(exception.getStatus().getCode()) + .isEqualTo(io.grpc.Status.Code.UNIMPLEMENTED); + + } finally { + channel.shutdown(); + } + }); + } + + private boolean isGrpcurlInstalled() { + try { + var process = new ProcessBuilder("grpcurl", "-version").start(); + return process.waitFor() == 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/tests/src/test/java/io/jooby/test/GrpcTest.java b/tests/src/test/java/io/jooby/test/GrpcTest.java deleted file mode 100644 index 838c7c985e..0000000000 --- a/tests/src/test/java/io/jooby/test/GrpcTest.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.example.grpc.GreeterGrpc; -import com.example.grpc.HelloReply; -import com.example.grpc.HelloRequest; -import io.grpc.stub.StreamObserver; -import io.jooby.ServerOptions; -import io.jooby.grpc.GrpcModule; -import io.jooby.junit.ServerTest; -import io.jooby.junit.ServerTestRunner; -import okhttp3.*; - -public class GrpcTest { - - public class GreeterService extends GreeterGrpc.GreeterImplBase { - @Override - public void sayHello(HelloRequest req, StreamObserver responseObserver) { - HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build(); - responseObserver.onNext(reply); - responseObserver.onCompleted(); - } - } - - @ServerTest - public void http2(ServerTestRunner runner) { - runner - .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) - .define( - app -> { - app.install(new GrpcModule(new GreeterService())); - }) - .ready( - (http, https) -> { - https.get( - "/", - rsp -> { - assertEquals( - "{secure=true, protocol=HTTP/2.0, scheme=https}", rsp.body().string()); - }); - http.get( - "/", - rsp -> { - assertEquals( - "{secure=false, protocol=HTTP/1.1, scheme=http}", rsp.body().string()); - }); - }); - } -} diff --git a/tests/src/test/resources/logback.xml b/tests/src/test/resources/logback.xml index 36266fe72c..442413b895 100644 --- a/tests/src/test/resources/logback.xml +++ b/tests/src/test/resources/logback.xml @@ -2,22 +2,12 @@ - [%d{ISO8601}]-[%thread] %-5level %logger - %msg%n + %-5p [%d{ISO8601}] [%thread] %msg %ex{0}%n - - - - - - - - - - From ca3695bad3e5e75f3026913ea3e172abffc37fcc Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 12 Mar 2026 19:42:08 -0300 Subject: [PATCH 07/11] - fix failing test on GitHub Actions, ref #3875 --- tests/src/test/java/io/jooby/i3875/GrpcTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/src/test/java/io/jooby/i3875/GrpcTest.java b/tests/src/test/java/io/jooby/i3875/GrpcTest.java index 1d6ea05394..6cfb3c372d 100644 --- a/tests/src/test/java/io/jooby/i3875/GrpcTest.java +++ b/tests/src/test/java/io/jooby/i3875/GrpcTest.java @@ -130,10 +130,20 @@ public void onCompleted() { requestObserver.onNext( ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 1").build()); + + // Add a tiny delay to prevent CI thread-scheduler race conditions + // where the server closes the stream before Undertow finishes flushing Ping 2. + Thread.sleep(50); + requestObserver.onNext( ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 2").build()); + + // Allow Ping 2 to reach the server before sending the close signal + Thread.sleep(50); + requestObserver.onCompleted(); + // Wait for the server stream to gracefully complete boolean completed = latch.await(5, TimeUnit.SECONDS); assertThat(completed).isTrue(); From 8c24d299792329ab8a316a06a4f48158c358b1b0 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 13 Mar 2026 07:26:18 -0300 Subject: [PATCH 08/11] - add DI support to gRPC services --- .../main/java/io/jooby/grpc/GrpcModule.java | 102 +++++++++++------- .../internal/grpc/DefaultGrpcProcessor.java | 10 +- .../jooby/grpc/DefaultGrpcProcessorTest.java | 3 +- .../io/jooby/i3875/EchoGreeterService.java | 10 +- .../test/java/io/jooby/i3875/EchoService.java | 13 +++ .../test/java/io/jooby/i3875/GrpcTest.java | 20 ++-- 6 files changed, 104 insertions(+), 54 deletions(-) create mode 100644 tests/src/test/java/io/jooby/i3875/EchoService.java diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java index d1ca215446..e889d2840f 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java @@ -5,25 +5,21 @@ */ package io.jooby.grpc; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import org.slf4j.bridge.SLF4JBridgeHandler; import edu.umd.cs.findbugs.annotations.NonNull; import io.grpc.BindableService; import io.grpc.MethodDescriptor; -import io.grpc.Server; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; import io.jooby.*; import io.jooby.internal.grpc.DefaultGrpcProcessor; public class GrpcModule implements Extension { - private final List services; - private final Map> registry = new HashMap<>(); - private Server grpcServer; + private final List services = new ArrayList<>(); + private final List> serviceClasses = new ArrayList<>(); static { // Optionally remove existing handlers attached to the j.u.l root logger @@ -33,52 +29,78 @@ public class GrpcModule implements Extension { } public GrpcModule(BindableService... services) { - this.services = List.of(services); + this.services.addAll(Arrays.asList(services)); + } + + @SafeVarargs + public GrpcModule(Class... serviceClasses) { + bind(serviceClasses); + } + + @SafeVarargs + public final GrpcModule bind(Class... serviceClasses) { + this.serviceClasses.addAll(List.of(serviceClasses)); + return this; } @Override public void install(@NonNull Jooby app) throws Exception { var serverName = app.getName(); var builder = InProcessServerBuilder.forName(serverName); + final Map> registry = new HashMap<>(); // 1. Register user-provided services for (var service : services) { - builder.addService(service); - for (var method : service.bindService().getMethods()) { - var descriptor = method.getMethodDescriptor(); - String methodFullName = descriptor.getFullMethodName(); - registry.put(methodFullName, descriptor); - String routePath = "/" + methodFullName; - - // - app.post( - routePath, - ctx -> { - throw new IllegalStateException( - "gRPC request reached the standard HTTP router for path: " - + routePath - + ". " - + "This means the native gRPC server interceptor was bypassed. " - + "Ensure you are running Jetty, Netty, or Undertow with HTTP/2 enabled, " - + "and that the GrpcProcessor SPI is correctly loaded."); - }); - } + bindService(app, builder, registry, service); } - this.grpcServer = builder.build().start(); - - // KEEP .directExecutor() here! - // This ensures that when the background gRPC worker finishes, it instantly pushes - // the response back to Undertow/Netty without wasting time on another thread hop. - var channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); var services = app.getServices(); - var bridge = new DefaultGrpcProcessor(channel, registry); + var processor = new DefaultGrpcProcessor(registry); + services.put(GrpcProcessor.class, processor); + + // Lazy init service from DI. + app.onStarting( + () -> { + for (Class serviceClass : serviceClasses) { + var service = app.require(serviceClass); + bindService(app, builder, registry, service); + } + var grpcServer = builder.build().start(); - // Register it in the Service Registry so the server layer can find it - services.put(DefaultGrpcProcessor.class, bridge); - services.put(GrpcProcessor.class, bridge); + // KEEP .directExecutor() here! + // This ensures that when the background gRPC worker finishes, it instantly pushes + // the response back to Undertow/Netty without wasting time on another thread hop. + var channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + processor.setChannel(channel); - app.onStop(channel::shutdownNow); - app.onStop(grpcServer::shutdownNow); + app.onStop(channel::shutdownNow); + app.onStop(grpcServer::shutdownNow); + }); + } + + private static void bindService( + Jooby app, + InProcessServerBuilder server, + Map> registry, + BindableService service) { + server.addService(service); + for (var method : service.bindService().getMethods()) { + var descriptor = method.getMethodDescriptor(); + String methodFullName = descriptor.getFullMethodName(); + registry.put(methodFullName, descriptor); + String routePath = "/" + methodFullName; + // + app.post( + routePath, + ctx -> { + throw new IllegalStateException( + "gRPC request reached the standard HTTP router for path: " + + routePath + + ". " + + "This means the native gRPC server interceptor was bypassed. " + + "Ensure you are running Jetty, Netty, or Undertow with HTTP/2 enabled, " + + "and that the GrpcProcessor SPI is correctly loaded."); + }); + } } } diff --git a/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/DefaultGrpcProcessor.java b/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/DefaultGrpcProcessor.java index c99c4774c8..16205a9b2d 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/DefaultGrpcProcessor.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/internal/grpc/DefaultGrpcProcessor.java @@ -47,15 +47,17 @@ public byte[] parse(InputStream stream) { } private final Logger log = LoggerFactory.getLogger(getClass()); - private final ManagedChannel channel; + private ManagedChannel channel; private final Map> registry; - public DefaultGrpcProcessor( - ManagedChannel channel, Map> registry) { - this.channel = channel; + public DefaultGrpcProcessor(Map> registry) { this.registry = registry; } + public void setChannel(ManagedChannel channel) { + this.channel = channel; + } + @Override public boolean isGrpcMethod(String path) { // gRPC paths typically come in as "/package.Service/Method" diff --git a/modules/jooby-grpc/src/test/java/io/jooby/grpc/DefaultGrpcProcessorTest.java b/modules/jooby-grpc/src/test/java/io/jooby/grpc/DefaultGrpcProcessorTest.java index 506453e71c..940ef8acfc 100644 --- a/modules/jooby-grpc/src/test/java/io/jooby/grpc/DefaultGrpcProcessorTest.java +++ b/modules/jooby-grpc/src/test/java/io/jooby/grpc/DefaultGrpcProcessorTest.java @@ -54,7 +54,8 @@ public void setUp() { // The interceptor wraps the channel, but eventually delegates to the real one when(channel.newCall(any(MethodDescriptor.class), any(CallOptions.class))).thenReturn(call); - bridge = new DefaultGrpcProcessor(channel, registry); + bridge = new DefaultGrpcProcessor(registry); + bridge.setChannel(channel); } @Test diff --git a/tests/src/test/java/io/jooby/i3875/EchoGreeterService.java b/tests/src/test/java/io/jooby/i3875/EchoGreeterService.java index 1e70594ba7..c76922aee3 100644 --- a/tests/src/test/java/io/jooby/i3875/EchoGreeterService.java +++ b/tests/src/test/java/io/jooby/i3875/EchoGreeterService.java @@ -8,9 +8,17 @@ import io.grpc.stub.StreamObserver; public class EchoGreeterService extends GreeterGrpc.GreeterImplBase { + + private final EchoService echoService; + + @jakarta.inject.Inject + public EchoGreeterService(EchoService echoService) { + this.echoService = echoService; + } + @Override public void sayHello(HelloRequest req, StreamObserver responseObserver) { - HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build(); + HelloReply reply = HelloReply.newBuilder().setMessage(echoService.echo(req.getName())).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } diff --git a/tests/src/test/java/io/jooby/i3875/EchoService.java b/tests/src/test/java/io/jooby/i3875/EchoService.java new file mode 100644 index 0000000000..25d44d594e --- /dev/null +++ b/tests/src/test/java/io/jooby/i3875/EchoService.java @@ -0,0 +1,13 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3875; + +public class EchoService { + + public String echo(String value) { + return "Hello " + value; + } +} diff --git a/tests/src/test/java/io/jooby/i3875/GrpcTest.java b/tests/src/test/java/io/jooby/i3875/GrpcTest.java index 6cfb3c372d..fd2a8a6de1 100644 --- a/tests/src/test/java/io/jooby/i3875/GrpcTest.java +++ b/tests/src/test/java/io/jooby/i3875/GrpcTest.java @@ -23,17 +23,18 @@ import io.jooby.Jooby; import io.jooby.ServerOptions; import io.jooby.grpc.GrpcModule; +import io.jooby.guice.GuiceModule; import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; public class GrpcTest { private void setupApp(Jooby app) { + app.install(new GuiceModule()); + app.install( - new GrpcModule( - new EchoGreeterService(), - new EchoChatService(), - ProtoReflectionServiceV1.newInstance())); + new GrpcModule(new EchoChatService(), ProtoReflectionServiceV1.newInstance()) + .bind(EchoGreeterService.class)); } @ServerTest @@ -50,9 +51,10 @@ void shouldHandleUnaryRequests(ServerTestRunner runner) { try { var stub = GreeterGrpc.newBlockingStub(channel); - var response = stub.sayHello(HelloRequest.newBuilder().setName("Edgar").build()); + var response = + stub.sayHello(HelloRequest.newBuilder().setName("Pablo Marmol").build()); - assertThat(response.getMessage()).isEqualTo("Hello Edgar"); + assertThat(response.getMessage()).isEqualTo("Hello Pablo Marmol"); } finally { channel.shutdown(); } @@ -80,7 +82,9 @@ void shouldHandleDeadlineExceeded(ServerTestRunner runner) { var exception = org.junit.jupiter.api.Assertions.assertThrows( StatusRuntimeException.class, - () -> stub.sayHello(HelloRequest.newBuilder().setName("Edgar").build())); + () -> + stub.sayHello( + HelloRequest.newBuilder().setName("Pablo Marmol").build())); // Assert that the bridge correctly caught the timeout and returned Status 4 assertThat(exception.getStatus().getCode()) @@ -352,7 +356,7 @@ void shouldHandleMethodNotFound(ServerTestRunner runner) { channel, unknownMethod, io.grpc.CallOptions.DEFAULT, - HelloRequest.newBuilder().setName("Edgar").build())); + HelloRequest.newBuilder().setName("Pablo Marmol").build())); // 3. Assert that Jooby's HTTP 404 is correctly translated by the gRPC client into // UNIMPLEMENTED From f3ae53fd1fe2f61b8bfdb2bdb73ec2146d3e28d7 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 13 Mar 2026 07:29:27 -0300 Subject: [PATCH 09/11] build: remove dead code --- dump.txt | 0 list.txt | 11 --- .../internal/netty/NettyByteBufBody.java | 97 ------------------- 3 files changed, 108 deletions(-) delete mode 100644 dump.txt delete mode 100644 list.txt delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java diff --git a/dump.txt b/dump.txt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/list.txt b/list.txt deleted file mode 100644 index bb31be4d70..0000000000 --- a/list.txt +++ /dev/null @@ -1,11 +0,0 @@ -INFO [2026-01-07 18:40:39,944] [worker-91] JettySubscription read data started -INFO [2026-01-07 18:40:39,945] [worker-91] JettySubscription byte read: 00000000033a012a -INFO [2026-01-07 18:40:39,945] [worker-91] GrpcRequestBridge deframe 3a012a -INFO [2026-01-07 18:40:39,946] [worker-91] GrpcRequestBridge asking for more request(1) -INFO [2026-01-07 18:40:39,984] [grpc-default-executor-1] UnifiedGrpcBridge onNext Send 12033a012a32460a120a10746573742e43686174536572766963650a250a23677270632e7265666c656374696f6e2e76312e5365727665725265666c656374696f6e0a090a0747726565746572 -INFO [2026-01-07 18:40:44,114] [grpc-default-executor-0] UnifiedGrpcBridge error io.grpc.StatusRuntimeException: UNAVAILABLE: Channel shutdownNow invoked - -INFO [2026-01-07 18:40:44,114] [Thread-0] GrpcServer Stopped GrpcServer -INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription read data started -INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription last reach -INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription handle complete diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java deleted file mode 100644 index e3ec01b05c..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import java.io.InputStream; -import java.lang.reflect.Type; -import java.nio.channels.Channels; -import java.nio.channels.ReadableByteChannel; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; -import io.jooby.Body; -import io.jooby.Context; -import io.jooby.MediaType; -import io.jooby.value.Value; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufInputStream; -import io.netty.buffer.ByteBufUtil; - -public class NettyByteBufBody implements Body { - private final Context ctx; - private final ByteBuf data; - private final long length; - - public NettyByteBufBody(Context ctx, ByteBuf data) { - this.ctx = ctx; - this.data = data; - this.length = data.readableBytes(); - } - - @Override - public boolean isInMemory() { - return true; - } - - @Override - public long getSize() { - return length; - } - - @Override - public InputStream stream() { - return new ByteBufInputStream(data); - } - - @Override - public Value get(@NonNull String name) { - return Value.missing(ctx.getValueFactory(), name); - } - - @Override - public Value getOrDefault(@NonNull String name, @NonNull String defaultValue) { - return Value.value(ctx.getValueFactory(), name, defaultValue); - } - - @Override - public ReadableByteChannel channel() { - return Channels.newChannel(stream()); - } - - @Override - public byte[] bytes() { - return ByteBufUtil.getBytes(data); - } - - @NonNull @Override - public String value() { - return value(StandardCharsets.UTF_8); - } - - @Override - public String name() { - return "body"; - } - - @NonNull @Override - public T to(@NonNull Type type) { - return ctx.decode(type, ctx.getRequestType(MediaType.text)); - } - - @Nullable @Override - public T toNullable(@NonNull Type type) { - return ctx.decode(type, ctx.getRequestType(MediaType.text)); - } - - @Override - public Map> toMultimap() { - return Collections.emptyMap(); - } -} From 86f51c06e811d5238db87aeaae77c3138720ab05 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 13 Mar 2026 10:50:15 -0300 Subject: [PATCH 10/11] gRPC: javadoc for major/public components --- .../src/main/java/io/jooby/GrpcExchange.java | 62 +++++++++++-- .../src/main/java/io/jooby/GrpcProcessor.java | 36 +++++++- .../main/java/io/jooby/grpc/GrpcModule.java | 91 ++++++++++++++++++- 3 files changed, 178 insertions(+), 11 deletions(-) diff --git a/jooby/src/main/java/io/jooby/GrpcExchange.java b/jooby/src/main/java/io/jooby/GrpcExchange.java index 217a1aa887..7e3a068a06 100644 --- a/jooby/src/main/java/io/jooby/GrpcExchange.java +++ b/jooby/src/main/java/io/jooby/GrpcExchange.java @@ -11,23 +11,73 @@ import edu.umd.cs.findbugs.annotations.Nullable; -/** Server-agnostic abstraction for HTTP/2 trailing-header exchanges. */ +/** + * Server-agnostic abstraction for a native HTTP/2 gRPC exchange. + * + *

This interface bridges the gap between the underlying web server (Undertow, Netty, or Jetty) + * and the reactive gRPC processor. Because gRPC heavily relies on HTTP/2 multiplexing, asynchronous + * I/O, and trailing headers (trailers) to communicate status codes, standard HTTP/1.1 context + * abstractions are insufficient. + * + *

Native server interceptors wrap their respective request/response objects into this interface, + * allowing the {@link GrpcProcessor} to read headers, push zero-copy byte frames, and finalize the + * stream with standard gRPC trailers without knowing which server engine is actually running. + */ public interface GrpcExchange { + /** + * Retrieves the requested URI path. + * + *

In gRPC, this dictates the routing and strictly follows the pattern: {@code + * /Fully.Qualified.ServiceName/MethodName}. + * + * @return The exact path of the incoming HTTP/2 request. + */ String getRequestPath(); + /** + * Retrieves the value of the specified HTTP request header. + * + * @param name The name of the header (case-insensitive). + * @return The header value, or {@code null} if the header is not present. + */ @Nullable String getHeader(String name); + /** + * Retrieves all HTTP request headers. + * + * @return A map containing all headers provided by the client. + */ Map getHeaders(); - /** Write framed bytes to the underlying non-blocking socket. */ + /** + * Writes a gRPC-framed byte payload to the underlying non-blocking socket. + * + *

This method must push the buffer to the native network layer without blocking the invoking + * thread (which is typically a background gRPC worker). The implementation is responsible for + * translating the ByteBuffer into the server's native data format and flushing it over the + * network. + * + * @param payload The properly framed 5-byte-prefixed gRPC payload to send. + * @param onFailure A callback invoked immediately if the asynchronous network write fails (e.g., + * if the client abruptly disconnects or the channel is closed). + */ void send(ByteBuffer payload, Consumer onFailure); /** - * Closes the HTTP/2 stream with trailing headers. + * Closes the HTTP/2 stream by appending the mandatory gRPC trailing headers. + * + *

In the gRPC specification, a successful response or an application-level error is + * communicated not by standard HTTP status codes (which are always 200 OK), but by appending + * HTTP/2 trailers ({@code grpc-status} and {@code grpc-message}) at the very end of the stream. + * + *

Calling this method informs the native server to write those trailing headers and formally + * close the bidirectional stream. * - * @param statusCode The gRPC status code (e.g., 0 for OK, 12 for UNIMPLEMENTED). - * @param description Optional status message. + * @param statusCode The gRPC integer status code (e.g., {@code 0} for OK, {@code 12} for + * UNIMPLEMENTED, {@code 4} for DEADLINE_EXCEEDED). + * @param description An optional, human-readable status message detailing the result or error. + * May be {@code null}. */ - void close(int statusCode, String description); + void close(int statusCode, @Nullable String description); } diff --git a/jooby/src/main/java/io/jooby/GrpcProcessor.java b/jooby/src/main/java/io/jooby/GrpcProcessor.java index d2dd5ed99d..b8885977ef 100644 --- a/jooby/src/main/java/io/jooby/GrpcProcessor.java +++ b/jooby/src/main/java/io/jooby/GrpcProcessor.java @@ -10,15 +10,43 @@ import edu.umd.cs.findbugs.annotations.NonNull; -/** Intercepts and processes gRPC exchanges. */ +/** + * Core Service Provider Interface (SPI) for the gRPC extension. + * + *

This processor acts as the bridge between the native HTTP/2 web servers (Undertow, Netty, + * Jetty) and the embedded gRPC engine. It is designed to intercept and process gRPC exchanges at + * the lowest possible network level, completely bypassing Jooby's standard HTTP/1.1 routing + * pipeline. This architecture ensures strict HTTP/2 specification compliance, zero-copy buffering, + * and reactive backpressure. + */ public interface GrpcProcessor { - /** Checks if the given URI path exactly matches a registered gRPC method. */ + /** + * Checks if the given URI path exactly matches a registered gRPC method. + * + *

Native server interceptors (Undertow, Netty, Jetty) use this method as a lightweight, + * fail-fast guard. If this returns {@code true}, the server will hijack the request and upgrade + * it to a native gRPC stream. If {@code false}, the request safely falls through to the standard + * Jooby router (typically resulting in a standard HTTP 404 Not Found, which gRPC clients + * gracefully translate to Status 12 UNIMPLEMENTED). + * + * @param path The incoming request path (e.g., {@code /fully.qualified.Service/MethodName}). + * @return {@code true} if the path is mapped to an active gRPC service; {@code false} otherwise. + */ boolean isGrpcMethod(String path); /** - * @return A subscriber that the server will feed ByteBuffer chunks into, or null if the exchange - * was rejected/unimplemented. + * Initiates the reactive gRPC pipeline for an incoming HTTP/2 request. + * + *

When a valid gRPC request is intercepted, the native server wraps the underlying network + * connection into a {@link GrpcExchange} and passes it to this method. The processor uses this + * exchange to asynchronously send response headers, payload byte frames, and HTTP/2 trailers. + * + * @param exchange The native server exchange representing the bidirectional HTTP/2 stream. + * @return A reactive {@link Flow.Subscriber} that the native server must feed incoming request + * payload {@link ByteBuffer} chunks into. Returns {@code null} if the exchange was rejected. + * @throws IllegalStateException If an unregistered path bypasses the {@link + * #isGrpcMethod(String)} guard. */ Flow.Subscriber process(@NonNull GrpcExchange exchange); } diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java index e889d2840f..aa3fbfcd82 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java @@ -17,6 +17,61 @@ import io.jooby.*; import io.jooby.internal.grpc.DefaultGrpcProcessor; +/** + * Native gRPC extension for Jooby. * + * + *

This module allows you to run strictly-typed gRPC services alongside standard Jooby HTTP + * routes on the exact same port. It completely bypasses standard HTTP/1.1 pipelines in favor of a + * highly optimized, reactive, native interceptor tailored for HTTP/2 multiplexing and trailing + * headers. * + * + *

Usage

+ * + *

gRPC requires HTTP/2. Ensure your Jooby application is configured to use a supported server + * (Undertow, Netty, or Jetty) with HTTP/2 enabled. * + * + *

{@code
+ * import io.jooby.Jooby;
+ * import io.jooby.ServerOptions;
+ * import io.jooby.grpc.GrpcModule;
+ * * public class App extends Jooby {
+ * {
+ * setServerOptions(new ServerOptions().setHttp2(true).setSecurePort(8443));
+ * * // Install the extension and register your services
+ * install(new GrpcModule(new GreeterService()));
+ * }
+ * }
+ * }
+ * + * * + * + *

Dependency Injection

+ * + *

If your gRPC services require external dependencies (like repositories or configuration), you + * can register the service classes instead of instances. The module will automatically provision + * them using Jooby's DI registry (e.g., Guice, Spring) during the application startup phase. * + * + *

{@code
+ * public class App extends Jooby {
+ * {
+ * install(new GuiceModule());
+ * * // Pass the class reference. Guice will instantiate it!
+ * install(new GrpcModule(GreeterService.class));
+ * }
+ * }
+ * }
+ * + * * + * + *

Note: gRPC services are inherently registered as Singletons. Ensure your + * service implementations are thread-safe and do not hold request-scoped state in instance + * variables. * + * + *

Logging

+ * + *

gRPC internally uses {@code java.util.logging}. This module automatically installs the {@link + * SLF4JBridgeHandler} to redirect all internal gRPC logs to your configured SLF4J backend. + */ public class GrpcModule implements Extension { private final List services = new ArrayList<>(); private final List> serviceClasses = new ArrayList<>(); @@ -28,21 +83,45 @@ public class GrpcModule implements Extension { SLF4JBridgeHandler.install(); } + /** + * Creates a new gRPC module with pre-instantiated service objects. * @param services One or more + * fully instantiated gRPC services. + */ public GrpcModule(BindableService... services) { this.services.addAll(Arrays.asList(services)); } + /** + * Creates a new gRPC module with service classes to be provisioned via Dependency Injection. + * * @param serviceClasses One or more gRPC service classes to be resolved from the Jooby + * registry. + */ @SafeVarargs public GrpcModule(Class... serviceClasses) { bind(serviceClasses); } + /** + * Registers additional gRPC service classes to be provisioned via Dependency Injection. * @param + * serviceClasses One or more gRPC service classes to be resolved from the Jooby registry. + * + * @return This module for chaining. + */ @SafeVarargs public final GrpcModule bind(Class... serviceClasses) { this.serviceClasses.addAll(List.of(serviceClasses)); return this; } + /** + * Installs the gRPC extension into the Jooby application. * + * + *

This method sets up the {@link GrpcProcessor} SPI, registers native fallback routes, and + * defers DI resolution and the starting of the embedded in-process gRPC server to the {@code + * onStarting} lifecycle hook. * @param app The target Jooby application. + * + * @throws Exception If an error occurs during installation. + */ @Override public void install(@NonNull Jooby app) throws Exception { var serverName = app.getName(); @@ -78,6 +157,14 @@ public void install(@NonNull Jooby app) throws Exception { }); } + /** + * Internal helper to register a service with the gRPC builder, extract its method descriptors, + * and map a fail-fast route in the Jooby router. * @param app The target Jooby application. + * + * @param server The in-process server builder. + * @param registry The method descriptor registry. + * @param service The provisioned gRPC service to bind. + */ private static void bindService( Jooby app, InProcessServerBuilder server, @@ -89,7 +176,9 @@ private static void bindService( String methodFullName = descriptor.getFullMethodName(); registry.put(methodFullName, descriptor); String routePath = "/" + methodFullName; - // + + // Map a fallback route. If a request hits this, it means the native SPI interceptor + // failed to upgrade the request, typically due to a missing HTTP/2 configuration. app.post( routePath, ctx -> { From ef7540f66efc616e41970eed07e0edc9e49120f0 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 13 Mar 2026 11:16:29 -0300 Subject: [PATCH 11/11] - add asciidoc --- docs/asciidoc/gRPC.adoc | 108 ++++++++++++++++++ .../src/main/java/io/jooby/GrpcProcessor.java | 19 ++- .../src/main/java/io/jooby/ServerOptions.java | 2 +- .../main/java/io/jooby/grpc/GrpcModule.java | 17 ++- .../test/java/io/jooby/test/Http2Test.java | 2 +- 5 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 docs/asciidoc/gRPC.adoc diff --git a/docs/asciidoc/gRPC.adoc b/docs/asciidoc/gRPC.adoc new file mode 100644 index 0000000000..39cb94822c --- /dev/null +++ b/docs/asciidoc/gRPC.adoc @@ -0,0 +1,108 @@ +== gRPC + +The `jooby-grpc` module provides first-class, native support for https://grpc.io/[gRPC]. + +Unlike traditional setups that require spinning up a separate gRPC server on a different port (often forcing a specific transport like Netty), this module embeds the `grpc-java` engine directly into Jooby. + +By using a custom native bridge, it allows you to run strictly-typed gRPC services alongside your standard REST API routes on the **exact same port**. It bypasses the standard HTTP/1.1 pipeline in favor of a highly optimized, native interceptor tailored for HTTP/2 multiplexing, reactive backpressure, and zero-copy byte framing. It works natively across Undertow, Netty, and Jetty. + +=== Dependency + +[source, xml, role="primary"] +.Maven +---- + + io.jooby + jooby-grpc + ${jooby.version} + +---- + +[source, gradle, role="secondary"] +.Gradle +---- +implementation 'io.jooby:jooby-grpc:${jooby.version}' +---- + +=== Usage + +gRPC strictly requires HTTP/2. Before installing the module, ensure your application is configured to use a supported server with HTTP/2 enabled. + +[source, java] +---- +import io.jooby.Jooby; +import io.jooby.ServerOptions; +import io.jooby.grpc.GrpcModule; + +public class App extends Jooby { + { + setServerOptions(new ServerOptions().setHttp2(true)); // <1> + + install(new GrpcModule( // <2> + new GreeterService() + )); + + get("/api/health", ctx -> "OK"); // <3> + } + + public static void main(String[] args) { + runApp(args, App::new); + } +} +---- +<1> Enable HTTP/2 on your server. +<2> Install the module and explicitly register your services. +<3> Standard REST routes still work on the exact same port! + +=== Dependency Injection + +If your gRPC services require external dependencies (like database repositories), you can register the service classes instead of pre-instantiated objects. The module will automatically provision them using your active Dependency Injection framework (e.g., Guice, Spring). + +[source, java] +---- +import io.jooby.Jooby; +import io.jooby.di.GuiceModule; +import io.jooby.grpc.GrpcModule; + +public class App extends Jooby { + { + install(new GuiceModule()); + + install(new GrpcModule( + GreeterService.class // <1> + )); + } +} +---- +<1> Pass the class references. The DI framework will instantiate them. + +WARNING: gRPC services are registered as **Singletons**. Ensure your service implementations are thread-safe and do not hold request-scoped state in instance variables. Heavy blocking operations will safely run on background workers, protecting the native server's I/O event loops. + +=== Server Reflection + +If you want to use tools like `grpcurl` or Postman to interact with your services without providing the `.proto` files, you can easily enable gRPC Server Reflection. + +Include the `grpc-services` dependency in your build, and register the v1 reflection service alongside your own: + +[source, java] +---- +import io.grpc.protobuf.services.ProtoReflectionServiceV1; + +public class App extends Jooby { + { + install(new GrpcModule( + new GreeterService(), + ProtoReflectionServiceV1.newInstance() // <1> + )); + } +} +---- +<1> Enables the modern `v1` reflection protocol for maximum compatibility with gRPC clients. + +=== Routing & Fallbacks + +The gRPC module intercepts requests natively before they reach Jooby's standard router. + +If a client attempts to call a gRPC method that does not exist, the request gracefully falls through to the standard Jooby router, returning a native `404 Not Found` (which gRPC clients will automatically translate to a Status `12 UNIMPLEMENTED`). + +If you misconfigure your server (e.g., attempting to run gRPC over HTTP/1.1), the fallback route will catch the request and throw an `IllegalStateException` to help you identify the missing configuration immediately. diff --git a/jooby/src/main/java/io/jooby/GrpcProcessor.java b/jooby/src/main/java/io/jooby/GrpcProcessor.java index b8885977ef..25b1bfdd41 100644 --- a/jooby/src/main/java/io/jooby/GrpcProcessor.java +++ b/jooby/src/main/java/io/jooby/GrpcProcessor.java @@ -13,22 +13,21 @@ /** * Core Service Provider Interface (SPI) for the gRPC extension. * - *

This processor acts as the bridge between the native HTTP/2 web servers (Undertow, Netty, - * Jetty) and the embedded gRPC engine. It is designed to intercept and process gRPC exchanges at - * the lowest possible network level, completely bypassing Jooby's standard HTTP/1.1 routing - * pipeline. This architecture ensures strict HTTP/2 specification compliance, zero-copy buffering, - * and reactive backpressure. + *

This processor acts as the bridge between the native HTTP/2 web servers and the embedded gRPC + * engine. It is designed to intercept and process gRPC exchanges at the lowest possible network + * level, completely bypassing Jooby's standard HTTP/1.1 routing pipeline. This architecture ensures + * strict HTTP/2 specification compliance, zero-copy buffering, and reactive backpressure. */ public interface GrpcProcessor { /** * Checks if the given URI path exactly matches a registered gRPC method. * - *

Native server interceptors (Undertow, Netty, Jetty) use this method as a lightweight, - * fail-fast guard. If this returns {@code true}, the server will hijack the request and upgrade - * it to a native gRPC stream. If {@code false}, the request safely falls through to the standard - * Jooby router (typically resulting in a standard HTTP 404 Not Found, which gRPC clients - * gracefully translate to Status 12 UNIMPLEMENTED). + *

Native server interceptors use this method as a lightweight, fail-fast guard. If this + * returns {@code true}, the server will hijack the request and upgrade it to a native gRPC + * stream. If {@code false}, the request safely falls through to the standard Jooby router + * (typically resulting in a standard HTTP 404 Not Found, which gRPC clients gracefully translate + * to Status 12 UNIMPLEMENTED). * * @param path The incoming request path (e.g., {@code /fully.qualified.Service/MethodName}). * @return {@code true} if the path is mapped to an active gRPC service; {@code false} otherwise. diff --git a/jooby/src/main/java/io/jooby/ServerOptions.java b/jooby/src/main/java/io/jooby/ServerOptions.java index 5ea3ab2a31..7e084053f4 100644 --- a/jooby/src/main/java/io/jooby/ServerOptions.java +++ b/jooby/src/main/java/io/jooby/ServerOptions.java @@ -290,7 +290,7 @@ public int getPort() { * @return True when SSL is enabled. Either bc the secure port, httpsOnly or SSL options are set. */ public boolean isSSLEnabled() { - return securePort != null || ssl != null || httpsOnly; + return securePort != null || ssl != null || http2 == Boolean.TRUE || httpsOnly; } /** diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java index aa3fbfcd82..93f0a0aa32 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java @@ -23,12 +23,12 @@ *

This module allows you to run strictly-typed gRPC services alongside standard Jooby HTTP * routes on the exact same port. It completely bypasses standard HTTP/1.1 pipelines in favor of a * highly optimized, reactive, native interceptor tailored for HTTP/2 multiplexing and trailing - * headers. * + * headers. * *

Usage

* *

gRPC requires HTTP/2. Ensure your Jooby application is configured to use a supported server - * (Undertow, Netty, or Jetty) with HTTP/2 enabled. * + * with HTTP/2 enabled. * *

{@code
  * import io.jooby.Jooby;
@@ -43,13 +43,11 @@
  * }
  * }
* - * * - * *

Dependency Injection

* *

If your gRPC services require external dependencies (like repositories or configuration), you * can register the service classes instead of instances. The module will automatically provision - * them using Jooby's DI registry (e.g., Guice, Spring) during the application startup phase. * + * them using Jooby's DI registry (e.g., Guice, Spring) during the application startup phase. * *

{@code
  * public class App extends Jooby {
@@ -65,7 +63,7 @@
  *
  * 

Note: gRPC services are inherently registered as Singletons. Ensure your * service implementations are thread-safe and do not hold request-scoped state in instance - * variables. * + * variables. * *

Logging

* @@ -159,8 +157,9 @@ public void install(@NonNull Jooby app) throws Exception { /** * Internal helper to register a service with the gRPC builder, extract its method descriptors, - * and map a fail-fast route in the Jooby router. * @param app The target Jooby application. + * and map a fail-fast route in the Jooby router. * + * @param app The target Jooby application. * @param server The in-process server builder. * @param registry The method descriptor registry. * @param service The provisioned gRPC service to bind. @@ -183,11 +182,11 @@ private static void bindService( routePath, ctx -> { throw new IllegalStateException( - "gRPC request reached the standard HTTP router for path: " + "gRPC request reached the standard HTTP router for: " + routePath + ". " + "This means the native gRPC server interceptor was bypassed. " - + "Ensure you are running Jetty, Netty, or Undertow with HTTP/2 enabled, " + + "Ensure you are running with HTTP/2 enabled, " + "and that the GrpcProcessor SPI is correctly loaded."); }); } diff --git a/tests/src/test/java/io/jooby/test/Http2Test.java b/tests/src/test/java/io/jooby/test/Http2Test.java index f0405aeda1..89796b2e35 100644 --- a/tests/src/test/java/io/jooby/test/Http2Test.java +++ b/tests/src/test/java/io/jooby/test/Http2Test.java @@ -42,7 +42,7 @@ public class Http2Test { @ServerTest public void h2body(ServerTestRunner runner) { runner - .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .options(new ServerOptions().setHttp2(true)) .define( app -> { app.install(new JacksonModule());