The Flag Was Verified. The File Was Never Read.

"Verified locally against GraalVM CE 25.0.2 …" The file says so about itself, which is more than most config files do. Here is native-image.properties on the three reactive Spring Boot branches, as committed on 3 September:

# Netty 4.2 can close a shared Arena; SubstrateVM refuses to unless this is enabled, and the
# refusal is an UnsupportedFeatureError at runtime rather than a build failure -- so a native
# image without it works right up until the path is taken. Micronaut has carried this flag since
# its first native build; these branches resolve the same netty-common 4.2.15 through
# reactor-netty and had no flag. Verified locally against GraalVM CE 25.0.2: without it,
# Arena.ofShared() allocates and reads fine and then throws on close(); with it, close() works.
# Costs nothing -- the two probe binaries were byte-for-byte the same size.
#
# Quarkus deliberately does NOT get this: it resolves netty-common 4.1.135, which contains no
# reference to ofShared at all, so the flag would be inert there.
Args = -H:+SharedArenaSupport

Every sentence in that comment was true when it was written. (The Netty versions have moved since: 4.2.17 and 4.1.138 now.) "Verified locally" is true. The commit message even says "verified … rather than assumed". And for 43 days, -H:+SharedArenaSupport never reached native-image on any of the three branches, because no build ever read this file. Spring read a different file at the same path.

Spring process-aot writes the same path#

The path is src/main/resources/META-INF/native-image/dev.tsvinc/spring-native-as-hell/. That is the conventional layout, META-INF/native-image/<groupId>/<artifactId>/, filled in with the project's own coordinates. Spring Framework's AOT processor uses the same convention, for the same reason. From ContextAotProcessor at v7.0.9, the version Spring Boot 4.1.1 pulls in here:

private void writeNativeImageProperties(List<String> args) {
	if (CollectionUtils.isEmpty(args)) {
		return;
	}
	StringBuilder sb = new StringBuilder();
	sb.append("Args = ");
	sb.append(String.join(String.format(" \\%n"), args));
	Path file = getSettings().getResourceOutput().resolve("META-INF/native-image/" +
			getSettings().getGroupId() + "/" + getSettings().getArtifactId() + "/native-image.properties");
	try {
		if (!Files.exists(file)) {
			Files.createDirectories(file.getParent());
			Files.createFile(file);
		}
		Files.writeString(file, sb.toString());
	}
	catch (IOException ex) {
		throw new IllegalStateException("Failed to write native-image.properties", ex);
	}
}

The default args are -H:Class=<main class> and --no-fallback. process-aot generates that file under target/spring-aot/main/resources/, the generated resources land in target/classes/, and the copy from src/main/resources that used to sit there is gone. No merge. No warning. The jar from the Wave 2 build of reactive-synth holds one native-image.properties in our directory, and it is Spring's:

$ unzip -l target/spring-native-as-hell-0.1.jar | /usr/bin/grep native-image/dev.tsvinc
        0  10-16-2026 18:05   META-INF/native-image/dev.tsvinc/
        0  10-16-2026 18:06   META-INF/native-image/dev.tsvinc/spring-native-as-hell/
        0  10-16-2026 18:05   META-INF/native-image/dev.tsvinc/micrometer-process-cpu-time/
   222425  10-16-2026 18:06   META-INF/native-image/dev.tsvinc/spring-native-as-hell/reachability-metadata.json
       54  10-16-2026 18:06   META-INF/native-image/dev.tsvinc/spring-native-as-hell/native-image.properties
      185  10-16-2026 18:05   META-INF/native-image/dev.tsvinc/micrometer-process-cpu-time/reachability-metadata.json

54 bytes is Args = -H:Class=dev.tsvinc.Application \, a newline, and --no-fallback. Our ten lines of comment and one flag are not in there.

Spring Boot's docs do warn about this directory. The advanced native-image topics page says AOT writes its hints to META-INF/native-image/{groupId}/{artifactId}/ and tells you to put static hint files "in a directory that does not clash with this location, such as META-INF/native-image/{groupId}/{artifactId}-additional-hints/". The warning sits under Providing Hints Statically, so it reads as advice about JSON hint files. None of the four Spring AOT and native-image pages I checked says that native-image.properties gets overwritten too.

Why the verification passed anyway#

The verification was real. It just tested something else. The commit and the comment both mention two probe binaries. The project notes describe that probe: a throwaway Arena.ofShared() program, built with and without a native-image.properties on the classpath. That proves the flag works. It cannot prove the Spring app gets the flag, because the probe never went through process-aot.

Seventy-six minutes after the flag commit, I wrote down the rule that justified the probe. It went into the project's notes file on 3 September and stayed there until 16 October. It said that native-image does not echo arguments that arrive through a native-image.properties file, so grepping the build log for the flag proves nothing, and the way to prove the mechanism is a throwaway probe binary built with and without the file.

So the method was: verify the mechanism in isolation, and trust that the file on disk is the file in the build. The first half was rigorous. The second half was never checked.

The 25.0.2 build log already named the file#

Wave 2 moved all eighteen branches to GraalVM 25i4 (GraalVM CE 25.4.4.1.1, an innovation release). Its build logs list experimental options with the file each one came from, and that is how this surfaced. My first reading was that 25i4 had started printing it. It hadn't. Here is the Micronaut reactive-synth native build from the benchmark run that began on 1 September, on 25.0.2:

$ /usr/bin/grep -h -e 'vendor version' -e 'SharedArena' micronaut/reactive-synth/20260901-174554/native-platform-synth-256mb/build.log
#16 21.78 Warning: The option '-H:+SharedArenaSupport' is experimental and must be enabled via '-H:+UnlockExperimentalVMOptions' in the future.
#16 26.05  Java version: 25.0.2+10, vendor version: GraalVM CE 25.0.2+10.1
#16 26.06  - '-H:+SharedArenaSupport' (origin(s): 'META-INF/native-image/dev.tsvinc/micronaut-native-as-hell/native-image.properties' in 'file:///app/target/micronaut-native-as-hell-0.1.jar')

That file was written about 32 hours before the note saying the build log cannot show this. The flag, the word "experimental", and the origin path are all there.

I have no record of which log I grepped on 3 September. I do know what the obvious grep returns over that run's directory:

$ /usr/bin/grep -c SharedArena micronaut/reactive-synth/20260901-174554/*/build.log
micronaut/reactive-synth/20260901-174554/jvm-synth-256mb/build.log:0
micronaut/reactive-synth/20260901-174554/jvm-synth-unlimited/build.log:0
micronaut/reactive-synth/20260901-174554/jvm-vthreads-synth-256mb/build.log:0
micronaut/reactive-synth/20260901-174554/jvm-vthreads-synth-unlimited/build.log:0
micronaut/reactive-synth/20260901-174554/native-platform-synth-256mb/build.log:2
micronaut/reactive-synth/20260901-174554/native-platform-synth-unlimited/build.log:0
micronaut/reactive-synth/20260901-174554/native-synth-128mb/build.log:0
micronaut/reactive-synth/20260901-174554/native-synth-256mb/build.log:0
micronaut/reactive-synth/20260901-174554/native-synth-unlimited/build.log:0

Four of the five native logs say zero. Each scenario rebuilds the image, Docker reuses the cached native-image layer, and those four logs are about 3 KB of CACHED lines. Only one build per branch ran the compiler and printed its warnings. Across all six Micronaut branches in that run, 6 of 34 native build logs contain an actual native-image run. Grep any of the other 28 and the conclusion writes itself.

The absence is the proof#

On 25i4 the same check takes one command per framework. Micronaut, Wave 2:

$ /usr/bin/grep -h "origin(s)" mn/logs/reactive-*native-build.log
#16 29.87  - '-H:+SharedArenaSupport' (origin(s): 'META-INF/native-image/dev.tsvinc/micronaut-native-as-hell/native-image.properties' in 'file:///app/target/micronaut-native-as-hell-0.1.jar')
#16 56.54  - '-H:+SharedArenaSupport' (origin(s): 'META-INF/native-image/dev.tsvinc/micronaut-native-as-hell/native-image.properties' in 'file:///app/target/micronaut-native-as-hell-0.1.jar')
#16 30.67  - '-H:+SharedArenaSupport' (origin(s): 'META-INF/native-image/dev.tsvinc/micronaut-native-as-hell/native-image.properties' in 'file:///app/target/micronaut-native-as-hell-0.1.jar')

Spring Boot, the same three reactive variants, each log a full uncached build:

$ /usr/bin/grep -c SharedArena sb/logs/reactive-*/build-native.log
sb/logs/reactive-r2dbc-synth/build-native.log:0
sb/logs/reactive-synth/build-native.log:0
sb/logs/reactive-synth-more-io/build-native.log:0

The Spring log does mention the directory, though. 25i4 deprecated --no-fallback, and the deprecation warning names its source:

$ /usr/bin/grep -h 'no-fallback' sb/logs/reactive-synth/build-native.log
#15 8.435 Warning: Using a deprecated option --no-fallback from 'META-INF/native-image/dev.tsvinc/spring-native-as-hell/native-image.properties' in 'file:///app/target/classes/'. No effect, no replacement available

native-image opened dev.tsvinc/spring-native-as-hell/native-image.properties, found --no-fallback in it, and found nothing else. The file the comment describes and the file the builder read share a path and nothing more.

What it cost: unmeasured, and that has to stay the answer#

The mechanism is concrete. The reactive branches resolve netty-common 4.2.17 through reactor-netty, and its CleanerJava25 class references ofShared. Without the flag, the probe on 25.0.2 showed allocation and reads working and close() throwing UnsupportedFeatureError. That points at Netty's direct-buffer release path. Quarkus resolves Netty 4.1.138, which has no ofShared path, and the imperative Spring branches run Tomcat with no Netty, so this only touches the three reactive Spring branches.

The cost has no number, and I'm not going to invent one:

  • No published number moves. The last Spring Boot benchmark run is the one that began on 1 September, and it finished before the flag was committed. Those binaries had no flag because the flag didn't exist yet.
  • Nothing ran in between. No Spring native benchmark ran between 3 September and the 25i4 builds on 16 October, so no result exists with the flag "applied" that would need a correction.
  • No error in the logs I have. The 1 September errors.txt files for the Spring reactive natives contain no UnsupportedFeature or Arena hits. The non-empty ones are 100-line tails, so that proves very little.

The damage was in the plan. The next rerun would have compared a Micronaut native with the flag against a Spring native that only claimed to have it, while the comment and the commit message both said Spring had it too.

The fix: a directory Spring doesn't own#

The fix follows the docs' advice about hint files: put the flag somewhere AOT doesn't write. The new home is META-INF/native-image/dev.tsvinc/native-flags/native-image.properties, with a comment explaining why it is not in the obvious place. After the rebuild, both files sit side by side in target/classes:

$ /usr/bin/wc -c target/classes/META-INF/native-image/dev.tsvinc/*/native-image.properties
1220 target/classes/META-INF/native-image/dev.tsvinc/native-flags/native-image.properties
  54 target/classes/META-INF/native-image/dev.tsvinc/spring-native-as-hell/native-image.properties
1274 total

All three rebuilt reactive branches now report the flag with its new origin (the move is committed as bae3dd54, 033c3e10 and 4602135f). Here is reactive-synth-more-io:

$ /usr/bin/grep -h -e 'vendor version' -e 'SharedArena' fix-wave/reactive-synth-more-io/build-native.log
#15 13.07 Warning: The option '-H:+SharedArenaSupport' is experimental and must be enabled via '-H:+UnlockExperimentalVMOptions' in the future.
#15 20.75  - Java version: 25.0.4.1.1+1, vendor version: GraalVM CE 25.4.4.1.1+1.1
#15 20.75  - '-H:+SharedArenaSupport' (origin(s): 'META-INF/native-image/dev.tsvinc/native-flags/native-image.properties' in 'file:///app/target/classes/')

The same overwrite had already shaped another fix in the same upgrade. When a native /prometheus endpoint hung on 25i4, the Micrometer reflection metadata for Spring had to go into its own META-INF/native-image/dev.tsvinc/micrometer-process-cpu-time/ directory, because process-aot replaces reachability-metadata.json at the shared path too (the hang post has that story). That fix routed around the overwrite before it could bite. The flag file sat under it for 43 days.

A flag in a properties file is a claim about the build. Check the claim where the builder reports its options and their origins, in a log that actually ran the builder.

Filed under: java, spring-boot, graalvm, native-image, reactive, debugging

Sources#

  • Benchmark repo (internal): origin/springboot/{reactive-synth,reactive-r2dbc-synth,reactive-synth-more-io}:src/main/resources/META-INF/native-image/dev.tsvinc/spring-native-as-hell/native-image.properties, added by commits 8df2a658, 683d4d89, ee2fbb47 (2026-09-03 09:33 +0300). The project notes file on main as of d958d59f, rule introduced by 2df560a7 (2026-09-03 10:49 +0300). The fix that moves the flag to dev.tsvinc/native-flags/: bae3dd54 (reactive-r2dbc-synth), 033c3e10 (reactive-synth), 4602135f (reactive-synth-more-io), 2026-10-16.
  • Build logs (internal): run 20260901-174554 micronaut/*/native*/build.log (GraalVM CE 25.0.2); Wave 2 native builds on GraalVM CE 25.4.4.1.1, 2026-10-16 (mn/logs/*native-build.log, sb/logs/*/build-native.log, the reactive-synth jar listing); fix rebuild of springboot/reactive-synth-more-io, 2026-10-16.
  • Spring Framework ContextAotProcessor at v7.0.9: getDefaultNativeImageArguments and writeNativeImageProperties.
  • Spring Boot: Advanced Native Images Topics: Providing Hints Statically, the {artifactId}-additional-hints advice.
  • Spring Boot: Introducing GraalVM Native Images: generated hint files go to META-INF/native-image/{groupId}/{artifactId}/.
  • Spring Boot Maven plugin: Ahead-of-Time Processing and Spring Framework: Ahead of Time Optimizations: checked; neither mentions native-image.properties.