From dc102d1cd302e223c075cbcf20efc9ddd3459307 Mon Sep 17 00:00:00 2001 From: akenra <37288280+akenra@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:39:01 +0500 Subject: [PATCH] fix(gcp): register nested URL protocol handler in GcfJarLauncher Handlers.register() only sets java.protocol.handler.pkgs, which the JVM resolves through the bootstrap/system classloaders. The functions-framework loads the deployed function JAR in a child URLClassLoader, so nested.Handler inside the JAR is never visible there and URLs still fail with 'unknown protocol: nested'. Install a URLStreamHandlerFactory that provides the handler directly and rework the test to load the launcher from a fat JAR through a child classloader, asserting the launcher constructs successfully. Fixes gh-1336 Signed-off-by: akenra <37288280+akenra@users.noreply.github.com> --- .../function/adapter/gcp/GcfJarLauncher.java | 41 +++- .../adapter/gcp/GcfJarLauncherTests.java | 201 ++++++++++++++++++ .../src/test/resources/ProtocolCheck.java | 54 +++++ .../test/resources/StubFunctionInvoker.java | 14 ++ 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncherTests.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/ProtocolCheck.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/StubFunctionInvoker.java diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java index 53e22a6dd..d28e0e52c 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java @@ -16,6 +16,10 @@ package org.springframework.cloud.function.adapter.gcp; +import java.net.URL; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; + import com.google.cloud.functions.Context; import com.google.cloud.functions.HttpFunction; import com.google.cloud.functions.HttpRequest; @@ -23,6 +27,8 @@ import com.google.cloud.functions.RawBackgroundFunction; import org.springframework.boot.loader.launch.JarLauncher; +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.net.protocol.nested.Handler; /** * The launcher class written at the top-level of the output JAR to be deployed to @@ -30,15 +36,19 @@ * * @author Ray Tsang * @author Daniel Zou + * @author Roman Akentev */ public class GcfJarLauncher extends JarLauncher implements HttpFunction, RawBackgroundFunction { + private static final URLStreamHandlerFactory NESTED_URL_STREAM_HANDLER_FACTORY = new NestedUrlStreamHandlerFactory(); + private final ClassLoader loader; private final Object delegate; public GcfJarLauncher() throws Exception { - //JarFile.registerUrlProtocolHandler(); + Handlers.register(); + registerNestedUrlStreamHandlerFactory(); this.loader = createClassLoader(getClassPathUrls()); @@ -47,6 +57,25 @@ public GcfJarLauncher() throws Exception { this.delegate = clazz.getConstructor().newInstance(); } + /** + * Install a {@link URLStreamHandlerFactory} that provides the {@code nested:} + * handler directly, without relying on the {@code java.protocol.handler.pkgs} + * property. That property is only consulted by the JVM through the bootstrap + * and system classloaders, so it cannot find the handler when the loader + * classes are only visible to the classloader of the deployed fat JAR (e.g. + * when the Google Cloud Functions framework loads the JAR in a child + * {@code URLClassLoader}). + */ + private void registerNestedUrlStreamHandlerFactory() { + try { + URL.setURLStreamHandlerFactory(NESTED_URL_STREAM_HANDLER_FACTORY); + } + catch (Error error) { + // A factory is already installed. Handlers.register() above may still be + // sufficient when the loader classes are on the system classpath. + } + } + @Override public void service(HttpRequest httpRequest, HttpResponse httpResponse) throws Exception { Thread.currentThread().setContextClassLoader(this.loader); @@ -58,5 +87,15 @@ public void accept(String json, Context context) throws Exception { Thread.currentThread().setContextClassLoader(this.loader); ((RawBackgroundFunction) delegate).accept(json, context); } + + private static final class NestedUrlStreamHandlerFactory implements URLStreamHandlerFactory { + + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + return ("nested".equals(protocol)) ? new Handler() : null; + } + + } + } diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncherTests.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncherTests.java new file mode 100644 index 000000000..0d88a1839 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncherTests.java @@ -0,0 +1,201 @@ +/* + * Copyright 2018-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.function.adapter.gcp; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Enumeration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import com.google.cloud.functions.HttpFunction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.launch.Launcher; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests verifying that {@link GcfJarLauncher} registers the {@code nested:} + * URL protocol handler before creating its classloader (GH-1336). + *
+ * The test builds a Spring Boot fat JAR, then forks a subprocess that loads
+ * {@code GcfJarLauncher} from that JAR through a child {@code URLClassLoader}
+ * - mirroring how the Google Cloud Functions framework loads the deployed
+ * function JAR. In that setup spring-boot-loader is only visible inside the
+ * fat JAR, so the launcher must install a {@code nested:} handler that the
+ * JVM can resolve without the loader classes being on the system classpath.
+ * The subprocess is also checked to fail on the {@code nested:} protocol
+ * before the launcher is constructed, proving the handler is not already
+ * registered by anything else.
+ *
+ * @author Roman Akentev
+ * @see GH-1336
+ */
+public class GcfJarLauncherTests {
+
+ public static final String PROTOCOL_ALREADY_REGISTERED = "PROTOCOL_ALREADY_REGISTERED";
+
+ public static final String PROTOCOL_NOT_REGISTERED = "PROTOCOL_NOT_REGISTERED";
+
+ public static final String GCF_JAR_LAUNCHER_SUCCEEDED = "GCF_JAR_LAUNCHER_SUCCEEDED";
+
+ public static final String GCF_JAR_LAUNCHER_FAILED = "GCF_JAR_LAUNCHER_FAILED";
+
+ public static final String PROTOCOL_NOW_REGISTERED = "PROTOCOL_NOW_REGISTERED";
+
+ public static final String PROTOCOL_STILL_NOT_REGISTERED = "PROTOCOL_STILL_NOT_REGISTERED";
+
+ private static final String GCF_LAUNCHER_CLASS_NAME = "org.springframework.cloud.function.adapter.gcp.GcfJarLauncher";
+
+ private static final String FUNCTION_INVOKER_CLASS_NAME = "org.springframework.cloud.function.adapter.gcp.FunctionInvoker";
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ public void nestedProtocolIsRegisteredByGcfJarLauncher() throws Exception {
+ String javaHome = System.getProperty("java.home");
+ Path classesDir = tempDir.resolve("classes");
+ compile(javaHome, classesDir);
+
+ Path functionJar = createFunctionJar(classesDir);
+ Path frameworkApiJar = codeSource(HttpFunction.class);
+
+ Process process = new ProcessBuilder(Path.of(javaHome, "bin", "java").toString(), "-cp",
+ String.join(File.pathSeparator, functionJar.toString(), frameworkApiJar.toString(),
+ classesDir.toString()),
+ "ProtocolCheck", functionJar.toString())
+ .redirectErrorStream(true)
+ .start();
+ boolean finished = process.waitFor(2, TimeUnit.MINUTES);
+ String output = new BufferedReader(new InputStreamReader(process.getInputStream())).lines()
+ .collect(Collectors.joining("\n"));
+ if (!finished) {
+ process.destroyForcibly();
+ }
+
+ assertThat(finished).as("ProtocolCheck timed out:\n" + output).isTrue();
+ assertThat(process.exitValue()).as("ProtocolCheck exit code:\n" + output).isZero();
+ assertThat(output).as("ProtocolCheck output")
+ .contains(PROTOCOL_NOT_REGISTERED + ": unknown protocol: nested")
+ .contains(GCF_JAR_LAUNCHER_SUCCEEDED)
+ .contains(PROTOCOL_NOW_REGISTERED);
+ }
+
+ private void compile(String javaHome, Path classesDir) throws Exception {
+ Files.createDirectories(classesDir);
+ Path protocolCheck = tempDir.resolve("ProtocolCheck.java");
+ writeResource(protocolCheck, "/ProtocolCheck.java");
+ Path stubInvoker = tempDir.resolve("FunctionInvoker.java");
+ writeResource(stubInvoker, "/StubFunctionInvoker.java");
+ Process compile = new ProcessBuilder(Path.of(javaHome, "bin", "javac").toString(), "-d",
+ classesDir.toString(), protocolCheck.toString(), stubInvoker.toString())
+ .redirectErrorStream(true)
+ .start();
+ String compileOutput = new BufferedReader(new InputStreamReader(compile.getInputStream())).lines()
+ .collect(Collectors.joining("\n"));
+ boolean finished = compile.waitFor(1, TimeUnit.MINUTES);
+ if (!finished) {
+ compile.destroyForcibly();
+ }
+ assertThat(finished).as("javac timed out").isTrue();
+ assertThat(compile.exitValue()).as("compilation failed:\n" + compileOutput).isZero();
+ }
+
+ private Path createFunctionJar(Path classesDir) throws Exception {
+ Path functionJar = tempDir.resolve("function.jar");
+ try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(functionJar))) {
+ writeEntry(zip, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes(StandardCharsets.UTF_8));
+ Path loaderJar = codeSource(Launcher.class);
+ try (JarFile loader = new JarFile(loaderJar.toFile())) {
+ Enumeration