From 079a4d40204b176163536df3a4ad2612599d1570 Mon Sep 17 00:00:00 2001
From: swarm-bot
Date: Tue, 12 May 2026 17:27:06 +0000
Subject: [PATCH 01/22] Add plaintext and TLS transport coverage
---
.../add-grpc-transport-tests/result.json | 23 ++++
.../functional/tests/GrpcTransportTest.java | 118 ++++++++++++++++++
.../test/utilities/FunctionsTestHost.java | 114 +++++++++++++++--
.../resources/grpc-tls/localhost-cert.pem | 19 +++
src/test/resources/grpc-tls/localhost-key.pem | 28 +++++
.../grpc-tls/localhost-truststore.p12 | Bin 0 -> 1190 bytes
6 files changed, 291 insertions(+), 11 deletions(-)
create mode 100644 .swarm/tasks/add-grpc-transport-tests/result.json
create mode 100644 src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java
create mode 100644 src/test/resources/grpc-tls/localhost-cert.pem
create mode 100644 src/test/resources/grpc-tls/localhost-key.pem
create mode 100644 src/test/resources/grpc-tls/localhost-truststore.p12
diff --git a/.swarm/tasks/add-grpc-transport-tests/result.json b/.swarm/tasks/add-grpc-transport-tests/result.json
new file mode 100644
index 00000000..a59a7f56
--- /dev/null
+++ b/.swarm/tasks/add-grpc-transport-tests/result.json
@@ -0,0 +1,23 @@
+{
+ "taskId": "add-grpc-transport-tests",
+ "status": "Succeeded",
+ "summary": "Added configurable plaintext/TLS test host coverage plus GrpcTransportTest cases for legacy plaintext, trusted HTTPS/TLS, and HTTPS failure without plaintext downgrade; validated with the full Maven test suite.",
+ "filesTouched": [
+ "src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java",
+ "src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java",
+ "src/test/resources/grpc-tls/localhost-cert.pem",
+ "src/test/resources/grpc-tls/localhost-key.pem",
+ "src/test/resources/grpc-tls/localhost-truststore.p12"
+ ],
+ "testsRun": {
+ "executed": true,
+ "passed": 73,
+ "failed": 0,
+ "command": "mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true test"
+ },
+ "failureExcerpt": null,
+ "designDeviations": null,
+ "followUps": [
+ "Maven dependency resolution in this worker environment required the insecure/allowall SSL flags shown in the test command."
+ ]
+}
\ No newline at end of file
diff --git a/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java b/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java
new file mode 100644
index 00000000..81df6469
--- /dev/null
+++ b/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java
@@ -0,0 +1,118 @@
+package com.microsoft.azure.functions.worker.functional.tests;
+
+import java.net.*;
+import java.util.concurrent.*;
+import javax.net.ssl.*;
+
+import com.microsoft.azure.functions.rpc.messages.*;
+import com.microsoft.azure.functions.worker.test.utilities.*;
+import org.junit.jupiter.api.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class GrpcTransportTest extends FunctionsTestBase {
+ private static final String RETURN_VALUE = "transport-ok";
+ private static final String TRUSTSTORE_RESOURCE = "grpc-tls/localhost-truststore.p12";
+ private static final String TRUSTSTORE_PASSWORD = "changeit";
+
+ public String ReturnStringFunction() {
+ return RETURN_VALUE;
+ }
+
+ @Test
+ public void legacyPlaintextTransportStillWorks() throws Exception {
+ System.setProperty("azure.functions.worker.java.skip.testing", "true");
+ try (FunctionsTestHost host = new FunctionsTestHost()) {
+ InvocationResponse response = this.invokeReturnString(host, "plaintext-function", "plaintext-request");
+
+ assertEquals(TypedData.DataCase.STRING, response.getReturnValue().getDataCase());
+ assertEquals(RETURN_VALUE, response.getReturnValue().getString());
+ }
+ }
+
+ @Test
+ public void trustedHttpsFunctionsUriConnectsToTlsHost() throws Exception {
+ System.setProperty("azure.functions.worker.java.skip.testing", "true");
+ try (TrustStoreScope ignored = TrustStoreScope.use(TRUSTSTORE_RESOURCE, TRUSTSTORE_PASSWORD, "PKCS12");
+ FunctionsTestHost host = new FunctionsTestHost(FunctionsTestHost.ServerTransport.TLS, FunctionsTestHost.ClientTransport.HTTPS)) {
+ InvocationResponse response = this.invokeReturnString(host, "tls-function", "tls-request");
+
+ assertEquals(TypedData.DataCase.STRING, response.getReturnValue().getDataCase());
+ assertEquals(RETURN_VALUE, response.getReturnValue().getString());
+ }
+ }
+
+ @Test
+ public void httpsFunctionsUriDoesNotDowngradeToPlaintextWhenTlsFails() {
+ System.setProperty("azure.functions.worker.java.skip.testing", "true");
+ ExecutionException exception = assertThrows(ExecutionException.class,
+ () -> {
+ try (FunctionsTestHost ignored = new FunctionsTestHost(FunctionsTestHost.ServerTransport.PLAINTEXT, FunctionsTestHost.ClientTransport.HTTPS)) {
+ }
+ });
+
+ assertTrue(hasCause(exception, SSLException.class), "Expected TLS failure but got: " + exception);
+ }
+
+ private InvocationResponse invokeReturnString(FunctionsTestHost host, String functionId, String requestId) throws Exception {
+ this.loadFunction(host, functionId, "ReturnStringFunction");
+ return host.call(requestId, functionId);
+ }
+
+ private static boolean hasCause(Throwable throwable, Class extends Throwable> type) {
+ Throwable current = throwable;
+ while (current != null) {
+ if (type.isInstance(current)) {
+ return true;
+ }
+ current = current.getCause();
+ }
+ return false;
+ }
+
+ private static final class TrustStoreScope implements AutoCloseable {
+ private final String originalTrustStore;
+ private final String originalTrustStorePassword;
+ private final String originalTrustStoreType;
+
+ private TrustStoreScope(String originalTrustStore, String originalTrustStorePassword, String originalTrustStoreType) {
+ this.originalTrustStore = originalTrustStore;
+ this.originalTrustStorePassword = originalTrustStorePassword;
+ this.originalTrustStoreType = originalTrustStoreType;
+ }
+
+ static TrustStoreScope use(String resourceName, String password, String storeType) {
+ String originalTrustStore = System.getProperty("javax.net.ssl.trustStore");
+ String originalTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword");
+ String originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType");
+ URL resource = GrpcTransportTest.class.getClassLoader().getResource(resourceName);
+ if (resource == null) {
+ throw new IllegalStateException("Missing TLS truststore resource: " + resourceName);
+ }
+
+ try {
+ System.setProperty("javax.net.ssl.trustStore", new java.io.File(resource.toURI()).getAbsolutePath());
+ } catch (URISyntaxException ex) {
+ throw new IllegalStateException("Invalid TLS truststore resource path: " + resourceName, ex);
+ }
+ System.setProperty("javax.net.ssl.trustStorePassword", password);
+ System.setProperty("javax.net.ssl.trustStoreType", storeType);
+ return new TrustStoreScope(originalTrustStore, originalTrustStorePassword, originalTrustStoreType);
+ }
+
+ @Override
+ public void close() {
+ restore("javax.net.ssl.trustStore", this.originalTrustStore);
+ restore("javax.net.ssl.trustStorePassword", this.originalTrustStorePassword);
+ restore("javax.net.ssl.trustStoreType", this.originalTrustStoreType);
+ }
+
+ private static void restore(String key, String value) {
+ if (value == null) {
+ System.clearProperty(key);
+ } else {
+ System.setProperty(key, value);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
index 207f0634..1f31f0ba 100644
--- a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
+++ b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
@@ -1,8 +1,10 @@
package com.microsoft.azure.functions.worker.test.utilities;
import java.io.*;
-import java.net.ServerSocket;
+import java.net.*;
+import java.nio.file.*;
import java.util.*;
+import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.*;
import java.util.function.*;
@@ -14,15 +16,44 @@
import com.microsoft.azure.functions.worker.*;
import com.microsoft.azure.functions.rpc.messages.*;
import io.grpc.*;
+import io.grpc.netty.shaded.io.grpc.netty.*;
import io.grpc.stub.*;
import org.apache.commons.lang3.tuple.*;
public final class FunctionsTestHost implements AutoCloseable, IApplication {
+ public enum ClientTransport {
+ LEGACY,
+ HTTP,
+ HTTPS
+ }
+
+ public enum ServerTransport {
+ PLAINTEXT,
+ TLS
+ }
+
+ private static final int RESPONSE_TIMEOUT_SECONDS = 10;
+ private static final long RESPONSE_POLL_MILLIS = 100L;
+ private static final String TLS_RESOURCE_ROOT = "grpc-tls/";
+ private static final String TLS_CERTIFICATE_RESOURCE = TLS_RESOURCE_ROOT + "localhost-cert.pem";
+ private static final String TLS_PRIVATE_KEY_RESOURCE = TLS_RESOURCE_ROOT + "localhost-key.pem";
+
private int port;
public FunctionsTestHost() throws Exception {
+ this(ServerTransport.PLAINTEXT, ClientTransport.LEGACY);
+ }
+
+ public FunctionsTestHost(ServerTransport serverTransport, ClientTransport clientTransport) throws Exception {
+ this.serverTransport = serverTransport;
+ this.clientTransport = clientTransport;
this.port = populatePort();
- this.initializeServer();
- this.initializeClient();
+ try {
+ this.initializeServer();
+ this.initializeClient();
+ } catch (Exception ex) {
+ this.closeQuietly();
+ throw ex;
+ }
}
private final List list = Arrays.asList(55005, 5005);
@@ -37,23 +68,38 @@ private int populatePort() {
@PostConstruct
private void initializeServer() throws IOException {
- ServerBuilder> builder = ServerBuilder.forPort(this.getPort());
+ ServerBuilder> builder = this.serverTransport == ServerTransport.TLS
+ ? NettyServerBuilder.forPort(this.getPort())
+ .sslContext(GrpcSslContexts.forServer(getTlsResource(TLS_CERTIFICATE_RESOURCE), getTlsResource(TLS_PRIVATE_KEY_RESOURCE)).build())
+ : ServerBuilder.forPort(this.getPort());
this.grpcHost = new HostGrpcImplementation();
this.server = builder.addService(this.grpcHost).build();
this.server.start();
}
@PostConstruct
- private void initializeClient() throws InterruptedException {
+ private void initializeClient() throws Exception {
this.client = new JavaWorkerClient(this);
- this.client.listen("java-worker-test", HostGrpcImplementation.ESTABLISH_REQID);
+ this.listeningTask = this.client.listen("java-worker-test", HostGrpcImplementation.ESTABLISH_REQID);
this.grpcHost.handleMessage(HostGrpcImplementation.ESTABLISH_REQID, m -> this.grpcHost.initWorker());
}
@Override
public void close() throws Exception {
- this.client.close();
- this.server.shutdownNow().awaitTermination();
+ Exception closeException = null;
+ if (this.client != null) {
+ try {
+ this.client.close();
+ } catch (Exception ex) {
+ closeException = ex;
+ }
+ }
+ if (this.server != null) {
+ this.server.shutdownNow().awaitTermination(15, TimeUnit.SECONDS);
+ }
+ if (closeException != null) {
+ throw closeException;
+ }
}
public void loadFunction(String id, String reflectionName, Map bindings) throws Exception {
@@ -80,13 +126,52 @@ public final InvocationResponse call(String reqId, String funcId, Triple listeningTask;
private String lastCallReqId = HostGrpcImplementation.LOADFUNC_REQID;
+ private void closeQuietly() {
+ try {
+ this.close();
+ } catch (Exception ignored) {
+ }
+ }
+
+ private static File getTlsResource(String resourcePath) {
+ URL resource = FunctionsTestHost.class.getClassLoader().getResource(resourcePath);
+ if (resource == null) {
+ throw new IllegalStateException("Missing test TLS resource: " + resourcePath);
+ }
+ try {
+ return Paths.get(resource.toURI()).toFile();
+ } catch (URISyntaxException ex) {
+ throw new IllegalStateException("Invalid test TLS resource path: " + resourcePath, ex);
+ }
+ }
+
+ private void throwIfListeningFailed() throws ExecutionException, InterruptedException {
+ if (this.listeningTask != null && this.listeningTask.isDone()) {
+ this.listeningTask.get();
+ }
+ }
+
@ThreadSafe
private class HostGrpcImplementation extends FunctionRpcGrpc.FunctionRpcImplBase {
@@ -107,11 +192,18 @@ private void setResponse(String requestId, StreamingMessage value, StreamObserve
this.getResponseCondition(requestId).signal();
}
- void handleMessage(String requestId, Function handler) throws InterruptedException {
+ void handleMessage(String requestId, Function handler) throws Exception {
this.lock.lock();
try {
- if (this.responder.get(requestId) == null) {
- this.getResponseCondition(requestId).await();
+ long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(RESPONSE_TIMEOUT_SECONDS);
+ while (this.responder.get(requestId) == null) {
+ FunctionsTestHost.this.throwIfListeningFailed();
+ long remainingNanos = deadlineNanos - System.nanoTime();
+ if (remainingNanos <= 0) {
+ throw new TimeoutException("Timed out waiting for gRPC request " + requestId);
+ }
+ long waitMillis = Math.max(1L, Math.min(TimeUnit.NANOSECONDS.toMillis(remainingNanos), RESPONSE_POLL_MILLIS));
+ this.getResponseCondition(requestId).await(waitMillis, TimeUnit.MILLISECONDS);
}
StreamingMessage message = this.respValue.get(requestId);
StreamingMessage response = null;
diff --git a/src/test/resources/grpc-tls/localhost-cert.pem b/src/test/resources/grpc-tls/localhost-cert.pem
new file mode 100644
index 00000000..4d9d070b
--- /dev/null
+++ b/src/test/resources/grpc-tls/localhost-cert.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDHzCCAgegAwIBAgIUUT0dnmqskpKcIS3wt+Fq+obG5GEwDQYJKoZIhvcNAQEL
+BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMjE3MjExMFoXDTM2MDUw
+OTE3MjExMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAmRbV1NPyunri/+EN14nfIQhyY7grIRmUS5XTvsJhVQMg
+/vP0rZ1H1I8f7mzkmyfDsB0i5putmz01lAHD5/Wfa0JEhVgmnwK20RsufjsWhwpY
+tVpbfzgY5LJILSwz5Qw//Q2VDFiFXK7plVkfnmwrutUQ4AXuk55IuLDHlHxAI7vt
+v/MxxlCri+EeoZcmTTVkLeMvtTf7Pw3upc8PBNNDYJOJvld4DXxz4e93ZhFmQGck
+QMVeGGPsL+jmhu78pdfW+TcwlEJTvKBZ8xYTGaQbks6ZwXslYvuL0mI7XEajTSrM
+Pw7vJeaJH85fETtZNfz3/IR4or88pyQQN9Cv0fY3xQIDAQABo2kwZzAdBgNVHQ4E
+FgQUQgk8tVR0g3rgA6MRktL39pScY9UwHwYDVR0jBBgwFoAUQgk8tVR0g3rgA6MR
+ktL39pScY9UwDwYDVR0TAQH/BAUwAwEB/zAUBgNVHREEDTALgglsb2NhbGhvc3Qw
+DQYJKoZIhvcNAQELBQADggEBAJg1IQoiIV8qoLpVLbY5pvgkeClmFQsVwFTB0rLS
+lU4ZkVkoIiYowQnb10WFpGdyhfI+WhZcLev2Dmm7PJ9Jp0H0vC5U0ebNLzMJdwjr
+HjJcIuueYJDJ3YelEIzO2qa4R977PCvBPLHEi7/KRdSbAOz6lf6Yp+OPe46gJHP1
+XwaKt628oPd7A4FdBTF7lUfcLdPUnW9Glhg7VfB6aX6o5+Mup+QEt96xlXg6Ua2L
+xYw8AJ75rAmQLwu05uzyaTGlHE7BXtsP+bEBAz4CXVv1z5i1UJfB6N/aWB1VOHKN
+2r1x1fMUCNLpsoTdtmkwAmkmzx4bfyWtIIK+hAxTm7bmjGk=
+-----END CERTIFICATE-----
diff --git a/src/test/resources/grpc-tls/localhost-key.pem b/src/test/resources/grpc-tls/localhost-key.pem
new file mode 100644
index 00000000..976e744b
--- /dev/null
+++ b/src/test/resources/grpc-tls/localhost-key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZFtXU0/K6euL/
+4Q3Xid8hCHJjuCshGZRLldO+wmFVAyD+8/StnUfUjx/ubOSbJ8OwHSLmm62bPTWU
+AcPn9Z9rQkSFWCafArbRGy5+OxaHCli1Wlt/OBjkskgtLDPlDD/9DZUMWIVcrumV
+WR+ebCu61RDgBe6Tnki4sMeUfEAju+2/8zHGUKuL4R6hlyZNNWQt4y+1N/s/De6l
+zw8E00Ngk4m+V3gNfHPh73dmEWZAZyRAxV4YY+wv6OaG7vyl19b5NzCUQlO8oFnz
+FhMZpBuSzpnBeyVi+4vSYjtcRqNNKsw/Du8l5okfzl8RO1k1/Pf8hHiivzynJBA3
+0K/R9jfFAgMBAAECggEANvM/Zdl+MvmRKY+6zDcs5EqH5Mtij8sCs+7fxoU3MrCg
+02L13Kur8Nw+9fIYTKkFUN3kfSo8MpDR/oJzs3sy8ekjd0mg80qiHITJN342I9rO
+5Km+Vffo14424iAPsJOpFEgfzAKqPA58waLv+omRWMrJ99+pN0uFhuXNfbrruudX
+zgCMWtw7gqciTEOrcy2GLyKOClfg6AIn/9F1Z3yrQBrHW7TZjPhyHZi3PeIeXAz4
+hVjj5zHP8GI8P4O/kP6gFfB5x/1PXGErGOSl3HOieKse7JTMu7NCtgLEEshFAjOd
+z4EsAGzJb0FdyH1eX9+WhsvVkbTTnek3fhBCMIJ/MQKBgQDLvpbTbRLdbxH6pDIx
+I53jBpDU1x+QU5j1lvLZJtvdMnsJFA43zamSqwmIzuIAs9POzEGIP586TCr5PXBi
+NrAz9mYZC1ye59ZFq0JVundbCWEO+YxuR5glgqjubWdfwFzFynCyuuZTqmPozGnY
+wSOjIE7g0Vw6aJVXJq0eXG1EAwKBgQDAWlUSyXSVZ7INXFr+Dh0gd12HhQMYmxEz
+twtXEfZTGwHqlxZOo0/G/LYVWPdIqkUw86aaKorlfgg5yzbJ6tfgxiE71W7VPiBR
+X9Cefz16th2rQfM6Y1fszimV1Dpf3HU5TuN/ZK4tlEqsfK01yfjofPCjNL1c2BQr
+sHYkUi5elwKBgBy8LZN2D7IRRyzdWYLarhrlwylxia8WSz1f47JCq8GfrACUxoiS
+Rfc8jiSwYOmOczH4Vsm7h152fZ0XUDFZ2zII709a7d4vfmXnCH0Exm6dfQXapjar
+fEbWDbNK1MiJXcw7h/d9KpzkLCEaK1d5regE13sXq/VE6MMY3lOo33Q3AoGAamsP
+mh8+ktIV3fJ0nQ3t62JeqnVaayiPcc8ZRQi5AO12N/Vy7/rGTk7N5i2cUeVx9k02
+pSBYS/NYVbEqFLgKy16SUGoasXt3oc2iu62ls9hBvdf02x7PLEI7G5uY2CQ97oDI
+uFhZTPo3/gnUQmgFf4pwD7tD8LPTJQCxvBKDeO0CgYEApeC2lxH4FekJEA4iIBG+
+InRXX20QCLqI2d4xMOHHDEWaulhDEkjs9HEErA+3HpkK5hOTsEIU6GENrpc3P2JY
+iFaO/gJiFEErkXsSaDRRaROTKI27qA0JPWzrfDHH4G59qpVVH6KWIYJvWbsHHo49
+MSFfMNCXhE9polVRU6EyWb0=
+-----END PRIVATE KEY-----
diff --git a/src/test/resources/grpc-tls/localhost-truststore.p12 b/src/test/resources/grpc-tls/localhost-truststore.p12
new file mode 100644
index 0000000000000000000000000000000000000000..cb4c21c18330c0fd5991d06ff34fa81dbacbe18e
GIT binary patch
literal 1190
zcmV;X1X=qqf&`)h0Ru3C1WX1ADuzgg_YDCD0ic2eJp_UTIWU3*H86q%F$M`LhDe6@
z4FLxRpn?P=^2`Yw2hW8Bt2LUi<1_>&LNQU+thDZTr0|Wso1P}T|3~Qx<7z|1E9~_2KxM{%$%h>p6br4%#iBZ
z9>^IC`s}0w3*pw}2*0GS+Pg$k1cQ6T3~ZPTZga(wVR#^eabs;yFrZBWr-a~X4#Q&^
z*xgZGuVN}%PkWvTr;&a!g3?)Ep(6LRG-4{4MR$?ghA>9~27u=7e#|x`sxjkw;ztkY
zMp}q|j!wH4-E%63!iy0Oq?zYWpxPzpeDfP0(G3XdpI*5Ot7lF!As2!RY6}`fXQO)G
zN^Dn_uGftZt;w+RO9mGD{4~qtWKEEv9?2h=YKTaIyEUt_5&k7DN$~@z%5;gfZivt0yxoDHnVp^Qf<~0bMcWl4xO8b=
zdE8$k;JWql^=mq!I94O^RZ`L
z%JG>yIo$M;)K%6Iq=b}gv#)nslLAF^Q%Rldllm(D@GUc(i&VEFh8F8%Lt$slK*GO7
z;-P)ECm*_hm%kI$ndqOdWL39Bjh-PlUJT#ueySut3OE1fwx~cWe`-_`2|5>T2A1L+
zR|XlzzEmw(;h)aL^)kFEWRRN&OUFBC9|}N89YRe~H!mXoh>)IPFQjfhp&tnIhm7d7
zlFFc$8Lic8I^RaTvHQD1n{O_nzn`GQTvKJEuD?-l7p^vVmLY-
zJsN=r=U9(3bcJgLpjw{ZW|$Pt&DP!L>rjtlpq*KEH
z8IXTkEbWn2&rj)4N`#eW%0thzq=ae=Rl|o~K%pcCh{jOW2~3py0{cak)V$qfK+isB
z+Xi1>ZUg>-4|cAJmUWwPCra1*=tlA2)~9A`hF;M1)u7DKM^Pp#$u?#xdyGM!;^
zSRDR``~ED`6<>1a-^CPs!r^B@fxXrt>s&BRFflL<1_@w>NC9O71OfpC00bZ&iU)@1
zB8W)-l)jyF{HrQ|py-ygpLWE_SdD6&EzOt&6n&+Fu>{$dsaX)(AZM2D64dXK!2$v&
E5NL)SEC2ui
literal 0
HcmV?d00001
From 2e77ed2b92f018dd3b2c12c37cfab18f423bf11e Mon Sep 17 00:00:00 2001
From: swarm-bot
Date: Tue, 12 May 2026 17:30:15 +0000
Subject: [PATCH 02/22] chore: persist review round 1
---
.../run/rounds/round-1/review/decision.json | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
create mode 100644 .swarm/run/rounds/round-1/review/decision.json
diff --git a/.swarm/run/rounds/round-1/review/decision.json b/.swarm/run/rounds/round-1/review/decision.json
new file mode 100644
index 00000000..8797069b
--- /dev/null
+++ b/.swarm/run/rounds/round-1/review/decision.json
@@ -0,0 +1,19 @@
+{
+ "outcome": "Approved",
+ "summary": "All three tasks are correctly implemented and well-tested. The security fix properly addresses the plaintext gRPC channel vulnerability by honoring the \u0060functions-uri\u0060 scheme: \u0060https\u0060 URIs now use TLS transport security, \u0060http\u0060 and legacy host\u002Bport continue using plaintext, and unsupported schemes fail fast. Backward compatibility is maintained through a Java 8 default interface method on \u0060IApplication\u0060. Test coverage includes unit tests for transport selection logic, Application argument precedence, and integration tests verifying plaintext connectivity, TLS connectivity, and no-downgrade behavior. All three workers report successful \u0060mvn test\u0060 runs with 73 total tests passing.",
+ "findings": [
+ {
+ "taskId": "select-grpc-transport-from-uri",
+ "severity": "minor",
+ "description": "The @Nullable annotation on useTransportSecurity() relies on a transitive dependency from grpc-api (jsr305) rather than an explicit POM dependency. This works but could break if the grpc dependency tree changes. Low risk since grpc-api has had this transitive dependency for years."
+ },
+ {
+ "taskId": "add-grpc-transport-tests",
+ "severity": "minor",
+ "description": "The FunctionsTestHost handleMessage() was refactored from indefinite await() to timeout-based waiting with throwIfListeningFailed() checks. While this is an improvement, it changes existing test infrastructure behavior beyond the strict scope of TLS support. The change is safe but worth noting."
+ }
+ ],
+ "fixTasks": null,
+ "replanSummary": null,
+ "replanFindings": null
+}
\ No newline at end of file
From 8fbdc256a4da70073fc0006484a633853c4a82bf Mon Sep 17 00:00:00 2001
From: swarm-bot
Date: Tue, 12 May 2026 17:30:22 +0000
Subject: [PATCH 03/22] chore: strip evidence store for clean PR
---
.swarm/run/original-prompt.md | 5 ---
.swarm/run/rounds/round-1/manifest.json | 8 -----
.../round-1/planning/design-document.md | 15 --------
.swarm/run/rounds/round-1/planning/plan.json | 34 -------------------
.../run/rounds/round-1/review/decision.json | 19 -----------
.../add-grpc-transport-tests/result.json | 23 -------------
.swarm/tasks/add-grpc-transport-tests/task.md | 10 ------
.swarm/tasks/expose-functions-uri/result.json | 21 ------------
.swarm/tasks/expose-functions-uri/task.md | 10 ------
.../result.json | 20 -----------
.../select-grpc-transport-from-uri/task.md | 10 ------
11 files changed, 175 deletions(-)
delete mode 100644 .swarm/run/original-prompt.md
delete mode 100644 .swarm/run/rounds/round-1/manifest.json
delete mode 100644 .swarm/run/rounds/round-1/planning/design-document.md
delete mode 100644 .swarm/run/rounds/round-1/planning/plan.json
delete mode 100644 .swarm/run/rounds/round-1/review/decision.json
delete mode 100644 .swarm/tasks/add-grpc-transport-tests/result.json
delete mode 100644 .swarm/tasks/add-grpc-transport-tests/task.md
delete mode 100644 .swarm/tasks/expose-functions-uri/result.json
delete mode 100644 .swarm/tasks/expose-functions-uri/task.md
delete mode 100644 .swarm/tasks/select-grpc-transport-from-uri/result.json
delete mode 100644 .swarm/tasks/select-grpc-transport-from-uri/task.md
diff --git a/.swarm/run/original-prompt.md b/.swarm/run/original-prompt.md
deleted file mode 100644
index f3b08058..00000000
--- a/.swarm/run/original-prompt.md
+++ /dev/null
@@ -1,5 +0,0 @@
-There is a SFI (security) work item in this file - /src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
-Multiple language workers use plaintext/insecure gRPC channels, enabling MITM attacks.
-Java worker: Uses plaintext gRPC channel enabling MITM tampering leading to potential code execution
-
-Can you fix it? It would be great to identify these things - - Is the fix necessary? - What was the historical context of designing the code as is? Was it intentional or a missed gap? - Can you confirm if there will be any regressions if the fix is made? - Will there be a contract or a breaking change for the customer? - How well is it tested?
\ No newline at end of file
diff --git a/.swarm/run/rounds/round-1/manifest.json b/.swarm/run/rounds/round-1/manifest.json
deleted file mode 100644
index 1f292b0c..00000000
--- a/.swarm/run/rounds/round-1/manifest.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "roundNumber": 1,
- "taskIds": [
- "expose-functions-uri",
- "select-grpc-transport-from-uri",
- "add-grpc-transport-tests"
- ]
-}
\ No newline at end of file
diff --git a/.swarm/run/rounds/round-1/planning/design-document.md b/.swarm/run/rounds/round-1/planning/design-document.md
deleted file mode 100644
index ddca5f46..00000000
--- a/.swarm/run/rounds/round-1/planning/design-document.md
+++ /dev/null
@@ -1,15 +0,0 @@
-## Summary
-`JavaWorkerClient` always builds `ManagedChannelBuilder.forAddress(...).usePlaintext()`, so the Java worker ignores the already-parsed `--functions-uri` and can never join a secure host/worker gRPC channel.
-
-## Findings
-- **Fix necessary:** Yes. Today the worker guarantees plaintext and blocks any host-side TLS rollout, leaving any non-local transport exposed to MITM tampering.
-- **Historical context:** Plaintext dates back to the early 2018 same-machine child-process design. In 2023 the worker added prefixed startup args, including `functions-uri`, plus fallback to legacy args, but `JavaWorkerClient` was not updated; this looks like an old assumption that became a gap once URI-based startup existed.
-- **TLS behavior clarification:** The fix should honor TLS, not ignore it. When `functions-uri` uses `https`, the worker should build a TLS gRPC channel and fail if handshake, certificate, or hostname validation fails. Only `http` URIs, or legacy startup that supplies just host+port, should continue to use plaintext.
-- **Regression / breaking change:** No new CLI or protocol contract. Existing `http` and legacy host+port launches keep their current behavior. The only observable behavior change is that a previously ignored or misconfigured `https` endpoint will stop connecting insecurely and instead fail closed.
-- **Customer contract:** No new flags are required. Trust still comes from the JVM trust configuration already available via existing Java options, so hosts using private CAs do not need a new worker-specific switch.
-- **Testing today:** `mvn test` currently passes (63 tests, 0 failures/errors/skips). Repo CI also runs build plus emulated, docker, and end-to-end matrices, but there is no direct secure gRPC transport coverage.
-
-## Plan
-1. Expose the parsed `functions-uri` through `IApplication` in a compatibility-safe way.
-2. Make `JavaWorkerClient` choose transport from the URI scheme: `https` => TLS with no plaintext downgrade, `http` or legacy host+port => plaintext.
-3. Add focused plaintext/TLS transport tests and rerun `mvn test`.
\ No newline at end of file
diff --git a/.swarm/run/rounds/round-1/planning/plan.json b/.swarm/run/rounds/round-1/planning/plan.json
deleted file mode 100644
index 8c14383f..00000000
--- a/.swarm/run/rounds/round-1/planning/plan.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "designDocument": "## Summary\n\u0060JavaWorkerClient\u0060 always builds \u0060ManagedChannelBuilder.forAddress(...).usePlaintext()\u0060, so the Java worker ignores the already-parsed \u0060--functions-uri\u0060 and can never join a secure host/worker gRPC channel.\n\n## Findings\n- **Fix necessary:** Yes. Today the worker guarantees plaintext and blocks any host-side TLS rollout, leaving any non-local transport exposed to MITM tampering.\n- **Historical context:** Plaintext dates back to the early 2018 same-machine child-process design. In 2023 the worker added prefixed startup args, including \u0060functions-uri\u0060, plus fallback to legacy args, but \u0060JavaWorkerClient\u0060 was not updated; this looks like an old assumption that became a gap once URI-based startup existed.\n- **TLS behavior clarification:** The fix should honor TLS, not ignore it. When \u0060functions-uri\u0060 uses \u0060https\u0060, the worker should build a TLS gRPC channel and fail if handshake, certificate, or hostname validation fails. Only \u0060http\u0060 URIs, or legacy startup that supplies just host\u002Bport, should continue to use plaintext.\n- **Regression / breaking change:** No new CLI or protocol contract. Existing \u0060http\u0060 and legacy host\u002Bport launches keep their current behavior. The only observable behavior change is that a previously ignored or misconfigured \u0060https\u0060 endpoint will stop connecting insecurely and instead fail closed.\n- **Customer contract:** No new flags are required. Trust still comes from the JVM trust configuration already available via existing Java options, so hosts using private CAs do not need a new worker-specific switch.\n- **Testing today:** \u0060mvn test\u0060 currently passes (63 tests, 0 failures/errors/skips). Repo CI also runs build plus emulated, docker, and end-to-end matrices, but there is no direct secure gRPC transport coverage.\n\n## Plan\n1. Expose the parsed \u0060functions-uri\u0060 through \u0060IApplication\u0060 in a compatibility-safe way.\n2. Make \u0060JavaWorkerClient\u0060 choose transport from the URI scheme: \u0060https\u0060 =\u003E TLS with no plaintext downgrade, \u0060http\u0060 or legacy host\u002Bport =\u003E plaintext.\n3. Add focused plaintext/TLS transport tests and rerun \u0060mvn test\u0060.",
- "tasks": [
- {
- "id": "expose-functions-uri",
- "title": "Expose functions-uri to worker startup code",
- "description": "Update \u0060src/main/java/com/microsoft/azure/functions/worker/IApplication.java\u0060 and \u0060Application.java\u0060 so the parsed \u0060--functions-uri\u0060 value is available through a compatibility-safe accessor. Use a Java 8 default interface method (or an equivalent non-breaking pattern) so existing implementers do not need source changes, while preserving the current precedence of prefixed args over legacy host/port/request/message-size arguments and keeping host/port extraction intact.",
- "dependencies": [],
- "roundNumber": 1,
- "branchName": "worker/task-1"
- },
- {
- "id": "select-grpc-transport-from-uri",
- "title": "Choose gRPC transport from endpoint scheme",
- "description": "Refactor \u0060src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java\u0060 so channel construction reads the endpoint scheme from \u0060functions-uri\u0060 when present instead of hard-coding plaintext. If the URI is \u0060https\u0060, build the channel with transport security and let TLS or certificate failures surface; do not retry or downgrade to plaintext. If the URI is \u0060http\u0060, or no URI is available and startup fell back to legacy host\u002Bport args, keep the current plaintext behavior. Preserve the existing message-size behavior and fail fast with a clear error for unsupported schemes.",
- "dependencies": [
- "expose-functions-uri"
- ],
- "roundNumber": 1,
- "branchName": "worker/task-2"
- },
- {
- "id": "add-grpc-transport-tests",
- "title": "Add plaintext and TLS transport coverage",
- "description": "Add focused tests under \u0060src/test/java\u0060 (and \u0060src/test/resources\u0060 if needed) that cover: (1) the existing plaintext path still working with the current test host, (2) successful connection to a TLS-enabled gRPC test host when the worker is given a trusted \u0060https\u0060 \u0060functions-uri\u0060, and (3) a negative \u0060https\u0060 case proving TLS errors are not silently downgraded to plaintext. Extend or split the current test host utility as needed, then rerun \u0060mvn test\u0060 to keep the full suite green.",
- "dependencies": [
- "select-grpc-transport-from-uri"
- ],
- "roundNumber": 1,
- "branchName": "worker/task-3"
- }
- ],
- "title": "Honor secure Functions host URIs in Java worker"
-}
\ No newline at end of file
diff --git a/.swarm/run/rounds/round-1/review/decision.json b/.swarm/run/rounds/round-1/review/decision.json
deleted file mode 100644
index 8797069b..00000000
--- a/.swarm/run/rounds/round-1/review/decision.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "outcome": "Approved",
- "summary": "All three tasks are correctly implemented and well-tested. The security fix properly addresses the plaintext gRPC channel vulnerability by honoring the \u0060functions-uri\u0060 scheme: \u0060https\u0060 URIs now use TLS transport security, \u0060http\u0060 and legacy host\u002Bport continue using plaintext, and unsupported schemes fail fast. Backward compatibility is maintained through a Java 8 default interface method on \u0060IApplication\u0060. Test coverage includes unit tests for transport selection logic, Application argument precedence, and integration tests verifying plaintext connectivity, TLS connectivity, and no-downgrade behavior. All three workers report successful \u0060mvn test\u0060 runs with 73 total tests passing.",
- "findings": [
- {
- "taskId": "select-grpc-transport-from-uri",
- "severity": "minor",
- "description": "The @Nullable annotation on useTransportSecurity() relies on a transitive dependency from grpc-api (jsr305) rather than an explicit POM dependency. This works but could break if the grpc dependency tree changes. Low risk since grpc-api has had this transitive dependency for years."
- },
- {
- "taskId": "add-grpc-transport-tests",
- "severity": "minor",
- "description": "The FunctionsTestHost handleMessage() was refactored from indefinite await() to timeout-based waiting with throwIfListeningFailed() checks. While this is an improvement, it changes existing test infrastructure behavior beyond the strict scope of TLS support. The change is safe but worth noting."
- }
- ],
- "fixTasks": null,
- "replanSummary": null,
- "replanFindings": null
-}
\ No newline at end of file
diff --git a/.swarm/tasks/add-grpc-transport-tests/result.json b/.swarm/tasks/add-grpc-transport-tests/result.json
deleted file mode 100644
index a59a7f56..00000000
--- a/.swarm/tasks/add-grpc-transport-tests/result.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "taskId": "add-grpc-transport-tests",
- "status": "Succeeded",
- "summary": "Added configurable plaintext/TLS test host coverage plus GrpcTransportTest cases for legacy plaintext, trusted HTTPS/TLS, and HTTPS failure without plaintext downgrade; validated with the full Maven test suite.",
- "filesTouched": [
- "src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java",
- "src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java",
- "src/test/resources/grpc-tls/localhost-cert.pem",
- "src/test/resources/grpc-tls/localhost-key.pem",
- "src/test/resources/grpc-tls/localhost-truststore.p12"
- ],
- "testsRun": {
- "executed": true,
- "passed": 73,
- "failed": 0,
- "command": "mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true test"
- },
- "failureExcerpt": null,
- "designDeviations": null,
- "followUps": [
- "Maven dependency resolution in this worker environment required the insecure/allowall SSL flags shown in the test command."
- ]
-}
\ No newline at end of file
diff --git a/.swarm/tasks/add-grpc-transport-tests/task.md b/.swarm/tasks/add-grpc-transport-tests/task.md
deleted file mode 100644
index 76119f45..00000000
--- a/.swarm/tasks/add-grpc-transport-tests/task.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Add plaintext and TLS transport coverage
-
-- Task ID: `add-grpc-transport-tests`
-- Round: 1
-- Branch: worker/task-3
-- Dependencies: select-grpc-transport-from-uri
-
-## Description
-
-Add focused tests under `src/test/java` (and `src/test/resources` if needed) that cover: (1) the existing plaintext path still working with the current test host, (2) successful connection to a TLS-enabled gRPC test host when the worker is given a trusted `https` `functions-uri`, and (3) a negative `https` case proving TLS errors are not silently downgraded to plaintext. Extend or split the current test host utility as needed, then rerun `mvn test` to keep the full suite green.
\ No newline at end of file
diff --git a/.swarm/tasks/expose-functions-uri/result.json b/.swarm/tasks/expose-functions-uri/result.json
deleted file mode 100644
index 9a8bbd2c..00000000
--- a/.swarm/tasks/expose-functions-uri/result.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "taskId": "expose-functions-uri",
- "status": "Succeeded",
- "summary": "Exposed functions-uri through a compatibility-safe IApplication default method, updated Application to override it while keeping legacy getUri/host/port behavior, and added startup-argument precedence coverage; validated with mvn test.",
- "filesTouched": [
- "src/main/java/com/microsoft/azure/functions/worker/IApplication.java",
- "src/main/java/com/microsoft/azure/functions/worker/Application.java",
- "src/test/java/com/microsoft/azure/functions/worker/ApplicationTest.java"
- ],
- "testsRun": {
- "executed": true,
- "passed": 66,
- "failed": 0,
- "command": "mvn test"
- },
- "failureExcerpt": null,
- "designDeviations": null,
- "followUps": [
- "Downstream gRPC transport selection can now read IApplication.getFunctionsUri() without breaking existing implementers."
- ]
-}
\ No newline at end of file
diff --git a/.swarm/tasks/expose-functions-uri/task.md b/.swarm/tasks/expose-functions-uri/task.md
deleted file mode 100644
index b33568fd..00000000
--- a/.swarm/tasks/expose-functions-uri/task.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Expose functions-uri to worker startup code
-
-- Task ID: `expose-functions-uri`
-- Round: 1
-- Branch: worker/task-1
-- Dependencies: (none)
-
-## Description
-
-Update `src/main/java/com/microsoft/azure/functions/worker/IApplication.java` and `Application.java` so the parsed `--functions-uri` value is available through a compatibility-safe accessor. Use a Java 8 default interface method (or an equivalent non-breaking pattern) so existing implementers do not need source changes, while preserving the current precedence of prefixed args over legacy host/port/request/message-size arguments and keeping host/port extraction intact.
\ No newline at end of file
diff --git a/.swarm/tasks/select-grpc-transport-from-uri/result.json b/.swarm/tasks/select-grpc-transport-from-uri/result.json
deleted file mode 100644
index 426b88bf..00000000
--- a/.swarm/tasks/select-grpc-transport-from-uri/result.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "taskId": "select-grpc-transport-from-uri",
- "status": "Succeeded",
- "summary": "Updated JavaWorkerClient to select TLS or plaintext from functions-uri scheme, fail fast on unsupported schemes, and added transport-selection tests; validated with full Maven test suite.",
- "filesTouched": [
- "src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java",
- "src/test/java/com/microsoft/azure/functions/worker/JavaWorkerClientTest.java"
- ],
- "testsRun": {
- "executed": true,
- "passed": 70,
- "failed": 0,
- "command": "mvn -s /tmp/maven-settings.xml -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true test"
- },
- "failureExcerpt": null,
- "designDeviations": null,
- "followUps": [
- "Task add-grpc-transport-tests can extend coverage to end-to-end TLS handshake behavior with a secure test host."
- ]
-}
\ No newline at end of file
diff --git a/.swarm/tasks/select-grpc-transport-from-uri/task.md b/.swarm/tasks/select-grpc-transport-from-uri/task.md
deleted file mode 100644
index 1e624c8c..00000000
--- a/.swarm/tasks/select-grpc-transport-from-uri/task.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Choose gRPC transport from endpoint scheme
-
-- Task ID: `select-grpc-transport-from-uri`
-- Round: 1
-- Branch: worker/task-2
-- Dependencies: expose-functions-uri
-
-## Description
-
-Refactor `src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java` so channel construction reads the endpoint scheme from `functions-uri` when present instead of hard-coding plaintext. If the URI is `https`, build the channel with transport security and let TLS or certificate failures surface; do not retry or downgrade to plaintext. If the URI is `http`, or no URI is available and startup fell back to legacy host+port args, keep the current plaintext behavior. Preserve the existing message-size behavior and fail fast with a clear error for unsupported schemes.
\ No newline at end of file
From 0c9d3608a13dc3e7ea24fc987e2010778368477b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 12 May 2026 17:37:52 +0000
Subject: [PATCH 04/22] Address review feedback on annotations and test-host
scope
Agent-Logs-Url: https://github.com/larohra/azure-functions-java-worker/sessions/db76de91-28a8-4391-b3a2-d8796d6725e4
Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>
---
.../functions/worker/JavaWorkerClient.java | 4 +--
.../test/utilities/FunctionsTestHost.java | 28 +++++++++++++++++--
2 files changed, 28 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java b/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
index c0fbe8a6..ad2b1862 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
@@ -7,7 +7,7 @@
import java.util.concurrent.atomic.*;
import java.util.function.*;
import java.util.logging.*;
-import javax.annotation.*;
+import javax.annotation.PostConstruct;
import io.grpc.*;
import io.grpc.stub.*;
@@ -146,7 +146,7 @@ private synchronized void send(String requestId, MessageHandler, ?> marshaller
private final Map>> handlerSuppliers;
private final ClassLoaderProvider classPathProvider;
- static boolean useTransportSecurity(@Nullable String functionsUri) {
+ static boolean useTransportSecurity(String functionsUri) {
if (functionsUri == null) {
return false;
}
diff --git a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
index 1f31f0ba..cd8f884e 100644
--- a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
+++ b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
@@ -81,7 +81,11 @@ private void initializeServer() throws IOException {
private void initializeClient() throws Exception {
this.client = new JavaWorkerClient(this);
this.listeningTask = this.client.listen("java-worker-test", HostGrpcImplementation.ESTABLISH_REQID);
- this.grpcHost.handleMessage(HostGrpcImplementation.ESTABLISH_REQID, m -> this.grpcHost.initWorker());
+ this.grpcHost.handleMessageWithTimeout(
+ HostGrpcImplementation.ESTABLISH_REQID,
+ m -> this.grpcHost.initWorker(),
+ RESPONSE_TIMEOUT_SECONDS,
+ TimeUnit.SECONDS);
}
@Override
@@ -195,7 +199,27 @@ private void setResponse(String requestId, StreamingMessage value, StreamObserve
void handleMessage(String requestId, Function handler) throws Exception {
this.lock.lock();
try {
- long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(RESPONSE_TIMEOUT_SECONDS);
+ while (this.responder.get(requestId) == null) {
+ this.getResponseCondition(requestId).await();
+ }
+ StreamingMessage message = this.respValue.get(requestId);
+ StreamingMessage response = null;
+ if (handler != null) {
+ response = handler.apply(message);
+ }
+ if (response != null) {
+ this.responder.get(requestId).onNext(response);
+ }
+ } finally {
+ this.lock.unlock();
+ }
+ }
+
+ void handleMessageWithTimeout(String requestId, Function handler,
+ long timeout, TimeUnit unit) throws Exception {
+ this.lock.lock();
+ try {
+ long deadlineNanos = System.nanoTime() + unit.toNanos(timeout);
while (this.responder.get(requestId) == null) {
FunctionsTestHost.this.throwIfListeningFailed();
long remainingNanos = deadlineNanos - System.nanoTime();
From 5a9ed39976476ec067c451d8b0180402ae25aa47 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 12 May 2026 17:40:28 +0000
Subject: [PATCH 05/22] Refine comment follow-up on nullability and test host
waits
Agent-Logs-Url: https://github.com/larohra/azure-functions-java-worker/sessions/db76de91-28a8-4391-b3a2-d8796d6725e4
Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>
---
.../com/microsoft/azure/functions/worker/JavaWorkerClient.java | 3 +++
.../functions/worker/test/utilities/FunctionsTestHost.java | 3 ++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java b/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
index ad2b1862..addca738 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
@@ -146,6 +146,9 @@ private synchronized void send(String requestId, MessageHandler, ?> marshaller
private final Map>> handlerSuppliers;
private final ClassLoaderProvider classPathProvider;
+ /**
+ * @param functionsUri Host endpoint URI, or null for legacy startup args that only provide host and port.
+ */
static boolean useTransportSecurity(String functionsUri) {
if (functionsUri == null) {
return false;
diff --git a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
index cd8f884e..e83c5ab6 100644
--- a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
+++ b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
@@ -200,7 +200,8 @@ void handleMessage(String requestId, Function
Date: Tue, 12 May 2026 17:43:01 +0000
Subject: [PATCH 06/22] Deduplicate test host message response handling
Agent-Logs-Url: https://github.com/larohra/azure-functions-java-worker/sessions/db76de91-28a8-4391-b3a2-d8796d6725e4
Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>
---
.../test/utilities/FunctionsTestHost.java | 29 +++++++++----------
1 file changed, 13 insertions(+), 16 deletions(-)
diff --git a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
index e83c5ab6..78d73a03 100644
--- a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
+++ b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java
@@ -203,14 +203,7 @@ void handleMessage(String requestId, Function handler) {
+ StreamingMessage message = this.respValue.get(requestId);
+ StreamingMessage response = null;
+ if (handler != null) {
+ response = handler.apply(message);
+ }
+ if (response != null) {
+ this.responder.get(requestId).onNext(response);
+ }
+ }
+
private StreamingMessage initWorker() {
WorkerInitRequest.Builder request = WorkerInitRequest.newBuilder().setHostVersion("2.0.0");
return StreamingMessage.newBuilder().setRequestId(INITWORKER_REQID).setWorkerInitRequest(request).build();
From f7e6bc4761ac9b5c5d6c44b3c3bc0e180ac8bd19 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 12 May 2026 18:14:15 +0000
Subject: [PATCH 07/22] Scope skip-testing property in GrpcTransportTest
Agent-Logs-Url: https://github.com/larohra/azure-functions-java-worker/sessions/bc5fc454-8077-4ad5-b568-3befc6ceb14c
Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>
---
.../functional/tests/GrpcTransportTest.java | 43 ++++++++++++++-----
1 file changed, 33 insertions(+), 10 deletions(-)
diff --git a/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java b/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java
index 81df6469..95c6d813 100644
--- a/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java
+++ b/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java
@@ -21,8 +21,8 @@ public String ReturnStringFunction() {
@Test
public void legacyPlaintextTransportStillWorks() throws Exception {
- System.setProperty("azure.functions.worker.java.skip.testing", "true");
- try (FunctionsTestHost host = new FunctionsTestHost()) {
+ try (SkipTestingScope ignored = SkipTestingScope.enable();
+ FunctionsTestHost host = new FunctionsTestHost()) {
InvocationResponse response = this.invokeReturnString(host, "plaintext-function", "plaintext-request");
assertEquals(TypedData.DataCase.STRING, response.getReturnValue().getDataCase());
@@ -32,8 +32,8 @@ public void legacyPlaintextTransportStillWorks() throws Exception {
@Test
public void trustedHttpsFunctionsUriConnectsToTlsHost() throws Exception {
- System.setProperty("azure.functions.worker.java.skip.testing", "true");
- try (TrustStoreScope ignored = TrustStoreScope.use(TRUSTSTORE_RESOURCE, TRUSTSTORE_PASSWORD, "PKCS12");
+ try (SkipTestingScope ignored = SkipTestingScope.enable();
+ TrustStoreScope ignoredTrustStore = TrustStoreScope.use(TRUSTSTORE_RESOURCE, TRUSTSTORE_PASSWORD, "PKCS12");
FunctionsTestHost host = new FunctionsTestHost(FunctionsTestHost.ServerTransport.TLS, FunctionsTestHost.ClientTransport.HTTPS)) {
InvocationResponse response = this.invokeReturnString(host, "tls-function", "tls-request");
@@ -44,12 +44,11 @@ public void trustedHttpsFunctionsUriConnectsToTlsHost() throws Exception {
@Test
public void httpsFunctionsUriDoesNotDowngradeToPlaintextWhenTlsFails() {
- System.setProperty("azure.functions.worker.java.skip.testing", "true");
- ExecutionException exception = assertThrows(ExecutionException.class,
- () -> {
- try (FunctionsTestHost ignored = new FunctionsTestHost(FunctionsTestHost.ServerTransport.PLAINTEXT, FunctionsTestHost.ClientTransport.HTTPS)) {
- }
- });
+ ExecutionException exception = assertThrows(ExecutionException.class, () -> {
+ try (SkipTestingScope ignored = SkipTestingScope.enable();
+ FunctionsTestHost ignoredHost = new FunctionsTestHost(FunctionsTestHost.ServerTransport.PLAINTEXT, FunctionsTestHost.ClientTransport.HTTPS)) {
+ }
+ });
assertTrue(hasCause(exception, SSLException.class), "Expected TLS failure but got: " + exception);
}
@@ -115,4 +114,28 @@ private static void restore(String key, String value) {
}
}
}
+
+ private static final class SkipTestingScope implements AutoCloseable {
+ private static final String SKIP_TESTING_PROPERTY = "azure.functions.worker.java.skip.testing";
+ private final String originalValue;
+
+ private SkipTestingScope(String originalValue) {
+ this.originalValue = originalValue;
+ }
+
+ static SkipTestingScope enable() {
+ String originalValue = System.getProperty(SKIP_TESTING_PROPERTY);
+ System.setProperty(SKIP_TESTING_PROPERTY, "true");
+ return new SkipTestingScope(originalValue);
+ }
+
+ @Override
+ public void close() {
+ if (this.originalValue == null) {
+ System.clearProperty(SKIP_TESTING_PROPERTY);
+ } else {
+ System.setProperty(SKIP_TESTING_PROPERTY, this.originalValue);
+ }
+ }
+ }
}
From 0696ee199ea9bef91e3b4eff2a885797c3a1f361 Mon Sep 17 00:00:00 2001
From: "azure-pipelines[bot]"
<36771401+azure-pipelines[bot]@users.noreply.github.com>
Date: Tue, 12 May 2026 17:26:13 -0500
Subject: [PATCH 08/22] Update Java versions (#870)
Auto-detected new Java versions from Microsoft OpenJDK releases.
This PR was automatically generated by the Java version check pipeline.
Pipeline Run: 20260504.1
Co-authored-by: Azure Functions Java Worker Bot
---
eng/ci/templates/java-versions.yml | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/eng/ci/templates/java-versions.yml b/eng/ci/templates/java-versions.yml
index 301601dc..ebe7e5c9 100644
--- a/eng/ci/templates/java-versions.yml
+++ b/eng/ci/templates/java-versions.yml
@@ -2,15 +2,15 @@ variables:
# Linux JDK Versions
JDK8_LINUX_VERSION: '482'
JDK8_LINUX_BUILD: '08'
- JDK11_LINUX_VERSION: '11.0.30'
- JDK17_LINUX_VERSION: '17.0.18'
- JDK21_LINUX_VERSION: '21.0.10'
- JDK25_LINUX_VERSION: '25.0.2'
+ JDK11_LINUX_VERSION: '11.0.31'
+ JDK17_LINUX_VERSION: '17.0.19'
+ JDK21_LINUX_VERSION: '21.0.11'
+ JDK25_LINUX_VERSION: '25.0.3'
# Windows JDK Versions
JDK8_WINDOWS_VERSION: '482'
JDK8_WINDOWS_BUILD: '08'
- JDK11_WINDOWS_VERSION: '11.0.30'
- JDK17_WINDOWS_VERSION: '17.0.18'
- JDK21_WINDOWS_VERSION: '21.0.10'
- JDK25_WINDOWS_VERSION: '25.0.2'
\ No newline at end of file
+ JDK11_WINDOWS_VERSION: '11.0.31'
+ JDK17_WINDOWS_VERSION: '17.0.19'
+ JDK21_WINDOWS_VERSION: '21.0.11'
+ JDK25_WINDOWS_VERSION: '25.0.3'
\ No newline at end of file
From ba67f1d0cecf72bdc46c5479ae1b23be4ba4ef83 Mon Sep 17 00:00:00 2001
From: Jacob Viau
Date: Tue, 12 May 2026 16:13:26 -0700
Subject: [PATCH 09/22] Add es-metadata.yml (schema 1.0.0) (#871)
* Add es-metadata.yml to schema 1.0.0
* Update area path to internal\Azure Functions
---
es-metadata.yml | 12 ++++++++++++
1 file changed, 12 insertions(+)
create mode 100644 es-metadata.yml
diff --git a/es-metadata.yml b/es-metadata.yml
new file mode 100644
index 00000000..1dd26abe
--- /dev/null
+++ b/es-metadata.yml
@@ -0,0 +1,12 @@
+schemaVersion: 1.0.0
+providers:
+- provider: InventoryAsCode
+ version: 1.0.0
+ metadata:
+ isProduction: true
+ accountableOwners:
+ service: e8fdf03b-44f7-48d3-8653-a744c922a342
+ routing:
+ defaultAreaPath:
+ org: azfunc
+ path: internal\Azure Functions
From dd4c111ef616b6cdaf91e61977f90348c7dbccfc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 16 May 2026 01:13:23 +0000
Subject: [PATCH 10/22] refactor URI parsing and transport security selection
Agent-Logs-Url: https://github.com/larohra/azure-functions-java-worker/sessions/f12a179d-bf43-486b-b716-cf4f837012ff
Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>
---
.../azure/functions/worker/Application.java | 47 ++++++++++++++-----
.../functions/worker/JavaWorkerClient.java | 33 +------------
.../functions/worker/ApplicationTest.java | 39 ++++++++++++++-
.../worker/JavaWorkerClientTest.java | 8 +---
4 files changed, 77 insertions(+), 50 deletions(-)
diff --git a/src/main/java/com/microsoft/azure/functions/worker/Application.java b/src/main/java/com/microsoft/azure/functions/worker/Application.java
index 8bb4a494..554e0dbd 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/Application.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/Application.java
@@ -1,8 +1,8 @@
package com.microsoft.azure.functions.worker;
-import java.net.MalformedURLException;
+import java.net.URI;
import java.net.URISyntaxException;
-import java.net.URL;
+import java.util.Locale;
import java.util.logging.*;
import javax.annotation.*;
@@ -162,10 +162,6 @@ public String getFunctionsUri() {
return this.uri;
}
- public String getUri() {
- return this.getFunctionsUri();
- }
-
@Override
public boolean logToConsole() {
return this.logToConsole;
@@ -190,17 +186,44 @@ private boolean isCommandlineValid() {
private String parseUri(String uri) throws ParseException {
try {
- URL url = new URL(uri);
- url.toURI();
- this.host = url.getHost();
- this.port = url.getPort();
+ URI parsedUri = new URI(uri);
+ String host = parsedUri.getHost();
+ String scheme = parsedUri.getScheme();
+ int port = parsedUri.getPort();
+
+ if (scheme == null || scheme.isEmpty()) {
+ throw new IllegalArgumentException("URI scheme is missing");
+ }
+
+ switch (scheme.toLowerCase(Locale.ROOT)) {
+ case "http":
+ if (port == -1) {
+ port = 80;
+ }
+ break;
+ case "https":
+ if (port == -1) {
+ port = 443;
+ }
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported URI scheme");
+ }
+
+ if (host == null || host.isEmpty()) {
+ throw new IllegalArgumentException("URI host is missing");
+ }
+
if (port < 1 || port > 65535) {
throw new IndexOutOfBoundsException("port number out of range");
}
+
+ this.host = host;
+ this.port = port;
return uri;
- } catch (MalformedURLException | URISyntaxException | IndexOutOfBoundsException e) {
+ } catch (URISyntaxException | IllegalArgumentException | IndexOutOfBoundsException e) {
throw new ParseException(String.format(
- "Error parsing URI \"%s\". Please provide a valid URI", uri));
+ "Error parsing URI \"%s\". Please provide a valid http or https URI", uri));
}
}
diff --git a/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java b/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
index addca738..478102b2 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
@@ -1,7 +1,5 @@
package com.microsoft.azure.functions.worker;
-import java.net.URI;
-import java.net.URISyntaxException;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
@@ -150,34 +148,7 @@ private synchronized void send(String requestId, MessageHandler, ?> marshaller
* @param functionsUri Host endpoint URI, or null for legacy startup args that only provide host and port.
*/
static boolean useTransportSecurity(String functionsUri) {
- if (functionsUri == null) {
- return false;
- }
-
- String scheme = parseFunctionsUriScheme(functionsUri);
- switch (scheme.toLowerCase(Locale.ROOT)) {
- case "http":
- return false;
- case "https":
- return true;
- default:
- throw new IllegalArgumentException(String.format(
- "Unsupported functions URI scheme \"%s\" in functions URI \"%s\". Only http and https are supported.",
- scheme, functionsUri));
- }
- }
-
- private static String parseFunctionsUriScheme(String functionsUri) {
- try {
- String scheme = new URI(functionsUri).getScheme();
- if (scheme == null || scheme.isEmpty()) {
- throw new IllegalArgumentException(String.format(
- "Unsupported functions URI \"%s\". Only http and https are supported.", functionsUri));
- }
- return scheme;
- } catch (URISyntaxException ex) {
- throw new IllegalArgumentException(String.format(
- "Error parsing functions URI \"%s\". Please provide a valid http or https URI.", functionsUri), ex);
- }
+ return functionsUri != null
+ && functionsUri.regionMatches(true, 0, "https://", 0, "https://".length());
}
}
diff --git a/src/test/java/com/microsoft/azure/functions/worker/ApplicationTest.java b/src/test/java/com/microsoft/azure/functions/worker/ApplicationTest.java
index 8df09e2e..7f468e60 100644
--- a/src/test/java/com/microsoft/azure/functions/worker/ApplicationTest.java
+++ b/src/test/java/com/microsoft/azure/functions/worker/ApplicationTest.java
@@ -6,6 +6,7 @@
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -32,7 +33,6 @@ public void prefixedStartupArgumentsTakePrecedenceOverLegacyValues() throws Exce
assertTrue(isCommandLineValid(application));
assertEquals("https://functions.example:8443", application.getFunctionsUri());
- assertEquals("https://functions.example:8443", application.getUri());
assertEquals("functions.example", application.getHost());
assertEquals(8443, application.getPort());
assertEquals(Integer.valueOf(2048), application.getMaxMessageSize());
@@ -56,6 +56,43 @@ public void legacyHostAndPortRemainAvailableWhenFunctionsUriIsAbsent() throws Ex
assertEquals(Integer.valueOf(4096), application.getMaxMessageSize());
}
+ @Test
+ public void httpsFunctionsUriDefaultsToPort443WhenPortIsMissing() throws Exception {
+ Application application = createApplication(
+ "--functions-uri", "https://functions.example/path",
+ "--functions-worker-id", "prefixed-worker",
+ "--functions-request-id", "prefixed-request",
+ "--functions-grpc-max-message-length", "2048");
+
+ assertTrue(isCommandLineValid(application));
+ assertEquals("functions.example", application.getHost());
+ assertEquals(443, application.getPort());
+ }
+
+ @Test
+ public void httpFunctionsUriDefaultsToPort80WhenPortIsMissing() throws Exception {
+ Application application = createApplication(
+ "--functions-uri", "http://functions.example/path",
+ "--functions-worker-id", "prefixed-worker",
+ "--functions-request-id", "prefixed-request",
+ "--functions-grpc-max-message-length", "2048");
+
+ assertTrue(isCommandLineValid(application));
+ assertEquals("functions.example", application.getHost());
+ assertEquals(80, application.getPort());
+ }
+
+ @Test
+ public void unsupportedFunctionsUriSchemeFailsCommandLineValidation() throws Exception {
+ Application application = createApplication(
+ "--functions-uri", "unix:///tmp/functions.sock",
+ "--functions-worker-id", "prefixed-worker",
+ "--functions-request-id", "prefixed-request",
+ "--functions-grpc-max-message-length", "2048");
+
+ assertFalse(isCommandLineValid(application));
+ }
+
private static Application createApplication(String... args) throws Exception {
Constructor constructor = Application.class.getDeclaredConstructor(String[].class);
constructor.setAccessible(true);
diff --git a/src/test/java/com/microsoft/azure/functions/worker/JavaWorkerClientTest.java b/src/test/java/com/microsoft/azure/functions/worker/JavaWorkerClientTest.java
index c3437334..7309a567 100644
--- a/src/test/java/com/microsoft/azure/functions/worker/JavaWorkerClientTest.java
+++ b/src/test/java/com/microsoft/azure/functions/worker/JavaWorkerClientTest.java
@@ -3,7 +3,6 @@
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class JavaWorkerClientTest {
@@ -23,10 +22,7 @@ public void httpsFunctionsUriUsesTransportSecurity() {
}
@Test
- public void unsupportedFunctionsUriSchemeFailsFast() {
- IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
- () -> JavaWorkerClient.useTransportSecurity("unix:///tmp/functions.sock"));
-
- assertTrue(exception.getMessage().contains("Only http and https are supported."));
+ public void httpsFunctionsUriSchemeIsCaseInsensitive() {
+ assertTrue(JavaWorkerClient.useTransportSecurity("HTTPS://functions.example:8443"));
}
}
From 280ec1df3ef1cbdcb021e22df1d4c3ef96a34746 Mon Sep 17 00:00:00 2001
From: "azure-pipelines[bot]"
<36771401+azure-pipelines[bot]@users.noreply.github.com>
Date: Wed, 3 Jun 2026 11:11:31 -0500
Subject: [PATCH 11/22] Update Java versions (#876)
Auto-detected new Java versions from Microsoft OpenJDK releases.
This PR was automatically generated by the Java version check pipeline.
Pipeline Run: 20260601.1
Co-authored-by: Azure Functions Java Worker Bot
---
eng/ci/templates/java-versions.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/eng/ci/templates/java-versions.yml b/eng/ci/templates/java-versions.yml
index ebe7e5c9..d2fbb759 100644
--- a/eng/ci/templates/java-versions.yml
+++ b/eng/ci/templates/java-versions.yml
@@ -1,15 +1,15 @@
variables:
# Linux JDK Versions
- JDK8_LINUX_VERSION: '482'
- JDK8_LINUX_BUILD: '08'
+ JDK8_LINUX_VERSION: '492'
+ JDK8_LINUX_BUILD: '09'
JDK11_LINUX_VERSION: '11.0.31'
JDK17_LINUX_VERSION: '17.0.19'
JDK21_LINUX_VERSION: '21.0.11'
JDK25_LINUX_VERSION: '25.0.3'
# Windows JDK Versions
- JDK8_WINDOWS_VERSION: '482'
- JDK8_WINDOWS_BUILD: '08'
+ JDK8_WINDOWS_VERSION: '492'
+ JDK8_WINDOWS_BUILD: '09'
JDK11_WINDOWS_VERSION: '11.0.31'
JDK17_WINDOWS_VERSION: '17.0.19'
JDK21_WINDOWS_VERSION: '21.0.11'
From 00ce27395d67d3acdf667fc83e066cfecf8b46b1 Mon Sep 17 00:00:00 2001
From: Ahmed Muhsin <36454324+ahmedmuhsin@users.noreply.github.com>
Date: Wed, 3 Jun 2026 12:03:10 -0500
Subject: [PATCH 12/22] Bump grpc-java to 1.81.0 to silence Java 25 Unsafe
deprecation warning (#873)
* Bump grpc-java to 1.81.0 to silence Java 25 Unsafe deprecation warning
Bumps io.grpc:grpc-netty-shaded (and grpc-protobuf, grpc-stub, protoc-gen-grpc-java) from 1.75.0 to 1.81.0, which pulls in shaded Netty 4.1.132. The previous shaded Netty (4.1.118 inside grpc-java 1.75.0) called the now terminally-deprecated sun.misc.Unsafe::allocateMemory, producing noisy warnings on JDK 23+ (and especially JDK 25). Netty 4.1.124+ routes direct-buffer allocation away from the deprecated method, so the warning goes away without any code or launch-argument changes.
grpc-java 1.81.0 still targets Java 8 bytecode (animal-sniffer is still on the dependency tree), so this preserves Java 8 compatibility for the worker.
* Bump protobuf-java and protoc to 3.25.8
Bumps protobuf-java (and the matching protoc artifact used by protobuf-maven-plugin) from 3.25.5 to 3.25.8. Same minor (3.25.x), patch-level fixes only, fully compatible with grpc-protobuf 1.81.0 which declares 3.25.5 as the minimum.
---
pom.xml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/pom.xml b/pom.xml
index a483d766..cf3253da 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,7 +22,7 @@
5.9.14.11.03.7.6
- 1.75.0
+ 1.81.0
@@ -75,7 +75,7 @@
com.google.protobufprotobuf-java
- 3.25.5
+ 3.25.8io.grpc
@@ -147,7 +147,7 @@
protobuf-maven-plugin0.5.1
- com.google.protobuf:protoc:3.25.5:exe:${os.detected.classifier}
+ com.google.protobuf:protoc:3.25.8:exe:${os.detected.classifier}grpc-javaio.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}${basedir}/src/main/azure-functions-language-worker-protobuf/src/proto
From 0f8bf4005fbd73ee8c7267c2b86e96637528aa13 Mon Sep 17 00:00:00 2001
From: Ahmed Muhsin
Date: Wed, 3 Jun 2026 14:16:08 -0500
Subject: [PATCH 13/22] Add HttpProxyServer + Coordinator infrastructure
Introduce a new com.microsoft.azure.functions.worker.http package that lays the foundation for receiving HTTP-triggered invocations directly from the Functions host via the HttpUri capability (parity with the Go, Python, and .NET isolated workers).
* HttpProxyServer wraps the JDK built-in com.sun.net.httpserver.HttpServer. Binds to 127.0.0.1 on an ephemeral port, dispatches requests on a cached executor mirroring the existing gRPC dispatch pool (unbounded growth, daemon threads, 15s shutdown drain). No new third-party dependencies.
* HttpInvocationCoordinator synchronizes the two halves of each invocation - the HTTP request and the gRPC InvocationRequest - via a ConcurrentMap of per-invocation slots keyed by invocationId. Each slot exposes CompletableFutures for HTTP arrival, gRPC arrival, and completion.
* ProxyConfig captures bind address/port only. No body-size or timeout caps; matches Go/Python/.NET behavior of relying on the platform for those guards.
Pure infrastructure commit: nothing is wired into the worker lifecycle yet. 15 new unit tests cover both rendezvous orderings, slot release/failure semantics, ephemeral port binding, request routing, and close-after-start behavior.
---
.../http/HttpInvocationCoordinator.java | 103 +++++++++++++
.../worker/http/HttpInvocationSlot.java | 52 +++++++
.../worker/http/HttpProxyServer.java | 126 ++++++++++++++++
.../functions/worker/http/ProxyConfig.java | 43 ++++++
.../http/HttpInvocationCoordinatorTest.java | 135 ++++++++++++++++++
.../worker/http/HttpProxyServerTest.java | 115 +++++++++++++++
6 files changed, 574 insertions(+)
create mode 100644 src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinator.java
create mode 100644 src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationSlot.java
create mode 100644 src/main/java/com/microsoft/azure/functions/worker/http/HttpProxyServer.java
create mode 100644 src/main/java/com/microsoft/azure/functions/worker/http/ProxyConfig.java
create mode 100644 src/test/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinatorTest.java
create mode 100644 src/test/java/com/microsoft/azure/functions/worker/http/HttpProxyServerTest.java
diff --git a/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinator.java b/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinator.java
new file mode 100644
index 00000000..fbe47dec
--- /dev/null
+++ b/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinator.java
@@ -0,0 +1,103 @@
+package com.microsoft.azure.functions.worker.http;
+
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import com.microsoft.azure.functions.rpc.messages.InvocationRequest;
+import com.sun.net.httpserver.HttpExchange;
+
+/**
+ * Synchronizes the two halves of an HTTP-proxied invocation by invocation id.
+ *
+ *
The Functions host dispatches each HTTP-triggered invocation as two
+ * messages that arrive on independent channels:
+ *
+ *
An HTTP request forwarded to the worker's embedded proxy server
+ * (delivered to a worker thread inside the JDK HttpServer pool).
+ *
A gRPC {@code InvocationRequest} carrying trigger metadata and
+ * the matching {@code invocationId}.
+ *
+ *
+ *
This coordinator owns the per-invocation slot, exposes rendezvous methods
+ * that block until the other half arrives, and releases the slot when the
+ * invocation completes. Slot lookup and creation are atomic so the two halves
+ * can race without losing one another.
+ *
+ *
The coordinator does not impose timeouts: the host owns end-to-end
+ * timeout enforcement, and per-invocation hangs are observable via the worker's
+ * existing health and watchdog telemetry.
+ */
+public final class HttpInvocationCoordinator {
+ private final ConcurrentMap slots = new ConcurrentHashMap<>();
+
+ /**
+ * Registers the arrival of an HTTP request for the given invocation.
+ * If the matching gRPC dispatch has not yet arrived, the returned future
+ * resolves once it does. Called by the HTTP proxy handler.
+ */
+ public CompletableFuture registerHttpArrival(String invocationId, HttpExchange exchange) {
+ Objects.requireNonNull(invocationId, "invocationId");
+ Objects.requireNonNull(exchange, "exchange");
+ HttpInvocationSlot slot = slots.computeIfAbsent(invocationId, HttpInvocationSlot::new);
+ if (!slot.httpArrival().complete(exchange)) {
+ throw new IllegalStateException(
+ "HTTP arrival already registered for invocation " + invocationId);
+ }
+ return slot.grpcArrival();
+ }
+
+ /**
+ * Registers the arrival of a gRPC InvocationRequest for the given invocation.
+ * If the matching HTTP request has not yet arrived, the returned future
+ * resolves once it does. Called by the gRPC invocation dispatcher.
+ */
+ public CompletableFuture registerGrpcArrival(InvocationRequest request) {
+ Objects.requireNonNull(request, "request");
+ String invocationId = request.getInvocationId();
+ HttpInvocationSlot slot = slots.computeIfAbsent(invocationId, HttpInvocationSlot::new);
+ if (!slot.grpcArrival().complete(request)) {
+ throw new IllegalStateException(
+ "gRPC arrival already registered for invocation " + invocationId);
+ }
+ return slot.httpArrival();
+ }
+
+ /**
+ * Marks the invocation as complete and removes its slot. Idempotent. Any
+ * outstanding rendezvous futures are cancelled to unblock callers.
+ */
+ public void releaseInvocation(String invocationId) {
+ Objects.requireNonNull(invocationId, "invocationId");
+ HttpInvocationSlot slot = slots.remove(invocationId);
+ if (slot == null) {
+ return;
+ }
+ slot.httpArrival().cancel(false);
+ slot.grpcArrival().cancel(false);
+ slot.completion().complete(null);
+ }
+
+ /**
+ * Fails the invocation slot with the given throwable. Used when the worker
+ * decides to abort an in-flight invocation (e.g., HTTP handler exception
+ * before the user function runs). The slot is removed after failure.
+ */
+ public void failInvocation(String invocationId, Throwable cause) {
+ Objects.requireNonNull(invocationId, "invocationId");
+ Objects.requireNonNull(cause, "cause");
+ HttpInvocationSlot slot = slots.remove(invocationId);
+ if (slot == null) {
+ return;
+ }
+ slot.httpArrival().completeExceptionally(cause);
+ slot.grpcArrival().completeExceptionally(cause);
+ slot.completion().completeExceptionally(cause);
+ }
+
+ /** Visible for tests. */
+ int activeInvocationCount() {
+ return slots.size();
+ }
+}
diff --git a/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationSlot.java b/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationSlot.java
new file mode 100644
index 00000000..7a5e3095
--- /dev/null
+++ b/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationSlot.java
@@ -0,0 +1,52 @@
+package com.microsoft.azure.functions.worker.http;
+
+import java.util.concurrent.CompletableFuture;
+
+import com.microsoft.azure.functions.rpc.messages.InvocationRequest;
+import com.sun.net.httpserver.HttpExchange;
+
+/**
+ * Holds the rendezvous state for a single in-flight HTTP-proxied invocation.
+ *
+ *
The Functions host delivers an invocation along two independent paths:
+ *
+ *
An HTTP request forwarded to the worker's proxy server, carrying the
+ * request body and headers.
+ *
A gRPC {@code InvocationRequest} carrying trigger metadata, route
+ * parameters, and the {@code invocationId} used to correlate the two.
+ *
+ *
+ *
Either side may arrive first. The slot exposes futures that the HTTP
+ * handler thread and the gRPC dispatcher thread wait on. The {@code completion}
+ * future is signaled once the invocation has fully responded, allowing the slot
+ * to be released from the coordinator's map.
+ *
+ *
Instances are package-private; use {@link HttpInvocationCoordinator} to
+ * acquire and release slots.
Backed by {@link com.sun.net.httpserver.HttpServer}, a JDK built-in
+ * since Java 6, so the worker takes on no new runtime dependencies.
+ *
+ *
The server binds to the loopback address on an ephemeral port and is
+ * started by {@link #start(HttpHandler)} with a single root handler.
+ * Worker threads come from a cached executor that mirrors the gRPC dispatch
+ * pool: unbounded growth, named for diagnostics, 15 s drain on shutdown.
+ * Capping concurrency is left to the platform, matching the Go, Python, and
+ * .NET isolated workers.
+ */
+public final class HttpProxyServer implements AutoCloseable {
+ private static final long EXECUTOR_SHUTDOWN_SECONDS = 15L;
+ private static final long SERVER_STOP_SECONDS = 5L;
+
+ private final ProxyConfig config;
+ private final AtomicBoolean started = new AtomicBoolean(false);
+
+ private HttpServer server;
+ private ExecutorService executor;
+ private String boundUri;
+
+ public HttpProxyServer(ProxyConfig config) {
+ this.config = Objects.requireNonNull(config, "config");
+ }
+
+ /**
+ * Binds the server, attaches {@code rootHandler} to {@code "/"}, and starts
+ * serving requests. Returns the absolute {@code http://host:port} URI that
+ * should be advertised to the Functions host via the {@code HttpUri}
+ * capability.
+ *
+ * @throws IllegalStateException if start has already been called
+ * @throws IOException if the server cannot bind
+ */
+ public synchronized String start(HttpHandler rootHandler) throws IOException {
+ Objects.requireNonNull(rootHandler, "rootHandler");
+ if (!started.compareAndSet(false, true)) {
+ throw new IllegalStateException("HttpProxyServer already started");
+ }
+ InetSocketAddress bindAddress = new InetSocketAddress(
+ config.getBindAddress(), config.getBindPort());
+ // Backlog 0 → JDK default.
+ this.server = HttpServer.create(bindAddress, 0);
+ this.executor = Executors.newCachedThreadPool(new ProxyThreadFactory());
+ this.server.setExecutor(this.executor);
+ this.server.createContext("/", rootHandler);
+ this.server.start();
+ InetSocketAddress actual = this.server.getAddress();
+ this.boundUri = "http://" + actual.getHostString() + ":" + actual.getPort();
+ WorkerLogManager.getSystemLogger().log(Level.INFO,
+ "HTTP proxy server bound to " + boundUri);
+ return boundUri;
+ }
+
+ /**
+ * Returns the URI the server is listening on, or {@code null} if the
+ * server has not been started.
+ */
+ public String getBoundUri() {
+ return boundUri;
+ }
+
+ @Override
+ public synchronized void close() {
+ if (!started.compareAndSet(true, false)) {
+ return;
+ }
+ if (server != null) {
+ try {
+ // Allow in-flight requests up to SERVER_STOP_SECONDS to drain.
+ server.stop((int) SERVER_STOP_SECONDS);
+ } catch (RuntimeException ex) {
+ WorkerLogManager.getSystemLogger().log(Level.WARNING,
+ "Error stopping HTTP proxy server", ex);
+ }
+ server = null;
+ }
+ if (executor != null) {
+ executor.shutdown();
+ try {
+ if (!executor.awaitTermination(EXECUTOR_SHUTDOWN_SECONDS, TimeUnit.SECONDS)) {
+ executor.shutdownNow();
+ }
+ } catch (InterruptedException ex) {
+ executor.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ executor = null;
+ }
+ boundUri = null;
+ }
+
+ /**
+ * Thread factory that names worker threads for diagnostics. Daemon threads
+ * so they do not block JVM shutdown if the server is not explicitly closed.
+ */
+ private static final class ProxyThreadFactory implements java.util.concurrent.ThreadFactory {
+ private final java.util.concurrent.atomic.AtomicInteger counter = new java.util.concurrent.atomic.AtomicInteger();
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r, "functions-http-proxy-" + counter.incrementAndGet());
+ t.setDaemon(true);
+ return t;
+ }
+ }
+}
diff --git a/src/main/java/com/microsoft/azure/functions/worker/http/ProxyConfig.java b/src/main/java/com/microsoft/azure/functions/worker/http/ProxyConfig.java
new file mode 100644
index 00000000..78f31d68
--- /dev/null
+++ b/src/main/java/com/microsoft/azure/functions/worker/http/ProxyConfig.java
@@ -0,0 +1,43 @@
+package com.microsoft.azure.functions.worker.http;
+
+import java.util.Objects;
+
+/**
+ * Configuration for the embedded HTTP proxy server used to receive HTTP-triggered
+ * invocations directly from the Functions host (HttpUri capability).
+ *
+ *
The configuration deliberately does not impose request body size limits or
+ * per-request timeouts. The Functions front-end (nginx) enforces an upstream
+ * ceiling, and per-worker overload is managed by the platform — matching the
+ * behavior of the Go, Python, and .NET isolated workers.
+ */
+public final class ProxyConfig {
+ /** Loopback bind address. Other workers also bind to 127.0.0.1 only. */
+ public static final String DEFAULT_BIND_ADDRESS = "127.0.0.1";
+
+ /** Ephemeral port. The OS picks an unused port at bind time. */
+ public static final int DEFAULT_BIND_PORT = 0;
+
+ private final String bindAddress;
+ private final int bindPort;
+
+ public ProxyConfig(String bindAddress, int bindPort) {
+ this.bindAddress = Objects.requireNonNull(bindAddress, "bindAddress");
+ if (bindPort < 0 || bindPort > 65535) {
+ throw new IllegalArgumentException("bindPort out of range: " + bindPort);
+ }
+ this.bindPort = bindPort;
+ }
+
+ public static ProxyConfig defaults() {
+ return new ProxyConfig(DEFAULT_BIND_ADDRESS, DEFAULT_BIND_PORT);
+ }
+
+ public String getBindAddress() {
+ return bindAddress;
+ }
+
+ public int getBindPort() {
+ return bindPort;
+ }
+}
diff --git a/src/test/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinatorTest.java b/src/test/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinatorTest.java
new file mode 100644
index 00000000..06a63188
--- /dev/null
+++ b/src/test/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinatorTest.java
@@ -0,0 +1,135 @@
+package com.microsoft.azure.functions.worker.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import com.microsoft.azure.functions.rpc.messages.InvocationRequest;
+import com.sun.net.httpserver.HttpExchange;
+
+import org.junit.jupiter.api.Test;
+
+public class HttpInvocationCoordinatorTest {
+
+ private static final String INVOCATION_ID = "abc-123";
+
+ @Test
+ public void httpArrivesBeforeGrpc() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpExchange exchange = mock(HttpExchange.class);
+ InvocationRequest request = InvocationRequest.newBuilder().setInvocationId(INVOCATION_ID).build();
+
+ CompletableFuture grpcFuture = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+ assertFalse(grpcFuture.isDone(), "gRPC future should still be pending before gRPC arrival");
+
+ CompletableFuture httpFuture = coordinator.registerGrpcArrival(request);
+ assertTrue(httpFuture.isDone(), "HTTP future should already be resolved once gRPC arrives");
+ assertSame(exchange, httpFuture.get(1, TimeUnit.SECONDS));
+ assertSame(request, grpcFuture.get(1, TimeUnit.SECONDS));
+ }
+
+ @Test
+ public void grpcArrivesBeforeHttp() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpExchange exchange = mock(HttpExchange.class);
+ InvocationRequest request = InvocationRequest.newBuilder().setInvocationId(INVOCATION_ID).build();
+
+ CompletableFuture httpFuture = coordinator.registerGrpcArrival(request);
+ assertFalse(httpFuture.isDone(), "HTTP future should still be pending before HTTP arrival");
+
+ CompletableFuture grpcFuture = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+ assertTrue(grpcFuture.isDone(), "gRPC future should already be resolved once HTTP arrives");
+ assertSame(exchange, httpFuture.get(1, TimeUnit.SECONDS));
+ assertSame(request, grpcFuture.get(1, TimeUnit.SECONDS));
+ }
+
+ @Test
+ public void releaseInvocationRemovesSlot() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpExchange exchange = mock(HttpExchange.class);
+ coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+ assertEquals(1, coordinator.activeInvocationCount());
+
+ coordinator.releaseInvocation(INVOCATION_ID);
+ assertEquals(0, coordinator.activeInvocationCount());
+ }
+
+ @Test
+ public void releaseInvocationIsIdempotent() {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ // Releasing an unknown invocation does not throw.
+ coordinator.releaseInvocation("unknown");
+ coordinator.releaseInvocation("unknown");
+ }
+
+ @Test
+ public void failInvocationPropagatesToFutures() {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpExchange exchange = mock(HttpExchange.class);
+ CompletableFuture grpcFuture = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+
+ IOException cause = new IOException("boom");
+ coordinator.failInvocation(INVOCATION_ID, cause);
+
+ ExecutionException ex = assertThrows(ExecutionException.class,
+ () -> grpcFuture.get(1, TimeUnit.SECONDS));
+ assertSame(cause, ex.getCause());
+ assertEquals(0, coordinator.activeInvocationCount());
+ }
+
+ @Test
+ public void duplicateHttpArrivalThrows() {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpExchange exchange = mock(HttpExchange.class);
+ coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+ assertThrows(IllegalStateException.class,
+ () -> coordinator.registerHttpArrival(INVOCATION_ID, exchange));
+ }
+
+ @Test
+ public void duplicateGrpcArrivalThrows() {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ InvocationRequest request = InvocationRequest.newBuilder().setInvocationId(INVOCATION_ID).build();
+ coordinator.registerGrpcArrival(request);
+ assertThrows(IllegalStateException.class,
+ () -> coordinator.registerGrpcArrival(request));
+ }
+
+ @Test
+ public void independentInvocationsDoNotInterfere() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpExchange exchangeA = mock(HttpExchange.class);
+ HttpExchange exchangeB = mock(HttpExchange.class);
+ InvocationRequest reqA = InvocationRequest.newBuilder().setInvocationId("a").build();
+ InvocationRequest reqB = InvocationRequest.newBuilder().setInvocationId("b").build();
+
+ CompletableFuture grpcA = coordinator.registerHttpArrival("a", exchangeA);
+ CompletableFuture grpcB = coordinator.registerHttpArrival("b", exchangeB);
+ // Resolve only A; B must still be pending.
+ coordinator.registerGrpcArrival(reqA);
+ assertTrue(grpcA.isDone());
+ assertFalse(grpcB.isDone());
+
+ coordinator.registerGrpcArrival(reqB);
+ assertSame(reqA, grpcA.get(1, TimeUnit.SECONDS));
+ assertSame(reqB, grpcB.get(1, TimeUnit.SECONDS));
+ }
+
+ @Test
+ public void grpcFutureRemainsPendingUntilHttpArrives() {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ InvocationRequest request = InvocationRequest.newBuilder().setInvocationId(INVOCATION_ID).build();
+ CompletableFuture httpFuture = coordinator.registerGrpcArrival(request);
+ // No HTTP arrival; future must time out.
+ assertThrows(TimeoutException.class, () -> httpFuture.get(50, TimeUnit.MILLISECONDS));
+ }
+}
diff --git a/src/test/java/com/microsoft/azure/functions/worker/http/HttpProxyServerTest.java b/src/test/java/com/microsoft/azure/functions/worker/http/HttpProxyServerTest.java
new file mode 100644
index 00000000..f9f04a33
--- /dev/null
+++ b/src/test/java/com/microsoft/azure/functions/worker/http/HttpProxyServerTest.java
@@ -0,0 +1,115 @@
+package com.microsoft.azure.functions.worker.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.sun.net.httpserver.HttpHandler;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+public class HttpProxyServerTest {
+
+ private HttpProxyServer server;
+
+ @AfterEach
+ public void tearDown() {
+ if (server != null) {
+ server.close();
+ server = null;
+ }
+ }
+
+ @Test
+ public void startBindsToEphemeralPortAndReturnsUri() throws Exception {
+ server = new HttpProxyServer(ProxyConfig.defaults());
+ String uri = server.start(noOpHandler());
+ assertNotNull(uri);
+ assertTrue(uri.startsWith("http://127.0.0.1:"), "Expected loopback URI, got " + uri);
+ URI parsed = URI.create(uri);
+ assertTrue(parsed.getPort() > 0, "Expected a real port number, got " + parsed.getPort());
+ assertEquals(uri, server.getBoundUri());
+ }
+
+ @Test
+ public void getBoundUriReturnsNullBeforeStart() {
+ server = new HttpProxyServer(ProxyConfig.defaults());
+ assertNull(server.getBoundUri());
+ }
+
+ @Test
+ public void closeBeforeStartIsNoop() {
+ server = new HttpProxyServer(ProxyConfig.defaults());
+ server.close();
+ assertNull(server.getBoundUri());
+ }
+
+ @Test
+ public void doubleStartThrows() throws Exception {
+ server = new HttpProxyServer(ProxyConfig.defaults());
+ server.start(noOpHandler());
+ assertThrows(IllegalStateException.class, () -> server.start(noOpHandler()));
+ }
+
+ @Test
+ public void routesIncomingRequestToHandler() throws Exception {
+ server = new HttpProxyServer(ProxyConfig.defaults());
+ AtomicReference seenPath = new AtomicReference<>();
+ AtomicReference seenHeader = new AtomicReference<>();
+ String uri = server.start(exchange -> {
+ seenPath.set(exchange.getRequestURI().getPath());
+ seenHeader.set(exchange.getRequestHeaders().getFirst("x-ms-invocation-id"));
+ byte[] body = "hello".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(body);
+ }
+ });
+
+ HttpURLConnection conn = (HttpURLConnection) URI.create(uri + "/some/route").toURL().openConnection();
+ conn.setRequestProperty("x-ms-invocation-id", "test-123");
+ conn.connect();
+ try {
+ assertEquals(200, conn.getResponseCode());
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
+ assertEquals("hello", reader.readLine());
+ }
+ } finally {
+ conn.disconnect();
+ }
+ assertEquals("/some/route", seenPath.get());
+ assertEquals("test-123", seenHeader.get());
+ }
+
+ @Test
+ public void closeStopsAcceptingConnections() throws Exception {
+ server = new HttpProxyServer(ProxyConfig.defaults());
+ String uri = server.start(noOpHandler());
+ server.close();
+ server = null;
+ HttpURLConnection conn = (HttpURLConnection) URI.create(uri + "/").toURL().openConnection();
+ conn.setConnectTimeout(500);
+ conn.setReadTimeout(500);
+ // After close, the next connect attempt must fail (connection refused).
+ assertThrows(Exception.class, conn::connect);
+ }
+
+ private static HttpHandler noOpHandler() {
+ return exchange -> {
+ exchange.sendResponseHeaders(204, -1);
+ exchange.close();
+ };
+ }
+}
From b0bd471d036b3f6d83f7b2e9e81d7e550fb2b308 Mon Sep 17 00:00:00 2001
From: Ahmed Muhsin
Date: Wed, 3 Jun 2026 14:34:35 -0500
Subject: [PATCH 14/22] Bridge HTTP proxy traffic to gRPC invocation pipeline
Adds HttpProxyHandler and HttpBodyBridge to connect the embedded HTTP
proxy server to the existing gRPC invocation dispatch path.
HttpProxyHandler parks each forwarded request on
HttpInvocationCoordinator (keyed by x-ms-invocation-id), then blocks on
the slot's completion future so the connection stays open until the
gRPC side has fully written the response. Missing header / duplicate
arrival / invocation failure paths return appropriate HTTP error
responses.
HttpBodyBridge handles the request body <-> protobuf body conversion
and writes RpcHttp responses back to HttpExchange. Body classification
mirrors the host's PopulateBody behavior (application/json -> JSON
TypedData, text/form-encoded/xml/js -> string, otherwise bytes) so
downstream RpcHttpRequestDataSource logic continues to work unchanged.
API refactor: HttpInvocationCoordinator.register*Arrival() now returns
the HttpInvocationSlot itself instead of the opposite side's future,
because the HTTP handler needs to await completion() (not grpcArrival).
HttpInvocationSlot is now public to expose this surface.
Tests:
- HttpBodyBridgeTest: 15 tests covering content-type classification,
charset handling, body enrichment, response writing, and status
parsing.
- HttpProxyHandlerTest: 4 tests covering missing-header,
successful-completion, invocation-failure, and duplicate-arrival
paths.
- HttpInvocationCoordinatorTest: updated for the new slot-returning
API; added completionFutureResolvesOnRelease.
No worker wiring yet -- the proxy server is not started and no
capability is advertised. This change just stages the parts that the
next commit will glue into JavaWorkerClient + the request handlers.
---
.../functions/worker/http/HttpBodyBridge.java | 226 +++++++++++++++++
.../http/HttpInvocationCoordinator.java | 21 +-
.../worker/http/HttpInvocationSlot.java | 34 ++-
.../worker/http/HttpProxyHandler.java | 101 ++++++++
.../worker/http/HttpBodyBridgeTest.java | 230 ++++++++++++++++++
.../http/HttpInvocationCoordinatorTest.java | 60 +++--
.../worker/http/HttpProxyHandlerTest.java | 129 ++++++++++
7 files changed, 759 insertions(+), 42 deletions(-)
create mode 100644 src/main/java/com/microsoft/azure/functions/worker/http/HttpBodyBridge.java
create mode 100644 src/main/java/com/microsoft/azure/functions/worker/http/HttpProxyHandler.java
create mode 100644 src/test/java/com/microsoft/azure/functions/worker/http/HttpBodyBridgeTest.java
create mode 100644 src/test/java/com/microsoft/azure/functions/worker/http/HttpProxyHandlerTest.java
diff --git a/src/main/java/com/microsoft/azure/functions/worker/http/HttpBodyBridge.java b/src/main/java/com/microsoft/azure/functions/worker/http/HttpBodyBridge.java
new file mode 100644
index 00000000..1ca9da74
--- /dev/null
+++ b/src/main/java/com/microsoft/azure/functions/worker/http/HttpBodyBridge.java
@@ -0,0 +1,226 @@
+package com.microsoft.azure.functions.worker.http;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import com.google.protobuf.ByteString;
+import com.microsoft.azure.functions.rpc.messages.InvocationRequest;
+import com.microsoft.azure.functions.rpc.messages.ParameterBinding;
+import com.microsoft.azure.functions.rpc.messages.RpcHttp;
+import com.microsoft.azure.functions.rpc.messages.TypedData;
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpExchange;
+
+/**
+ * Bridges between the JDK {@link HttpExchange} surface and the Functions
+ * worker's protobuf-based binding plumbing.
+ *
+ *
For HTTP-proxied invocations, the Functions host sends the request body
+ * and headers over HTTP (to the worker's embedded proxy server) and the trigger
+ * metadata over gRPC (in an {@link InvocationRequest} whose HTTP input has an
+ * empty body). This class:
+ *
+ *
Reads the body off the {@code HttpExchange} and folds it into the
+ * {@code RpcHttp} payload that downstream binding code expects.
+ *
Writes the {@code RpcHttp} response produced by the user function back
+ * to the {@code HttpExchange}.
+ *
+ *
+ *
Body classification mirrors the host's {@code PopulateBody} logic and the
+ * existing worker behavior for in-process bodies: {@code application/json} →
+ * {@code TypedData.json}; {@code text/*} and form-encoded → {@code TypedData.string};
+ * everything else (including absent {@code Content-Type}) → {@code TypedData.bytes}.
+ */
+public final class HttpBodyBridge {
+ private static final String CONTENT_TYPE_HEADER = "Content-Type";
+ private static final int READ_CHUNK = 8192;
+
+ private HttpBodyBridge() {
+ }
+
+ /**
+ * Returns a copy of {@code request} with the body of its HTTP input replaced
+ * by the body read from {@code exchange}. If no input parameter holds an
+ * {@code RpcHttp} payload, {@code request} is returned unchanged.
+ *
+ *
The body is read eagerly into memory. Streaming support (InputStream
+ * as a parameter type) is layered on in a later commit and bypasses this
+ * eager read.
+ */
+ public static InvocationRequest enrichRequestWithBody(InvocationRequest request, HttpExchange exchange)
+ throws IOException {
+ InvocationRequest.Builder builder = request.toBuilder();
+ List inputs = request.getInputDataList();
+ boolean enriched = false;
+ byte[] body = null;
+ for (int i = 0; i < inputs.size(); i++) {
+ ParameterBinding input = inputs.get(i);
+ if (!input.getData().hasHttp()) {
+ continue;
+ }
+ if (body == null) {
+ body = readBody(exchange);
+ }
+ RpcHttp.Builder httpBuilder = input.getData().getHttp().toBuilder();
+ httpBuilder.setBody(buildBodyTypedData(body, contentType(exchange.getRequestHeaders())));
+ TypedData.Builder dataBuilder = input.getData().toBuilder().setHttp(httpBuilder);
+ ParameterBinding patched = input.toBuilder().setData(dataBuilder).build();
+ builder.setInputData(i, patched);
+ enriched = true;
+ }
+ return enriched ? builder.build() : request;
+ }
+
+ /**
+ * Writes an {@link RpcHttp} response (status + headers + body) to the
+ * given {@link HttpExchange}. The exchange is left open for the caller
+ * to close.
+ */
+ public static void writeRpcHttpResponse(HttpExchange exchange, RpcHttp response) throws IOException {
+ int status = parseStatus(response.getStatusCode());
+ for (Map.Entry header : response.getHeadersMap().entrySet()) {
+ exchange.getResponseHeaders().add(header.getKey(), header.getValue());
+ }
+ byte[] bodyBytes = extractBodyBytes(response.getBody(), response.getHeadersMap());
+ if (bodyBytes.length == 0) {
+ // -1 == no response body; the response body stream does not need to be opened.
+ exchange.sendResponseHeaders(status, -1);
+ } else {
+ exchange.sendResponseHeaders(status, bodyBytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bodyBytes);
+ }
+ }
+ }
+
+ /**
+ * Writes a plain text error response to the exchange. Used by the proxy
+ * handler when it cannot wire up an invocation (missing header, lost
+ * coordinator, etc.).
+ */
+ public static void writeErrorResponse(HttpExchange exchange, int status, String message) throws IOException {
+ byte[] body = (message == null ? "" : message).getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
+ exchange.sendResponseHeaders(status, body.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(body);
+ }
+ }
+
+ static TypedData buildBodyTypedData(byte[] bytes, String contentType) {
+ if (contentType != null) {
+ String normalized = contentType.toLowerCase(Locale.ROOT);
+ if (normalized.startsWith("application/json")) {
+ return TypedData.newBuilder()
+ .setJson(new String(bytes, charsetFromContentType(contentType)))
+ .build();
+ }
+ if (normalized.startsWith("text/")
+ || normalized.startsWith("application/x-www-form-urlencoded")
+ || normalized.startsWith("application/xml")
+ || normalized.startsWith("application/javascript")) {
+ return TypedData.newBuilder()
+ .setString(new String(bytes, charsetFromContentType(contentType)))
+ .build();
+ }
+ }
+ return TypedData.newBuilder()
+ .setBytes(ByteString.copyFrom(bytes))
+ .build();
+ }
+
+ private static byte[] readBody(HttpExchange exchange) throws IOException {
+ try (InputStream in = exchange.getRequestBody()) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] chunk = new byte[READ_CHUNK];
+ int n;
+ while ((n = in.read(chunk)) != -1) {
+ out.write(chunk, 0, n);
+ }
+ return out.toByteArray();
+ }
+ }
+
+ private static String contentType(Headers headers) {
+ return headers.getFirst(CONTENT_TYPE_HEADER);
+ }
+
+ private static Charset charsetFromContentType(String contentType) {
+ if (contentType == null) {
+ return StandardCharsets.UTF_8;
+ }
+ int idx = contentType.toLowerCase(Locale.ROOT).indexOf("charset=");
+ if (idx < 0) {
+ return StandardCharsets.UTF_8;
+ }
+ String charset = contentType.substring(idx + "charset=".length()).trim();
+ int semi = charset.indexOf(';');
+ if (semi >= 0) {
+ charset = charset.substring(0, semi).trim();
+ }
+ // strip enclosing quotes
+ if (charset.length() >= 2
+ && charset.charAt(0) == '"'
+ && charset.charAt(charset.length() - 1) == '"') {
+ charset = charset.substring(1, charset.length() - 1);
+ }
+ try {
+ return Charset.forName(charset);
+ } catch (RuntimeException ex) {
+ return StandardCharsets.UTF_8;
+ }
+ }
+
+ private static int parseStatus(String statusCode) {
+ if (statusCode == null || statusCode.isEmpty()) {
+ return 200;
+ }
+ try {
+ int status = Integer.parseInt(statusCode);
+ if (status < 100 || status > 599) {
+ return 500;
+ }
+ return status;
+ } catch (NumberFormatException ex) {
+ return 500;
+ }
+ }
+
+ private static byte[] extractBodyBytes(TypedData body, Map headers) {
+ if (body == null) {
+ return new byte[0];
+ }
+ switch (body.getDataCase()) {
+ case BYTES:
+ return body.getBytes().toByteArray();
+ case STRING:
+ return body.getString().getBytes(charsetFromContentType(headerLookup(headers, "Content-Type")));
+ case JSON:
+ return body.getJson().getBytes(StandardCharsets.UTF_8);
+ case DATA_NOT_SET:
+ return new byte[0];
+ default:
+ // Unsupported body shapes are coerced to their string form so we never drop the response.
+ return body.toString().getBytes(StandardCharsets.UTF_8);
+ }
+ }
+
+ private static String headerLookup(Map headers, String key) {
+ if (headers == null) {
+ return null;
+ }
+ for (Map.Entry entry : headers.entrySet()) {
+ if (key.equalsIgnoreCase(entry.getKey())) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinator.java b/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinator.java
index fbe47dec..1fa93cc0 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinator.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinator.java
@@ -1,7 +1,6 @@
package com.microsoft.azure.functions.worker.http;
import java.util.Objects;
-import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -34,10 +33,12 @@ public final class HttpInvocationCoordinator {
/**
* Registers the arrival of an HTTP request for the given invocation.
- * If the matching gRPC dispatch has not yet arrived, the returned future
- * resolves once it does. Called by the HTTP proxy handler.
+ * Returns the slot so the HTTP handler can await
+ * {@link HttpInvocationSlot#completion()}.
+ *
+ * @throws IllegalStateException if HTTP arrival was already registered for this id
*/
- public CompletableFuture registerHttpArrival(String invocationId, HttpExchange exchange) {
+ public HttpInvocationSlot registerHttpArrival(String invocationId, HttpExchange exchange) {
Objects.requireNonNull(invocationId, "invocationId");
Objects.requireNonNull(exchange, "exchange");
HttpInvocationSlot slot = slots.computeIfAbsent(invocationId, HttpInvocationSlot::new);
@@ -45,15 +46,17 @@ public CompletableFuture registerHttpArrival(String invocatio
throw new IllegalStateException(
"HTTP arrival already registered for invocation " + invocationId);
}
- return slot.grpcArrival();
+ return slot;
}
/**
* Registers the arrival of a gRPC InvocationRequest for the given invocation.
- * If the matching HTTP request has not yet arrived, the returned future
- * resolves once it does. Called by the gRPC invocation dispatcher.
+ * Returns the slot so the gRPC dispatcher can await
+ * {@link HttpInvocationSlot#httpArrival()}.
+ *
+ * @throws IllegalStateException if gRPC arrival was already registered for this id
*/
- public CompletableFuture registerGrpcArrival(InvocationRequest request) {
+ public HttpInvocationSlot registerGrpcArrival(InvocationRequest request) {
Objects.requireNonNull(request, "request");
String invocationId = request.getInvocationId();
HttpInvocationSlot slot = slots.computeIfAbsent(invocationId, HttpInvocationSlot::new);
@@ -61,7 +64,7 @@ public CompletableFuture registerGrpcArrival(InvocationRequest req
throw new IllegalStateException(
"gRPC arrival already registered for invocation " + invocationId);
}
- return slot.httpArrival();
+ return slot;
}
/**
diff --git a/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationSlot.java b/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationSlot.java
index 7a5e3095..6f4c77c3 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationSlot.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/http/HttpInvocationSlot.java
@@ -17,14 +17,15 @@
*
*
*
Either side may arrive first. The slot exposes futures that the HTTP
- * handler thread and the gRPC dispatcher thread wait on. The {@code completion}
- * future is signaled once the invocation has fully responded, allowing the slot
- * to be released from the coordinator's map.
+ * handler thread and the gRPC dispatcher thread wait on. The {@link #completion}
+ * future is signaled once the invocation has fully responded, allowing the HTTP
+ * handler to return from {@code handle()} so the server can close the exchange.
*
- *
Instances are package-private; use {@link HttpInvocationCoordinator} to
- * acquire and release slots.
+ *
The class is mutable from the coordinator's perspective only; consumers
+ * see immutable {@link CompletableFuture} handles and use them to await
+ * rendezvous and completion.
*/
-final class HttpInvocationSlot {
+public final class HttpInvocationSlot {
private final String invocationId;
private final CompletableFuture httpArrival = new CompletableFuture<>();
private final CompletableFuture grpcArrival = new CompletableFuture<>();
@@ -34,19 +35,32 @@ final class HttpInvocationSlot {
this.invocationId = invocationId;
}
- String getInvocationId() {
+ public String getInvocationId() {
return invocationId;
}
- CompletableFuture httpArrival() {
+ /**
+ * Future that resolves when the HTTP request for this invocation arrives.
+ * Consumed by the gRPC dispatcher thread.
+ */
+ public CompletableFuture httpArrival() {
return httpArrival;
}
- CompletableFuture grpcArrival() {
+ /**
+ * Future that resolves when the gRPC {@code InvocationRequest} for this
+ * invocation arrives. Consumed by the HTTP handler thread.
+ */
+ public CompletableFuture grpcArrival() {
return grpcArrival;
}
- CompletableFuture completion() {
+ /**
+ * Future that resolves when the invocation has fully completed (response
+ * written to HTTP, output bindings collected for the gRPC response).
+ * The HTTP handler thread waits on this before returning from {@code handle()}.
+ */
+ public CompletableFuture completion() {
return completion;
}
}
diff --git a/src/main/java/com/microsoft/azure/functions/worker/http/HttpProxyHandler.java b/src/main/java/com/microsoft/azure/functions/worker/http/HttpProxyHandler.java
new file mode 100644
index 00000000..2b748416
--- /dev/null
+++ b/src/main/java/com/microsoft/azure/functions/worker/http/HttpProxyHandler.java
@@ -0,0 +1,101 @@
+package com.microsoft.azure.functions.worker.http;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import com.microsoft.azure.functions.worker.WorkerLogManager;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+
+/**
+ * Handler attached to the worker's embedded HTTP proxy server.
+ *
+ *
Receives HTTP requests forwarded by the Functions host (via the HttpUri
+ * capability) and parks them on the {@link HttpInvocationCoordinator} until
+ * the gRPC dispatcher picks them up. The actual invocation runs on the gRPC
+ * dispatch thread, which reads the request body and writes the response back
+ * to the same {@link HttpExchange}. This handler simply:
+ *
+ *
Extracts {@code x-ms-invocation-id} from the request headers.
+ *
Registers the HTTP arrival with the coordinator.
+ *
Blocks on the slot's {@code completion} future so the exchange stays
+ * open until the gRPC side finishes writing the response.
+ *
Returns from {@code handle()}, letting the JDK HttpServer close the
+ * exchange.
+ *
+ *
+ *
Missing header or unexpected failures are converted into appropriate HTTP
+ * error responses so the host always gets a closed connection.
+ */
+public final class HttpProxyHandler implements HttpHandler {
+ /** Header set by {@code DefaultHttpProxyService} on the host side. */
+ public static final String INVOCATION_ID_HEADER = "x-ms-invocation-id";
+
+ private static final Logger LOGGER = WorkerLogManager.getSystemLogger();
+
+ private final HttpInvocationCoordinator coordinator;
+
+ public HttpProxyHandler(HttpInvocationCoordinator coordinator) {
+ this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
+ }
+
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ String invocationId = exchange.getRequestHeaders().getFirst(INVOCATION_ID_HEADER);
+ if (invocationId == null || invocationId.isEmpty()) {
+ LOGGER.warning("HTTP proxy request missing " + INVOCATION_ID_HEADER + " header");
+ try {
+ HttpBodyBridge.writeErrorResponse(exchange, 400,
+ "Missing required header: " + INVOCATION_ID_HEADER);
+ } finally {
+ exchange.close();
+ }
+ return;
+ }
+
+ HttpInvocationSlot slot;
+ try {
+ slot = coordinator.registerHttpArrival(invocationId, exchange);
+ } catch (IllegalStateException ex) {
+ LOGGER.log(Level.WARNING, "Duplicate HTTP arrival for invocation " + invocationId, ex);
+ try {
+ HttpBodyBridge.writeErrorResponse(exchange, 409,
+ "Duplicate HTTP arrival for invocation " + invocationId);
+ } finally {
+ exchange.close();
+ }
+ return;
+ }
+
+ try {
+ // Block until the gRPC dispatcher signals invocation completion.
+ // The dispatcher is responsible for writing the response to this
+ // exchange; we simply hold the connection open in the meantime.
+ slot.completion().get();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ tryWriteError(exchange, 503, "Worker interrupted while waiting for invocation");
+ } catch (CancellationException ex) {
+ // Coordinator cancelled the futures via releaseInvocation();
+ // the gRPC side has already written (or chosen not to write) the response.
+ } catch (ExecutionException ex) {
+ Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
+ LOGGER.log(Level.WARNING, "Invocation " + invocationId + " failed before responding", cause);
+ tryWriteError(exchange, 500, "Invocation failed: " + cause.getMessage());
+ } finally {
+ exchange.close();
+ }
+ }
+
+ private static void tryWriteError(HttpExchange exchange, int status, String message) {
+ try {
+ HttpBodyBridge.writeErrorResponse(exchange, status, message);
+ } catch (IOException ioe) {
+ LOGGER.log(Level.FINE, "Unable to write error response (response likely already started)", ioe);
+ }
+ }
+}
diff --git a/src/test/java/com/microsoft/azure/functions/worker/http/HttpBodyBridgeTest.java b/src/test/java/com/microsoft/azure/functions/worker/http/HttpBodyBridgeTest.java
new file mode 100644
index 00000000..e48f0e41
--- /dev/null
+++ b/src/test/java/com/microsoft/azure/functions/worker/http/HttpBodyBridgeTest.java
@@ -0,0 +1,230 @@
+package com.microsoft.azure.functions.worker.http;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import com.google.protobuf.ByteString;
+import com.microsoft.azure.functions.rpc.messages.InvocationRequest;
+import com.microsoft.azure.functions.rpc.messages.ParameterBinding;
+import com.microsoft.azure.functions.rpc.messages.RpcHttp;
+import com.microsoft.azure.functions.rpc.messages.TypedData;
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpExchange;
+
+import org.junit.jupiter.api.Test;
+
+public class HttpBodyBridgeTest {
+
+ @Test
+ public void buildBodyTypedDataJsonContentType() {
+ TypedData data = HttpBodyBridge.buildBodyTypedData(
+ "{\"k\":1}".getBytes(StandardCharsets.UTF_8), "application/json");
+ assertEquals(TypedData.DataCase.JSON, data.getDataCase());
+ assertEquals("{\"k\":1}", data.getJson());
+ }
+
+ @Test
+ public void buildBodyTypedDataJsonWithCharsetSuffix() {
+ TypedData data = HttpBodyBridge.buildBodyTypedData(
+ "{}".getBytes(StandardCharsets.UTF_8), "application/json; charset=utf-8");
+ assertEquals(TypedData.DataCase.JSON, data.getDataCase());
+ }
+
+ @Test
+ public void buildBodyTypedDataTextContentType() {
+ TypedData data = HttpBodyBridge.buildBodyTypedData(
+ "hello".getBytes(StandardCharsets.UTF_8), "text/plain");
+ assertEquals(TypedData.DataCase.STRING, data.getDataCase());
+ assertEquals("hello", data.getString());
+ }
+
+ @Test
+ public void buildBodyTypedDataFormEncoded() {
+ TypedData data = HttpBodyBridge.buildBodyTypedData(
+ "a=1&b=2".getBytes(StandardCharsets.UTF_8), "application/x-www-form-urlencoded");
+ assertEquals(TypedData.DataCase.STRING, data.getDataCase());
+ }
+
+ @Test
+ public void buildBodyTypedDataBinaryWhenNoContentType() {
+ byte[] bytes = new byte[]{1, 2, 3};
+ TypedData data = HttpBodyBridge.buildBodyTypedData(bytes, null);
+ assertEquals(TypedData.DataCase.BYTES, data.getDataCase());
+ assertArrayEquals(bytes, data.getBytes().toByteArray());
+ }
+
+ @Test
+ public void buildBodyTypedDataBinaryWhenOctetStream() {
+ byte[] bytes = "binary".getBytes(StandardCharsets.UTF_8);
+ TypedData data = HttpBodyBridge.buildBodyTypedData(bytes, "application/octet-stream");
+ assertEquals(TypedData.DataCase.BYTES, data.getDataCase());
+ }
+
+ @Test
+ public void buildBodyTypedDataRespectsCharset() {
+ byte[] bytes = "héllo".getBytes(StandardCharsets.ISO_8859_1);
+ TypedData data = HttpBodyBridge.buildBodyTypedData(bytes, "text/plain; charset=ISO-8859-1");
+ assertEquals(TypedData.DataCase.STRING, data.getDataCase());
+ assertEquals("héllo", data.getString());
+ }
+
+ @Test
+ public void enrichRequestWithBodyReplacesHttpBody() throws Exception {
+ InvocationRequest original = InvocationRequest.newBuilder()
+ .setInvocationId("inv-1")
+ .addInputData(ParameterBinding.newBuilder()
+ .setName("req")
+ .setData(TypedData.newBuilder()
+ .setHttp(RpcHttp.newBuilder().setMethod("POST").setUrl("http://localhost/api/x"))))
+ .build();
+ HttpExchange exchange = mockExchangeWithBody("payload".getBytes(StandardCharsets.UTF_8), "text/plain");
+
+ InvocationRequest enriched = HttpBodyBridge.enrichRequestWithBody(original, exchange);
+ assertNotSame(original, enriched);
+ TypedData body = enriched.getInputData(0).getData().getHttp().getBody();
+ assertEquals(TypedData.DataCase.STRING, body.getDataCase());
+ assertEquals("payload", body.getString());
+ // Method/url preserved.
+ assertEquals("POST", enriched.getInputData(0).getData().getHttp().getMethod());
+ }
+
+ @Test
+ public void enrichRequestWithBodyReturnsSameWhenNoHttpInput() throws Exception {
+ InvocationRequest original = InvocationRequest.newBuilder()
+ .setInvocationId("inv-1")
+ .addInputData(ParameterBinding.newBuilder()
+ .setName("queueItem")
+ .setData(TypedData.newBuilder().setString("hello")))
+ .build();
+ HttpExchange exchange = mock(HttpExchange.class);
+ InvocationRequest result = HttpBodyBridge.enrichRequestWithBody(original, exchange);
+ assertSame(original, result, "Non-HTTP requests should not be modified");
+ }
+
+ @Test
+ public void enrichRequestReadsChunkedBodyLargerThanBuffer() throws Exception {
+ // Simulate transfer-encoding: chunked by providing a body larger than the read buffer.
+ byte[] big = new byte[20_000];
+ for (int i = 0; i < big.length; i++) {
+ big[i] = (byte) (i & 0xff);
+ }
+ InvocationRequest original = InvocationRequest.newBuilder()
+ .setInvocationId("inv-big")
+ .addInputData(ParameterBinding.newBuilder().setName("req")
+ .setData(TypedData.newBuilder().setHttp(RpcHttp.newBuilder().setMethod("POST"))))
+ .build();
+ HttpExchange exchange = mockExchangeWithBody(big, "application/octet-stream");
+
+ InvocationRequest enriched = HttpBodyBridge.enrichRequestWithBody(original, exchange);
+ TypedData body = enriched.getInputData(0).getData().getHttp().getBody();
+ assertEquals(TypedData.DataCase.BYTES, body.getDataCase());
+ assertArrayEquals(big, body.getBytes().toByteArray());
+ }
+
+ @Test
+ public void writeRpcHttpResponseWritesStatusHeadersAndBody() throws Exception {
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ Headers responseHeaders = new Headers();
+ HttpExchange exchange = mock(HttpExchange.class);
+ when(exchange.getResponseHeaders()).thenReturn(responseHeaders);
+ when(exchange.getResponseBody()).thenReturn(captured);
+
+ RpcHttp response = RpcHttp.newBuilder()
+ .setStatusCode("201")
+ .putHeaders("Content-Type", "application/json")
+ .putHeaders("X-Custom", "value")
+ .setBody(TypedData.newBuilder().setJson("{\"ok\":true}"))
+ .build();
+
+ HttpBodyBridge.writeRpcHttpResponse(exchange, response);
+
+ byte[] expected = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
+ verify(exchange).sendResponseHeaders(201, expected.length);
+ assertArrayEquals(expected, captured.toByteArray());
+ assertEquals("application/json", responseHeaders.getFirst("Content-Type"));
+ assertEquals("value", responseHeaders.getFirst("X-Custom"));
+ }
+
+ @Test
+ public void writeRpcHttpResponseHandlesEmptyBody() throws Exception {
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ HttpExchange exchange = mock(HttpExchange.class);
+ when(exchange.getResponseHeaders()).thenReturn(new Headers());
+ when(exchange.getResponseBody()).thenReturn(captured);
+
+ RpcHttp response = RpcHttp.newBuilder().setStatusCode("204").build();
+ HttpBodyBridge.writeRpcHttpResponse(exchange, response);
+
+ verify(exchange).sendResponseHeaders(204, -1);
+ assertEquals(0, captured.size());
+ }
+
+ @Test
+ public void writeRpcHttpResponseHandlesBytesBody() throws Exception {
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ HttpExchange exchange = mock(HttpExchange.class);
+ when(exchange.getResponseHeaders()).thenReturn(new Headers());
+ when(exchange.getResponseBody()).thenReturn(captured);
+
+ byte[] payload = new byte[]{0x01, 0x02, 0x03};
+ RpcHttp response = RpcHttp.newBuilder()
+ .setStatusCode("200")
+ .setBody(TypedData.newBuilder().setBytes(ByteString.copyFrom(payload)))
+ .build();
+ HttpBodyBridge.writeRpcHttpResponse(exchange, response);
+
+ verify(exchange).sendResponseHeaders(200, payload.length);
+ assertArrayEquals(payload, captured.toByteArray());
+ }
+
+ @Test
+ public void writeRpcHttpResponseDefaultsInvalidStatusTo500() throws Exception {
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ HttpExchange exchange = mock(HttpExchange.class);
+ when(exchange.getResponseHeaders()).thenReturn(new Headers());
+ when(exchange.getResponseBody()).thenReturn(captured);
+
+ RpcHttp response = RpcHttp.newBuilder().setStatusCode("not-a-number").build();
+ HttpBodyBridge.writeRpcHttpResponse(exchange, response);
+
+ verify(exchange).sendResponseHeaders(500, -1);
+ }
+
+ @Test
+ public void writeErrorResponseWritesPlainText() throws Exception {
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ Headers headers = new Headers();
+ HttpExchange exchange = mock(HttpExchange.class);
+ when(exchange.getResponseHeaders()).thenReturn(headers);
+ when(exchange.getResponseBody()).thenReturn(captured);
+
+ HttpBodyBridge.writeErrorResponse(exchange, 418, "I'm a teapot");
+
+ byte[] expected = "I'm a teapot".getBytes(StandardCharsets.UTF_8);
+ verify(exchange).sendResponseHeaders(418, expected.length);
+ assertArrayEquals(expected, captured.toByteArray());
+ assertTrue(headers.getFirst("Content-Type").startsWith("text/plain"));
+ }
+
+ private static HttpExchange mockExchangeWithBody(byte[] body, String contentType) throws IOException {
+ HttpExchange exchange = mock(HttpExchange.class);
+ Headers headers = new Headers();
+ if (contentType != null) {
+ headers.add("Content-Type", contentType);
+ }
+ when(exchange.getRequestHeaders()).thenReturn(headers);
+ when(exchange.getRequestBody()).thenReturn(new ByteArrayInputStream(body));
+ return exchange;
+ }
+}
diff --git a/src/test/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinatorTest.java b/src/test/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinatorTest.java
index 06a63188..4fd35d3a 100644
--- a/src/test/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinatorTest.java
+++ b/src/test/java/com/microsoft/azure/functions/worker/http/HttpInvocationCoordinatorTest.java
@@ -8,7 +8,6 @@
import static org.mockito.Mockito.mock;
import java.io.IOException;
-import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -28,13 +27,14 @@ public void httpArrivesBeforeGrpc() throws Exception {
HttpExchange exchange = mock(HttpExchange.class);
InvocationRequest request = InvocationRequest.newBuilder().setInvocationId(INVOCATION_ID).build();
- CompletableFuture grpcFuture = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
- assertFalse(grpcFuture.isDone(), "gRPC future should still be pending before gRPC arrival");
+ HttpInvocationSlot httpSlot = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+ assertFalse(httpSlot.grpcArrival().isDone(), "gRPC future should still be pending before gRPC arrival");
- CompletableFuture httpFuture = coordinator.registerGrpcArrival(request);
- assertTrue(httpFuture.isDone(), "HTTP future should already be resolved once gRPC arrives");
- assertSame(exchange, httpFuture.get(1, TimeUnit.SECONDS));
- assertSame(request, grpcFuture.get(1, TimeUnit.SECONDS));
+ HttpInvocationSlot grpcSlot = coordinator.registerGrpcArrival(request);
+ assertSame(httpSlot, grpcSlot, "both registrations should yield the same slot");
+ assertTrue(grpcSlot.httpArrival().isDone(), "HTTP future should already be resolved once gRPC arrives");
+ assertSame(exchange, grpcSlot.httpArrival().get(1, TimeUnit.SECONDS));
+ assertSame(request, httpSlot.grpcArrival().get(1, TimeUnit.SECONDS));
}
@Test
@@ -43,13 +43,14 @@ public void grpcArrivesBeforeHttp() throws Exception {
HttpExchange exchange = mock(HttpExchange.class);
InvocationRequest request = InvocationRequest.newBuilder().setInvocationId(INVOCATION_ID).build();
- CompletableFuture httpFuture = coordinator.registerGrpcArrival(request);
- assertFalse(httpFuture.isDone(), "HTTP future should still be pending before HTTP arrival");
+ HttpInvocationSlot grpcSlot = coordinator.registerGrpcArrival(request);
+ assertFalse(grpcSlot.httpArrival().isDone(), "HTTP future should still be pending before HTTP arrival");
- CompletableFuture grpcFuture = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
- assertTrue(grpcFuture.isDone(), "gRPC future should already be resolved once HTTP arrives");
- assertSame(exchange, httpFuture.get(1, TimeUnit.SECONDS));
- assertSame(request, grpcFuture.get(1, TimeUnit.SECONDS));
+ HttpInvocationSlot httpSlot = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+ assertSame(grpcSlot, httpSlot, "both registrations should yield the same slot");
+ assertTrue(httpSlot.grpcArrival().isDone(), "gRPC future should already be resolved once HTTP arrives");
+ assertSame(exchange, httpSlot.httpArrival().get(1, TimeUnit.SECONDS));
+ assertSame(request, httpSlot.grpcArrival().get(1, TimeUnit.SECONDS));
}
@Test
@@ -75,14 +76,17 @@ public void releaseInvocationIsIdempotent() {
public void failInvocationPropagatesToFutures() {
HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
HttpExchange exchange = mock(HttpExchange.class);
- CompletableFuture grpcFuture = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+ HttpInvocationSlot slot = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
IOException cause = new IOException("boom");
coordinator.failInvocation(INVOCATION_ID, cause);
ExecutionException ex = assertThrows(ExecutionException.class,
- () -> grpcFuture.get(1, TimeUnit.SECONDS));
+ () -> slot.grpcArrival().get(1, TimeUnit.SECONDS));
assertSame(cause, ex.getCause());
+ ExecutionException completionEx = assertThrows(ExecutionException.class,
+ () -> slot.completion().get(1, TimeUnit.SECONDS));
+ assertSame(cause, completionEx.getCause());
assertEquals(0, coordinator.activeInvocationCount());
}
@@ -112,24 +116,34 @@ public void independentInvocationsDoNotInterfere() throws Exception {
InvocationRequest reqA = InvocationRequest.newBuilder().setInvocationId("a").build();
InvocationRequest reqB = InvocationRequest.newBuilder().setInvocationId("b").build();
- CompletableFuture grpcA = coordinator.registerHttpArrival("a", exchangeA);
- CompletableFuture grpcB = coordinator.registerHttpArrival("b", exchangeB);
+ HttpInvocationSlot slotA = coordinator.registerHttpArrival("a", exchangeA);
+ HttpInvocationSlot slotB = coordinator.registerHttpArrival("b", exchangeB);
// Resolve only A; B must still be pending.
coordinator.registerGrpcArrival(reqA);
- assertTrue(grpcA.isDone());
- assertFalse(grpcB.isDone());
+ assertTrue(slotA.grpcArrival().isDone());
+ assertFalse(slotB.grpcArrival().isDone());
coordinator.registerGrpcArrival(reqB);
- assertSame(reqA, grpcA.get(1, TimeUnit.SECONDS));
- assertSame(reqB, grpcB.get(1, TimeUnit.SECONDS));
+ assertSame(reqA, slotA.grpcArrival().get(1, TimeUnit.SECONDS));
+ assertSame(reqB, slotB.grpcArrival().get(1, TimeUnit.SECONDS));
}
@Test
public void grpcFutureRemainsPendingUntilHttpArrives() {
HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
InvocationRequest request = InvocationRequest.newBuilder().setInvocationId(INVOCATION_ID).build();
- CompletableFuture httpFuture = coordinator.registerGrpcArrival(request);
+ HttpInvocationSlot slot = coordinator.registerGrpcArrival(request);
// No HTTP arrival; future must time out.
- assertThrows(TimeoutException.class, () -> httpFuture.get(50, TimeUnit.MILLISECONDS));
+ assertThrows(TimeoutException.class, () -> slot.httpArrival().get(50, TimeUnit.MILLISECONDS));
+ }
+
+ @Test
+ public void completionFutureResolvesOnRelease() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpExchange exchange = mock(HttpExchange.class);
+ HttpInvocationSlot slot = coordinator.registerHttpArrival(INVOCATION_ID, exchange);
+ assertFalse(slot.completion().isDone());
+ coordinator.releaseInvocation(INVOCATION_ID);
+ slot.completion().get(1, TimeUnit.SECONDS); // resolves without throwing
}
}
diff --git a/src/test/java/com/microsoft/azure/functions/worker/http/HttpProxyHandlerTest.java b/src/test/java/com/microsoft/azure/functions/worker/http/HttpProxyHandlerTest.java
new file mode 100644
index 00000000..42e9dd6b
--- /dev/null
+++ b/src/test/java/com/microsoft/azure/functions/worker/http/HttpProxyHandlerTest.java
@@ -0,0 +1,129 @@
+package com.microsoft.azure.functions.worker.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayOutputStream;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpExchange;
+
+import org.junit.jupiter.api.Test;
+
+public class HttpProxyHandlerTest {
+
+ private static final String INVOCATION_ID = "abc-123";
+
+ @Test
+ public void rejectsRequestWithoutInvocationIdHeader() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpProxyHandler handler = new HttpProxyHandler(coordinator);
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ HttpExchange exchange = mock(HttpExchange.class);
+ when(exchange.getRequestHeaders()).thenReturn(new Headers());
+ when(exchange.getResponseHeaders()).thenReturn(new Headers());
+ when(exchange.getResponseBody()).thenReturn(captured);
+
+ handler.handle(exchange);
+
+ verify(exchange).sendResponseHeaders(400, captured.size());
+ verify(exchange).close();
+ assertEquals(0, coordinator.activeInvocationCount());
+ }
+
+ @Test
+ public void registersHttpArrivalAndWaitsForCompletion() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpProxyHandler handler = new HttpProxyHandler(coordinator);
+ HttpExchange exchange = mock(HttpExchange.class);
+ Headers requestHeaders = new Headers();
+ requestHeaders.add(HttpProxyHandler.INVOCATION_ID_HEADER, INVOCATION_ID);
+ when(exchange.getRequestHeaders()).thenReturn(requestHeaders);
+
+ AtomicReference handlerError = new AtomicReference<>();
+ CompletableFuture handlerDone = CompletableFuture.runAsync(() -> {
+ try {
+ handler.handle(exchange);
+ } catch (Throwable t) {
+ handlerError.set(t);
+ }
+ });
+
+ // Wait for the handler to register HTTP arrival.
+ long deadline = System.currentTimeMillis() + 1000;
+ while (coordinator.activeInvocationCount() == 0 && System.currentTimeMillis() < deadline) {
+ Thread.sleep(10);
+ }
+ assertEquals(1, coordinator.activeInvocationCount());
+
+ // Simulate the gRPC side finishing the invocation.
+ coordinator.releaseInvocation(INVOCATION_ID);
+
+ handlerDone.get();
+ assertEquals(null, handlerError.get());
+ verify(exchange).close();
+ // The handler must NOT have written any error response - the gRPC side owns the body.
+ verify(exchange, never()).sendResponseHeaders(anyInt(), anyLong());
+ }
+
+ @Test
+ public void respondsWith500WhenInvocationFails() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpProxyHandler handler = new HttpProxyHandler(coordinator);
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ HttpExchange exchange = mock(HttpExchange.class);
+ Headers requestHeaders = new Headers();
+ requestHeaders.add(HttpProxyHandler.INVOCATION_ID_HEADER, INVOCATION_ID);
+ when(exchange.getRequestHeaders()).thenReturn(requestHeaders);
+ when(exchange.getResponseHeaders()).thenReturn(new Headers());
+ when(exchange.getResponseBody()).thenReturn(captured);
+
+ CompletableFuture handlerDone = CompletableFuture.runAsync(() -> {
+ try {
+ handler.handle(exchange);
+ } catch (Exception ignored) {
+ }
+ });
+
+ long deadline = System.currentTimeMillis() + 1000;
+ while (coordinator.activeInvocationCount() == 0 && System.currentTimeMillis() < deadline) {
+ Thread.sleep(10);
+ }
+ coordinator.failInvocation(INVOCATION_ID, new RuntimeException("user fn crashed"));
+
+ handlerDone.get();
+ verify(exchange).sendResponseHeaders(500, captured.size());
+ verify(exchange).close();
+ assertTrue(new String(captured.toByteArray()).contains("user fn crashed"));
+ }
+
+ @Test
+ public void duplicateRegistrationReturns409() throws Exception {
+ HttpInvocationCoordinator coordinator = new HttpInvocationCoordinator();
+ HttpProxyHandler handler = new HttpProxyHandler(coordinator);
+ // Pre-register HTTP arrival to force a duplicate on the next handle() call.
+ HttpExchange first = mock(HttpExchange.class);
+ coordinator.registerHttpArrival(INVOCATION_ID, first);
+
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ HttpExchange second = mock(HttpExchange.class);
+ Headers headers = new Headers();
+ headers.add(HttpProxyHandler.INVOCATION_ID_HEADER, INVOCATION_ID);
+ when(second.getRequestHeaders()).thenReturn(headers);
+ when(second.getResponseHeaders()).thenReturn(new Headers());
+ when(second.getResponseBody()).thenReturn(captured);
+
+ handler.handle(second);
+
+ verify(second).sendResponseHeaders(409, captured.size());
+ verify(second).close();
+ }
+}
From 0ada8b56ac41eb69725a67eb253ebdbd5d097ffc Mon Sep 17 00:00:00 2001
From: Ahmed Muhsin
Date: Wed, 3 Jun 2026 14:40:14 -0500
Subject: [PATCH 15/22] Route HTTP-triggered invocations through embedded proxy
server
Wires HttpProxyServer and HttpInvocationCoordinator into the worker
lifecycle. When the FUNCTIONS_JAVA_DISABLE_HTTP_PROXY environment
variable is not set to true:
- JavaWorkerClient constructs a single HttpProxyServer and a single
HttpInvocationCoordinator, shared across all handler instances.
- WorkerInitRequestHandler starts the proxy server during worker init,
advertises HttpUri = "http://127.0.0.1:" and
RequiresRouteParameters = "true" so the host forwards HTTP traffic
for HTTP-triggered functions directly to the worker.
- InvocationRequestHandler detects requests that contain an HTTP input
binding, rendezvous-es with the matching HttpExchange via the
coordinator, folds the request body into the gRPC InvocationRequest,
invokes the user function, writes the RpcHttp response back to the
exchange, and releases the slot. Failures fail the slot so the HTTP
handler returns 500.
- Non-HTTP invocations (queue, timer, etc.) bypass the coordinator and
execute on the existing gRPC-only path.
- JavaWorkerClient.close() stops the proxy server before tearing down
the gRPC peer so in-flight HTTP handlers drain cleanly.
Fixes #781: requests sent with Transfer-Encoding: chunked previously
arrived at the worker with an empty body because of a null-comparison
bug in the host's RpcHttp content-length handling. Moving body delivery
to HTTP (matching Go / Python / .NET isolated workers) sidesteps that
host bug entirely.
The escape hatch FUNCTIONS_JAVA_DISABLE_HTTP_PROXY=true restores the
old behavior if anything regresses.
Test verification: full test suite (mvn -pl . test) passes 111/111
tests including 35 new tests across HttpProxyServer, coordinator,
handler, and body bridge.
---
.../azure/functions/worker/Constants.java | 7 ++
.../functions/worker/JavaWorkerClient.java | 31 ++++++-
.../handler/InvocationRequestHandler.java | 85 ++++++++++++++++++-
.../handler/WorkerInitRequestHandler.java | 33 +++++++
4 files changed, 150 insertions(+), 6 deletions(-)
diff --git a/src/main/java/com/microsoft/azure/functions/worker/Constants.java b/src/main/java/com/microsoft/azure/functions/worker/Constants.java
index 87b28344..2369b35e 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/Constants.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/Constants.java
@@ -18,4 +18,11 @@ private Constants(){}
public static final String JAVA_ENABLE_OPENTELEMETRY = "JAVA_ENABLE_OPENTELEMETRY";
public static final String JAVA_APPLICATIONINSIGHTS_ENABLE_TELEMETRY = "JAVA_APPLICATIONINSIGHTS_ENABLE_TELEMETRY";
public static final String JAVA_ENABLE_SDK_TYPES = "JAVA_ENABLE_SDK_TYPES";
+ /**
+ * If set to "true" (case-insensitive), the worker will NOT start the
+ * embedded HTTP proxy server and will NOT advertise the {@code HttpUri}
+ * capability. Useful as an escape hatch if the proxy path causes problems.
+ * Default: unset (proxy enabled).
+ */
+ public static final String FUNCTIONS_JAVA_DISABLE_HTTP_PROXY = "FUNCTIONS_JAVA_DISABLE_HTTP_PROXY";
}
diff --git a/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java b/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
index 478102b2..fca6bf77 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/JavaWorkerClient.java
@@ -12,6 +12,9 @@
import com.microsoft.azure.functions.worker.broker.*;
import com.microsoft.azure.functions.worker.handler.*;
+import com.microsoft.azure.functions.worker.http.HttpInvocationCoordinator;
+import com.microsoft.azure.functions.worker.http.HttpProxyServer;
+import com.microsoft.azure.functions.worker.http.ProxyConfig;
import com.microsoft.azure.functions.worker.reflect.*;
import com.microsoft.azure.functions.rpc.messages.*;
@@ -36,19 +39,28 @@ public JavaWorkerClient(IApplication app) {
this.peer = new AtomicReference<>(null);
this.handlerSuppliers = new HashMap<>();
this.classPathProvider = new FactoryClassLoader().createClassLoaderProvider();
-
+ this.httpInvocationCoordinator = new HttpInvocationCoordinator();
+ this.httpProxyServer = httpProxyEnabled() ? new HttpProxyServer(ProxyConfig.defaults()) : null;
+
this.addHandlers();
}
+ private static boolean httpProxyEnabled() {
+ String value = System.getenv(Constants.FUNCTIONS_JAVA_DISABLE_HTTP_PROXY);
+ return !Boolean.parseBoolean(value);
+ }
+
@PostConstruct
private void addHandlers() {
JavaFunctionBroker broker = new JavaFunctionBroker(classPathProvider);
-
- this.handlerSuppliers.put(StreamingMessage.ContentCase.WORKER_INIT_REQUEST, () -> new WorkerInitRequestHandler(broker));
+
+ this.handlerSuppliers.put(StreamingMessage.ContentCase.WORKER_INIT_REQUEST,
+ () -> new WorkerInitRequestHandler(broker, this.httpProxyServer, this.httpInvocationCoordinator));
this.handlerSuppliers.put(StreamingMessage.ContentCase.WORKER_WARMUP_REQUEST, WorkerWarmupHandler::new);
this.handlerSuppliers.put(StreamingMessage.ContentCase.FUNCTION_ENVIRONMENT_RELOAD_REQUEST, () -> new FunctionEnvironmentReloadRequestHandler(broker));
this.handlerSuppliers.put(StreamingMessage.ContentCase.FUNCTION_LOAD_REQUEST, () -> new FunctionLoadRequestHandler(broker));
- this.handlerSuppliers.put(StreamingMessage.ContentCase.INVOCATION_REQUEST, () -> new InvocationRequestHandler(broker));
+ this.handlerSuppliers.put(StreamingMessage.ContentCase.INVOCATION_REQUEST,
+ () -> new InvocationRequestHandler(broker, this.httpInvocationCoordinator));
this.handlerSuppliers.put(StreamingMessage.ContentCase.WORKER_STATUS_REQUEST, WorkerStatusRequestHandler::new);
this.handlerSuppliers.put(StreamingMessage.ContentCase.WORKER_TERMINATE, WorkerTerminateRequestHandler::new);
}
@@ -68,6 +80,15 @@ void logToHost(LogRecord record, String invocationId) {
@Override
public void close() throws Exception {
+ // Stop accepting HTTP proxy requests before tearing down the gRPC peer
+ // so in-flight HTTP handlers can drain on completion futures cleanly.
+ if (this.httpProxyServer != null) {
+ try {
+ this.httpProxyServer.close();
+ } catch (Exception ex) {
+ logger.log(Level.WARNING, "Failed to close HTTP proxy server cleanly", ex);
+ }
+ }
this.peer.get().close();
this.peer.set(null);
this.channel.shutdownNow();
@@ -143,6 +164,8 @@ private synchronized void send(String requestId, MessageHandler, ?> marshaller
private final AtomicReference peer;
private final Map>> handlerSuppliers;
private final ClassLoaderProvider classPathProvider;
+ private final HttpInvocationCoordinator httpInvocationCoordinator;
+ private final HttpProxyServer httpProxyServer;
/**
* @param functionsUri Host endpoint URI, or null for legacy startup args that only provide host and port.
diff --git a/src/main/java/com/microsoft/azure/functions/worker/handler/InvocationRequestHandler.java b/src/main/java/com/microsoft/azure/functions/worker/handler/InvocationRequestHandler.java
index 434a5738..bf4a91b0 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/handler/InvocationRequestHandler.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/handler/InvocationRequestHandler.java
@@ -1,20 +1,30 @@
package com.microsoft.azure.functions.worker.handler;
import java.util.*;
+import java.util.concurrent.ExecutionException;
import java.util.logging.*;
import com.microsoft.azure.functions.worker.*;
import com.microsoft.azure.functions.worker.broker.*;
+import com.microsoft.azure.functions.worker.http.HttpBodyBridge;
+import com.microsoft.azure.functions.worker.http.HttpInvocationCoordinator;
+import com.microsoft.azure.functions.worker.http.HttpInvocationSlot;
import com.microsoft.azure.functions.rpc.messages.*;
+import com.sun.net.httpserver.HttpExchange;
public class InvocationRequestHandler extends MessageHandler {
public InvocationRequestHandler(JavaFunctionBroker broker) {
+ this(broker, null);
+ }
+
+ public InvocationRequestHandler(JavaFunctionBroker broker, HttpInvocationCoordinator httpInvocationCoordinator) {
super(StreamingMessage::getInvocationRequest,
InvocationResponse::newBuilder,
InvocationResponse.Builder::setResult,
StreamingMessage.Builder::setInvocationResponse);
assert broker != null;
this.broker = broker;
+ this.httpInvocationCoordinator = httpInvocationCoordinator;
this.invocationLogger = super.getLogger();
}
@@ -29,7 +39,15 @@ String execute(InvocationRequest request, InvocationResponse.Builder response) t
this.invocationLogger = WorkerLogManager.getInvocationLogger(invocationId);
response.setInvocationId(invocationId);
-
+
+ // For HTTP-triggered invocations dispatched via the HttpUri capability, the
+ // gRPC request carries trigger metadata but an empty body. We rendezvous
+ // with the HTTP arrival via the coordinator, fold the body bytes back into
+ // the request, and write the response to the held HttpExchange.
+ if (httpInvocationCoordinator != null && hasHttpInput(request)) {
+ return executeProxiedHttp(request, response, functionId, invocationId);
+ }
+
List outputBindings = new ArrayList<>();
this.broker.invokeMethod(functionId, request, outputBindings).ifPresent(response::setReturnValue);
response.addAllOutputData(outputBindings);
@@ -38,6 +56,69 @@ String execute(InvocationRequest request, InvocationResponse.Builder response) t
this.broker.getMethodName(functionId).orElse("UNKNOWN"), invocationId);
}
- private JavaFunctionBroker broker;
+ private String executeProxiedHttp(InvocationRequest request,
+ InvocationResponse.Builder response,
+ String functionId,
+ String invocationId) throws Exception {
+ HttpInvocationSlot slot = httpInvocationCoordinator.registerGrpcArrival(request);
+ HttpExchange exchange = null;
+ try {
+ try {
+ exchange = slot.httpArrival().get();
+ } catch (ExecutionException ex) {
+ Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
+ throw asException(cause);
+ }
+ InvocationRequest enriched = HttpBodyBridge.enrichRequestWithBody(request, exchange);
+ List outputBindings = new ArrayList<>();
+ this.broker.invokeMethod(functionId, enriched, outputBindings).ifPresent(response::setReturnValue);
+ response.addAllOutputData(outputBindings);
+ RpcHttp httpResponse = extractHttpResponse(response, outputBindings);
+ HttpBodyBridge.writeRpcHttpResponse(exchange, httpResponse);
+ httpInvocationCoordinator.releaseInvocation(invocationId);
+ return String.format("Function \"%s\" (Id: %s) invoked by Java Worker (HTTP proxy)",
+ this.broker.getMethodName(functionId).orElse("UNKNOWN"), invocationId);
+ } catch (Throwable t) {
+ httpInvocationCoordinator.failInvocation(invocationId, t);
+ throw asException(t);
+ }
+ }
+
+ private static boolean hasHttpInput(InvocationRequest request) {
+ for (ParameterBinding binding : request.getInputDataList()) {
+ if (binding.getData().hasHttp()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static RpcHttp extractHttpResponse(InvocationResponse.Builder response,
+ List outputBindings) {
+ if (response.hasReturnValue() && response.getReturnValue().hasHttp()) {
+ return response.getReturnValue().getHttp();
+ }
+ for (ParameterBinding binding : outputBindings) {
+ if (binding.getData().hasHttp()) {
+ return binding.getData().getHttp();
+ }
+ }
+ // No HTTP response binding produced; respond with an empty 200 so the
+ // host doesn't see a hung connection.
+ return RpcHttp.newBuilder().setStatusCode("200").build();
+ }
+
+ private static Exception asException(Throwable t) {
+ if (t instanceof Exception) {
+ return (Exception) t;
+ }
+ if (t instanceof Error) {
+ throw (Error) t;
+ }
+ return new RuntimeException(t);
+ }
+
+ private final JavaFunctionBroker broker;
+ private final HttpInvocationCoordinator httpInvocationCoordinator;
private Logger invocationLogger;
}
diff --git a/src/main/java/com/microsoft/azure/functions/worker/handler/WorkerInitRequestHandler.java b/src/main/java/com/microsoft/azure/functions/worker/handler/WorkerInitRequestHandler.java
index 618833a4..6efff729 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/handler/WorkerInitRequestHandler.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/handler/WorkerInitRequestHandler.java
@@ -3,7 +3,11 @@
import com.microsoft.azure.functions.worker.*;
import com.microsoft.azure.functions.rpc.messages.*;
import com.microsoft.azure.functions.worker.broker.JavaFunctionBroker;
+import com.microsoft.azure.functions.worker.http.HttpInvocationCoordinator;
+import com.microsoft.azure.functions.worker.http.HttpProxyHandler;
+import com.microsoft.azure.functions.worker.http.HttpProxyServer;
+import java.io.IOException;
import java.util.logging.Level;
import static com.microsoft.azure.functions.worker.Constants.JAVA_APPLICATIONINSIGHTS_ENABLE_TELEMETRY;
@@ -11,11 +15,19 @@
public class WorkerInitRequestHandler extends MessageHandler {
public WorkerInitRequestHandler(JavaFunctionBroker broker) {
+ this(broker, null, null);
+ }
+
+ public WorkerInitRequestHandler(JavaFunctionBroker broker,
+ HttpProxyServer httpProxyServer,
+ HttpInvocationCoordinator httpInvocationCoordinator) {
super(StreamingMessage::getWorkerInitRequest,
WorkerInitResponse::newBuilder,
WorkerInitResponse.Builder::setResult,
StreamingMessage.Builder::setWorkerInitResponse);
this.broker = broker;
+ this.httpProxyServer = httpProxyServer;
+ this.httpInvocationCoordinator = httpInvocationCoordinator;
}
@Override
@@ -30,6 +42,8 @@ String execute(WorkerInitRequest request, WorkerInitResponse.Builder response) {
response.putCapabilities("HandlesWorkerTerminateMessage", "HandlesWorkerTerminateMessage");
response.putCapabilities("HandlesWorkerWarmupMessage", "HandlesWorkerWarmupMessage");
+ advertiseHttpProxy(response);
+
if (Boolean.parseBoolean(System.getenv(JAVA_ENABLE_OPENTELEMETRY)) ||
Boolean.parseBoolean(System.getenv(JAVA_APPLICATIONINSIGHTS_ENABLE_TELEMETRY))) {
response.putCapabilities("WorkerOpenTelemetryEnabled", "true");
@@ -41,6 +55,23 @@ String execute(WorkerInitRequest request, WorkerInitResponse.Builder response) {
return "Worker initialized";
}
+ private void advertiseHttpProxy(WorkerInitResponse.Builder response) {
+ if (httpProxyServer == null || httpInvocationCoordinator == null) {
+ return;
+ }
+ try {
+ String uri = httpProxyServer.start(new HttpProxyHandler(httpInvocationCoordinator));
+ response.putCapabilities("HttpUri", uri);
+ response.putCapabilities("RequiresRouteParameters", "true");
+ WorkerLogManager.getSystemLogger().log(Level.INFO,
+ "Java worker HTTP proxy listening on " + uri);
+ } catch (IOException ex) {
+ // Fall back to gRPC-only path: simply do not advertise HttpUri.
+ WorkerLogManager.getSystemLogger().log(Level.WARNING,
+ "Failed to start HTTP proxy server; continuing without HttpUri capability", ex);
+ }
+ }
+
private WorkerMetadata.Builder composeWorkerMetadata(){
WorkerMetadata.Builder workerMetadataBuilder = WorkerMetadata.newBuilder();
workerMetadataBuilder.setRuntimeName("java");
@@ -51,4 +82,6 @@ private WorkerMetadata.Builder composeWorkerMetadata(){
}
private final JavaFunctionBroker broker;
+ private final HttpProxyServer httpProxyServer;
+ private final HttpInvocationCoordinator httpInvocationCoordinator;
}
From 4794ffaad4b4353f076cdfdd905649eef8ac2226 Mon Sep 17 00:00:00 2001
From: Ahmed Muhsin
Date: Wed, 3 Jun 2026 17:12:39 -0500
Subject: [PATCH 16/22] Add streaming output support via
HttpResponseMessage.Builder.bodyStream()
When a function returns an HttpResponseMessage whose body is an
InputStream or HttpResponseMessage.IOConsumer, the HTTP
proxy dispatch path now writes the body directly to the underlying
HttpExchange response stream using chunked transfer-encoding instead
of buffering the entire payload through a protobuf TypedData. This
enables Server-Sent Events, large file downloads, and other long-lived
streaming responses without first materializing the whole body in
memory.
Design highlights:
- RpcHttpDataTarget.toRpcHttpData detects streaming bodies and skips
the RpcUnspecifiedDataTarget serialization step, leaving the RpcHttp
body field unset so the status + headers envelope still flows through
the existing pipeline unchanged.
- BindingDataStore exposes getHttpResponseRawBody() so the dispatch
layer can recover the raw (unserialized) body without ripping apart
the protobuf reply.
- JavaFunctionBroker.invokeMethodForHttpProxy is a new method overload
(not a modification of invokeMethod) that returns an
HttpInvocationOutcome containing both the protobuf reply and the raw
body. The original invokeMethod is left untouched for backward
compatibility with the existing gRPC dispatch path.
- HttpBodyBridge.writeStreamingResponse(InputStream) and
writeStreamingResponse(IOConsumer) handle the actual streaming
write; both use sendResponseHeaders(status, 0) to select chunked
transfer-encoding (or close-delimited for HTTP/1.0 clients) and
close/flush the streams reliably in try-with-resources.
- InvocationRequestHandler.executeProxiedHttp dispatches based on the
raw body type, falling back to the existing buffered writer when the
body is not a streaming type.
The pom.xml is bumped to consume azure-functions-java-core-library
1.4.0-SNAPSHOT, which adds the bodyStream() overloads and the
IOConsumer functional interface.
Tests added: 5 new HttpBodyBridge streaming tests, 8 new
RpcHttpDataTarget tests, 6 new BindingDataStore tests. Total worker
test suite: 130 passing (was 111).
---
pom.xml | 2 +-
.../worker/binding/BindingDataStore.java | 25 +++++
.../worker/binding/RpcHttpDataTarget.java | 25 ++++-
.../worker/broker/JavaFunctionBroker.java | 55 ++++++++++
.../handler/InvocationRequestHandler.java | 29 ++++-
.../functions/worker/http/HttpBodyBridge.java | 52 +++++++++
.../worker/binding/BindingDataStoreTest.java | 96 +++++++++++++++++
.../worker/binding/RpcHttpDataTargetTest.java | 91 ++++++++++++++++
.../worker/http/HttpBodyBridgeTest.java | 100 ++++++++++++++++++
9 files changed, 468 insertions(+), 7 deletions(-)
create mode 100644 src/test/java/com/microsoft/azure/functions/worker/binding/BindingDataStoreTest.java
create mode 100644 src/test/java/com/microsoft/azure/functions/worker/binding/RpcHttpDataTargetTest.java
diff --git a/pom.xml b/pom.xml
index cf3253da..178f9ef4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,7 +15,7 @@
UTF-81.81.8
- 1.3.0
+ 1.4.0-SNAPSHOT1.1.01.0.22.2.0
diff --git a/src/main/java/com/microsoft/azure/functions/worker/binding/BindingDataStore.java b/src/main/java/com/microsoft/azure/functions/worker/binding/BindingDataStore.java
index 1ccd91c9..300006b7 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/binding/BindingDataStore.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/binding/BindingDataStore.java
@@ -125,6 +125,31 @@ public Optional getDataTargetTypedValue(String name) throws Exception
});
}
+ /**
+ * Returns the raw, unserialized response body of the HTTP output target, or
+ * {@code null} if no HTTP output target is registered or its body is null.
+ *
+ *
Used by the HTTP proxy path to recover streaming bodies
+ * ({@link java.io.InputStream}, {@code HttpResponseMessage.IOConsumer}) that
+ * cannot be represented in a protobuf {@code TypedData} and must instead be
+ * written directly to the {@code HttpExchange} response stream.
+ */
+ public Object getHttpResponseRawBody() {
+ if (this.promotedTargets == null) {
+ return null;
+ }
+ Map promoted = this.targets.get(this.promotedTargets);
+ if (promoted == null) {
+ return null;
+ }
+ for (DataTarget target : promoted.values()) {
+ if (target instanceof RpcHttpDataTarget) {
+ return ((RpcHttpDataTarget) target).getBody();
+ }
+ }
+ return null;
+ }
+
public Optional getOrAddDataTarget(UUID outputId, String name, Type target, boolean ignoreDefinition) {
DataTarget output = null;
if (this.isDataTargetValid(name, target)) {
diff --git a/src/main/java/com/microsoft/azure/functions/worker/binding/RpcHttpDataTarget.java b/src/main/java/com/microsoft/azure/functions/worker/binding/RpcHttpDataTarget.java
index 16b45f19..17bd7e94 100644
--- a/src/main/java/com/microsoft/azure/functions/worker/binding/RpcHttpDataTarget.java
+++ b/src/main/java/com/microsoft/azure/functions/worker/binding/RpcHttpDataTarget.java
@@ -1,9 +1,11 @@
package com.microsoft.azure.functions.worker.binding;
+import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import com.microsoft.azure.functions.HttpResponseMessage;
+import com.microsoft.azure.functions.HttpResponseMessage.IOConsumer;
import com.microsoft.azure.functions.HttpStatus;
import com.microsoft.azure.functions.HttpStatusType;
import com.microsoft.azure.functions.rpc.messages.RpcHttp;
@@ -37,14 +39,31 @@ public static TypedData.Builder toRpcHttpData(RpcHttpDataTarget response) throws
if (response != null) {
RpcHttp.Builder httpBuilder = RpcHttp.newBuilder().setStatusCode(Integer.toString(response.getStatusCode()));
response.headers.forEach(httpBuilder::putHeaders);
- RpcUnspecifiedDataTarget bodyTarget = new RpcUnspecifiedDataTarget();
- bodyTarget.setValue(response.getBody());
- bodyTarget.computeFromValue().ifPresent(httpBuilder::setBody);
+ Object body = response.getBody();
+ if (isStreamingBody(body)) {
+ // Streaming bodies (InputStream / IOConsumer) cannot be serialized into a
+ // protobuf TypedData; they are written directly to the HTTP response by the
+ // HTTP proxy path. Leave the RpcHttp body unset so downstream code sees an
+ // empty envelope but can still read status + headers.
+ } else {
+ RpcUnspecifiedDataTarget bodyTarget = new RpcUnspecifiedDataTarget();
+ bodyTarget.setValue(body);
+ bodyTarget.computeFromValue().ifPresent(httpBuilder::setBody);
+ }
dataBuilder.setHttp(httpBuilder);
}
return dataBuilder;
}
+ /**
+ * Returns {@code true} if {@code body} is a streaming response body type that
+ * should bypass protobuf serialization and be written directly to the HTTP
+ * response by the worker's HTTP proxy.
+ */
+ static boolean isStreamingBody(Object body) {
+ return body instanceof InputStream || body instanceof IOConsumer;
+ }
+
private static final DataOperations