Skip to content

Commit dc102d1

Browse files
committed
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>
1 parent ef533f3 commit dc102d1

4 files changed

Lines changed: 309 additions & 1 deletion

File tree

‎spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,39 @@
1616

1717
package org.springframework.cloud.function.adapter.gcp;
1818

19+
import java.net.URL;
20+
import java.net.URLStreamHandler;
21+
import java.net.URLStreamHandlerFactory;
22+
1923
import com.google.cloud.functions.Context;
2024
import com.google.cloud.functions.HttpFunction;
2125
import com.google.cloud.functions.HttpRequest;
2226
import com.google.cloud.functions.HttpResponse;
2327
import com.google.cloud.functions.RawBackgroundFunction;
2428

2529
import org.springframework.boot.loader.launch.JarLauncher;
30+
import org.springframework.boot.loader.net.protocol.Handlers;
31+
import org.springframework.boot.loader.net.protocol.nested.Handler;
2632

2733
/**
2834
* The launcher class written at the top-level of the output JAR to be deployed to
2935
* Google Cloud Functions. This is the entry point to the function when run from JAR.
3036
*
3137
* @author Ray Tsang
3238
* @author Daniel Zou
39+
* @author Roman Akentev
3340
*/
3441
public class GcfJarLauncher extends JarLauncher implements HttpFunction, RawBackgroundFunction {
3542

43+
private static final URLStreamHandlerFactory NESTED_URL_STREAM_HANDLER_FACTORY = new NestedUrlStreamHandlerFactory();
44+
3645
private final ClassLoader loader;
3746

3847
private final Object delegate;
3948

4049
public GcfJarLauncher() throws Exception {
41-
//JarFile.registerUrlProtocolHandler();
50+
Handlers.register();
51+
registerNestedUrlStreamHandlerFactory();
4252

4353
this.loader = createClassLoader(getClassPathUrls());
4454

@@ -47,6 +57,25 @@ public GcfJarLauncher() throws Exception {
4757
this.delegate = clazz.getConstructor().newInstance();
4858
}
4959

60+
/**
61+
* Install a {@link URLStreamHandlerFactory} that provides the {@code nested:}
62+
* handler directly, without relying on the {@code java.protocol.handler.pkgs}
63+
* property. That property is only consulted by the JVM through the bootstrap
64+
* and system classloaders, so it cannot find the handler when the loader
65+
* classes are only visible to the classloader of the deployed fat JAR (e.g.
66+
* when the Google Cloud Functions framework loads the JAR in a child
67+
* {@code URLClassLoader}).
68+
*/
69+
private void registerNestedUrlStreamHandlerFactory() {
70+
try {
71+
URL.setURLStreamHandlerFactory(NESTED_URL_STREAM_HANDLER_FACTORY);
72+
}
73+
catch (Error error) {
74+
// A factory is already installed. Handlers.register() above may still be
75+
// sufficient when the loader classes are on the system classpath.
76+
}
77+
}
78+
5079
@Override
5180
public void service(HttpRequest httpRequest, HttpResponse httpResponse) throws Exception {
5281
Thread.currentThread().setContextClassLoader(this.loader);
@@ -58,5 +87,15 @@ public void accept(String json, Context context) throws Exception {
5887
Thread.currentThread().setContextClassLoader(this.loader);
5988
((RawBackgroundFunction) delegate).accept(json, context);
6089
}
90+
91+
private static final class NestedUrlStreamHandlerFactory implements URLStreamHandlerFactory {
92+
93+
@Override
94+
public URLStreamHandler createURLStreamHandler(String protocol) {
95+
return ("nested".equals(protocol)) ? new Handler() : null;
96+
}
97+
98+
}
99+
61100
}
62101

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/*
2+
* Copyright 2018-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.function.adapter.gcp;
18+
19+
import java.io.BufferedReader;
20+
import java.io.ByteArrayOutputStream;
21+
import java.io.File;
22+
import java.io.IOException;
23+
import java.io.InputStream;
24+
import java.io.InputStreamReader;
25+
import java.nio.charset.StandardCharsets;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
28+
import java.nio.file.StandardCopyOption;
29+
import java.util.Enumeration;
30+
import java.util.Objects;
31+
import java.util.concurrent.TimeUnit;
32+
import java.util.jar.JarEntry;
33+
import java.util.jar.JarFile;
34+
import java.util.stream.Collectors;
35+
import java.util.stream.Stream;
36+
import java.util.zip.ZipEntry;
37+
import java.util.zip.ZipOutputStream;
38+
39+
import com.google.cloud.functions.HttpFunction;
40+
import org.junit.jupiter.api.Test;
41+
import org.junit.jupiter.api.io.TempDir;
42+
43+
import org.springframework.boot.loader.launch.Launcher;
44+
45+
import static org.assertj.core.api.Assertions.assertThat;
46+
47+
/**
48+
* Tests verifying that {@link GcfJarLauncher} registers the {@code nested:}
49+
* URL protocol handler before creating its classloader (GH-1336).
50+
* <p>
51+
* The test builds a Spring Boot fat JAR, then forks a subprocess that loads
52+
* {@code GcfJarLauncher} from that JAR through a child {@code URLClassLoader}
53+
* - mirroring how the Google Cloud Functions framework loads the deployed
54+
* function JAR. In that setup spring-boot-loader is only visible inside the
55+
* fat JAR, so the launcher must install a {@code nested:} handler that the
56+
* JVM can resolve without the loader classes being on the system classpath.
57+
* The subprocess is also checked to fail on the {@code nested:} protocol
58+
* before the launcher is constructed, proving the handler is not already
59+
* registered by anything else.
60+
*
61+
* @author Roman Akentev
62+
* @see <a href="https://github.com/spring-cloud/spring-cloud-function/issues/1336">GH-1336</a>
63+
*/
64+
public class GcfJarLauncherTests {
65+
66+
public static final String PROTOCOL_ALREADY_REGISTERED = "PROTOCOL_ALREADY_REGISTERED";
67+
68+
public static final String PROTOCOL_NOT_REGISTERED = "PROTOCOL_NOT_REGISTERED";
69+
70+
public static final String GCF_JAR_LAUNCHER_SUCCEEDED = "GCF_JAR_LAUNCHER_SUCCEEDED";
71+
72+
public static final String GCF_JAR_LAUNCHER_FAILED = "GCF_JAR_LAUNCHER_FAILED";
73+
74+
public static final String PROTOCOL_NOW_REGISTERED = "PROTOCOL_NOW_REGISTERED";
75+
76+
public static final String PROTOCOL_STILL_NOT_REGISTERED = "PROTOCOL_STILL_NOT_REGISTERED";
77+
78+
private static final String GCF_LAUNCHER_CLASS_NAME = "org.springframework.cloud.function.adapter.gcp.GcfJarLauncher";
79+
80+
private static final String FUNCTION_INVOKER_CLASS_NAME = "org.springframework.cloud.function.adapter.gcp.FunctionInvoker";
81+
82+
@TempDir
83+
Path tempDir;
84+
85+
@Test
86+
public void nestedProtocolIsRegisteredByGcfJarLauncher() throws Exception {
87+
String javaHome = System.getProperty("java.home");
88+
Path classesDir = tempDir.resolve("classes");
89+
compile(javaHome, classesDir);
90+
91+
Path functionJar = createFunctionJar(classesDir);
92+
Path frameworkApiJar = codeSource(HttpFunction.class);
93+
94+
Process process = new ProcessBuilder(Path.of(javaHome, "bin", "java").toString(), "-cp",
95+
String.join(File.pathSeparator, functionJar.toString(), frameworkApiJar.toString(),
96+
classesDir.toString()),
97+
"ProtocolCheck", functionJar.toString())
98+
.redirectErrorStream(true)
99+
.start();
100+
boolean finished = process.waitFor(2, TimeUnit.MINUTES);
101+
String output = new BufferedReader(new InputStreamReader(process.getInputStream())).lines()
102+
.collect(Collectors.joining("\n"));
103+
if (!finished) {
104+
process.destroyForcibly();
105+
}
106+
107+
assertThat(finished).as("ProtocolCheck timed out:\n" + output).isTrue();
108+
assertThat(process.exitValue()).as("ProtocolCheck exit code:\n" + output).isZero();
109+
assertThat(output).as("ProtocolCheck output")
110+
.contains(PROTOCOL_NOT_REGISTERED + ": unknown protocol: nested")
111+
.contains(GCF_JAR_LAUNCHER_SUCCEEDED)
112+
.contains(PROTOCOL_NOW_REGISTERED);
113+
}
114+
115+
private void compile(String javaHome, Path classesDir) throws Exception {
116+
Files.createDirectories(classesDir);
117+
Path protocolCheck = tempDir.resolve("ProtocolCheck.java");
118+
writeResource(protocolCheck, "/ProtocolCheck.java");
119+
Path stubInvoker = tempDir.resolve("FunctionInvoker.java");
120+
writeResource(stubInvoker, "/StubFunctionInvoker.java");
121+
Process compile = new ProcessBuilder(Path.of(javaHome, "bin", "javac").toString(), "-d",
122+
classesDir.toString(), protocolCheck.toString(), stubInvoker.toString())
123+
.redirectErrorStream(true)
124+
.start();
125+
String compileOutput = new BufferedReader(new InputStreamReader(compile.getInputStream())).lines()
126+
.collect(Collectors.joining("\n"));
127+
boolean finished = compile.waitFor(1, TimeUnit.MINUTES);
128+
if (!finished) {
129+
compile.destroyForcibly();
130+
}
131+
assertThat(finished).as("javac timed out").isTrue();
132+
assertThat(compile.exitValue()).as("compilation failed:\n" + compileOutput).isZero();
133+
}
134+
135+
private Path createFunctionJar(Path classesDir) throws Exception {
136+
Path functionJar = tempDir.resolve("function.jar");
137+
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(functionJar))) {
138+
writeEntry(zip, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes(StandardCharsets.UTF_8));
139+
Path loaderJar = codeSource(Launcher.class);
140+
try (JarFile loader = new JarFile(loaderJar.toFile())) {
141+
Enumeration<JarEntry> entries = loader.entries();
142+
while (entries.hasMoreElements()) {
143+
JarEntry entry = entries.nextElement();
144+
if (!entry.isDirectory() && !entry.getName().startsWith("META-INF/")) {
145+
writeEntry(zip, entry.getName(), loader.getInputStream(entry).readAllBytes());
146+
}
147+
}
148+
}
149+
writeLauncherClasses(zip);
150+
writeEntry(zip, "BOOT-INF/lib/spring-cloud-function-adapter-gcp.jar", nestedAdapterJar(classesDir));
151+
}
152+
return functionJar;
153+
}
154+
155+
private void writeLauncherClasses(ZipOutputStream zip) throws Exception {
156+
Path adapterClasses = codeSource(GcfJarLauncher.class);
157+
if (!Files.isDirectory(adapterClasses)) {
158+
throw new IllegalStateException("Adapter classes are not on the classpath as a directory: " + adapterClasses);
159+
}
160+
String launcherPrefix = GCF_LAUNCHER_CLASS_NAME.replace('.', '/');
161+
try (Stream<Path> paths = Files.walk(adapterClasses)) {
162+
for (Path path : paths.toList()) {
163+
if (!Files.isRegularFile(path)) {
164+
continue;
165+
}
166+
String entryName = adapterClasses.relativize(path).toString().replace(File.separatorChar, '/');
167+
if (entryName.startsWith(launcherPrefix) && entryName.endsWith(".class")) {
168+
writeEntry(zip, entryName, Files.readAllBytes(path));
169+
}
170+
}
171+
}
172+
}
173+
174+
private byte[] nestedAdapterJar(Path classesDir) throws IOException {
175+
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
176+
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
177+
writeEntry(zip, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes(StandardCharsets.UTF_8));
178+
Path stub = classesDir.resolve(FUNCTION_INVOKER_CLASS_NAME.replace('.', '/') + ".class");
179+
writeEntry(zip, FUNCTION_INVOKER_CLASS_NAME.replace('.', '/') + ".class", Files.readAllBytes(stub));
180+
}
181+
return bytes.toByteArray();
182+
}
183+
184+
private static void writeResource(Path target, String resource) throws IOException {
185+
try (InputStream in = Objects.requireNonNull(GcfJarLauncherTests.class.getResourceAsStream(resource),
186+
"Resource " + resource + " not found")) {
187+
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
188+
}
189+
}
190+
191+
private static Path codeSource(Class<?> type) throws Exception {
192+
return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI());
193+
}
194+
195+
private static void writeEntry(ZipOutputStream zip, String name, byte[] content) throws IOException {
196+
zip.putNextEntry(new ZipEntry(name));
197+
zip.write(content);
198+
zip.closeEntry();
199+
}
200+
201+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import java.io.File;
2+
import java.net.MalformedURLException;
3+
import java.net.URL;
4+
import java.net.URLClassLoader;
5+
6+
/**
7+
* Subprocess entry point for {@code GcfJarLauncherTests}. Loads
8+
* {@code GcfJarLauncher} from a Spring Boot fat JAR through a child
9+
* {@link URLClassLoader}, mirroring how the Google Cloud Functions framework
10+
* loads the deployed function JAR, and reports whether the {@code nested:}
11+
* URL protocol is registered before and after the launcher is constructed
12+
* (GH-1336).
13+
*/
14+
public class ProtocolCheck {
15+
16+
public static void main(String[] args) {
17+
File functionJar = new File(args[0]);
18+
try {
19+
new URL("nested:/dev/null");
20+
System.out.println("PROTOCOL_ALREADY_REGISTERED");
21+
}
22+
catch (MalformedURLException ex) {
23+
System.out.println("PROTOCOL_NOT_REGISTERED: " + ex.getMessage());
24+
}
25+
try (URLClassLoader child = new URLClassLoader(new URL[] { toUrl(functionJar) },
26+
ClassLoader.getSystemClassLoader())) {
27+
Class<?> launcher = Class.forName("org.springframework.cloud.function.adapter.gcp.GcfJarLauncher",
28+
true, child);
29+
launcher.getConstructor().newInstance();
30+
System.out.println("GCF_JAR_LAUNCHER_SUCCEEDED");
31+
}
32+
catch (Throwable ex) {
33+
System.out.println("GCF_JAR_LAUNCHER_FAILED: " + ex);
34+
ex.printStackTrace(System.out);
35+
}
36+
try {
37+
new URL("nested:/dev/null");
38+
System.out.println("PROTOCOL_NOW_REGISTERED");
39+
}
40+
catch (MalformedURLException ex) {
41+
System.out.println("PROTOCOL_STILL_NOT_REGISTERED: " + ex.getMessage());
42+
}
43+
}
44+
45+
private static URL toUrl(File file) {
46+
try {
47+
return file.toURI().toURL();
48+
}
49+
catch (MalformedURLException ex) {
50+
throw new IllegalStateException(ex);
51+
}
52+
}
53+
54+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.springframework.cloud.function.adapter.gcp;
2+
3+
/**
4+
* Minimal stand-in for the real {@code FunctionInvoker} used by
5+
* {@code GcfJarLauncherTests}. The real class would bootstrap the entire
6+
* Spring application in its constructor, which is not needed to verify that
7+
* {@code GcfJarLauncher} can load its delegate from a nested JAR.
8+
*/
9+
public class FunctionInvoker {
10+
11+
public FunctionInvoker() {
12+
}
13+
14+
}

0 commit comments

Comments
 (0)