Skip to content

jvm: use argfiles for JVM and JVM-compiler process arguments - #23620

Open
robertpi wants to merge 4 commits into
pantsbuild:mainfrom
robertpi:fix/classpath-length-issue
Open

jvm: use argfiles for JVM and JVM-compiler process arguments#23620
robertpi wants to merge 4 commits into
pantsbuild:mainfrom
robertpi:fix/classpath-length-issue

Conversation

@robertpi

@robertpi robertpi commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem

JVM processes that don't use nailgun — JUnit/ScalaTest test runs (use_nailgun=False), pants run, and deploy-jar execution — invoke the java binary with their full classpath and argument list passed directly on the command line via argv. For components with many dependencies or a long classpath, this can exceed OS-level command-line length limits (e.g. MAX_ARG_STRLEN/ARG_MAX on Linux/macOS), causing the process to fail with an "Argument list too long" style error.

Concrete case hit in practice: pants test on a JUnit 5 target with a ~250-jar classpath (Spark + Hadoop + Cassandra + Scala + Netty native bundles) failed with Os { code: 7, kind: ArgumentListTooLong, ... } when colon-joined into a single -cp argv element.

Fix

  • For Java 9+ JDKs, JvmProcess now writes the java invocation's arguments (classpath plus JVM options and program args) to a @argfile (__jvm_args.txt) instead of passing them on the command line, per the java command-line argument files support added in JDK 9.
  • This is gated on not request.use_nailgun: nailgun sends the classpath and program arguments to the already-running server over a socket rather than via argv, so nailgun-eligible processes (e.g. compilation) were never subject to this limit and get no benefit from the argfile — they keep using nailgun exactly as before this PR.
  • Skip the argfile (fall back to inline argv) whenever a {chroot} placeholder is present in the classpath or argv. That placeholder is only substituted by the engine within a Process's literal argv at spawn time, never within file contents, so callers relying on it (run.py, run_deploy_jar.py, which also run outside the sandbox via InteractiveProcess) can't have those arguments moved into a file.

Scoped out

An earlier version of this PR also routed the javac/scalac/kotlinc compiler invocations' own argument lists through an @argfile, unconditionally. That change wasn't tied to an observed failure (compilation stays nailgun-eligible by default, so it doesn't hit this limit the same way), so it's been split out for separate review/justification rather than riding along with this fix.

Test plan

  • src/python/pants/jvm/jdk_rules_test.py (new/updated coverage for argfile generation, escaping, the Java 9+ nailgun-vs-argfile branch, and the {chroot} fallback)
  • src/python/pants/jvm/run_integration_test.py, src/python/pants/backend/openapi/util_rules/generator_process_test.py, src/python/pants/backend/java/compile/javac_test.py, src/python/pants/backend/scala/compile/scalac_test.py, src/python/pants/backend/kotlin/compile/kotlinc_test.py, src/python/pants/jvm/test/junit_test.py, src/python/pants/backend/scala/test/scalatest_test.py
  • ./pants lint fmt check test on all changed files

@jgranstrom jgranstrom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be able to expand on the cases where you hit this? Given where these limits generally sit it would have to be quite extreme to hit it for classpaths. Essentially on the order of thousands of classpath entries on a single target.

This PR effectively disables nailgun for JDK 9+, and nailgun is what gives warm JVMs, so that is a significant and unconditional cost on things like compile. But this isn't a problem when running nailgun in the first place, because then the potentially long arg list isn't passed via command arguments at all. So this disables nailgun, making it a universal problem, then applies the fix, and applies the file-indirection to every jvm_process whether or not it's needed. For anyone without the extreme classpath case that is strictly a regression.

The launcher args, the ones incompatible with nailgun, are also the ones that are bounded for anything nailgun-able. Under nailgun the only command line is the server spawn, carrying just the tool classpath. Everything that varies per invocation goes over the socket. So they are only unbounded for things that will not run under nailgun anyway, like tests.

The compile-side argfiles are nailgun-compatible, though also unnecessary under nailgun, since those args aren't passed via the command line there either.

So both are only needed when nailgun isn't used. The compile-side ones pay for that with a small unconditional overhead while the jvm_process one pays for it by disabling nailgun for everyone.

There is also a nailgun implementation that supports all modern JDK versions which Pants can be configured to use, so this would entirely disable the option of using that.

I would not like to see the blanket disabling of nailgun, but I can imagine other directions

  • Only do this when nailgun is not used, which is not immediately known here. You would essentially need to predict if nailgun will be used, which needs to combine several pieces of state. This can easily drift.
  • Doing it only if use_nailgun=False would allow for the fix when you know nailgun won't be used, but it being True only means it might.
  • Possibly rewriting the args at a later stage when you know how it will execute (nailgun or not) and would also know the actual argv length

Comment thread src/python/pants/jvm/jdk_rules.py Outdated
Comment on lines +463 to +465
use_argfile = jdk.jre_major_version >= 9 and not contains_chroot_placeholder
use_nailgun = []
if request.use_nailgun:
if request.use_nailgun and not use_argfile:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effectively disables nailgun, where the fix is not needed since the long args are not passed on command line already.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — see the full reply above and the new commit; gated the argfile on not request.use_nailgun instead of just the JDK version, so nailgun stays enabled here unless the caller already isn't going to use it.

@robertpi

Copy link
Copy Markdown
Contributor Author

Good catch — you're right this disables nailgun for every JDK 9+ process, not just the ones that need the argfile, and that nailgun already keeps the classpath off the command line entirely, so the fix bought nothing there and only cost something. Pushed a fix: the gate is now use_argfile = jdk.jre_major_version >= 9 and not request.use_nailgun and not contains_chroot_placeholder, so we only route through the argfile when the caller already isn't going to use nailgun, matching your second suggestion. Compilation (which doesn't override use_nailgun, so defaults to True) goes back to using nailgun on Java 9+ as before this PR.

On the concrete case: it's pants test on a subproject with a JUnit 5 test classpath of roughly 250 jars (Spark + Hadoop + Cassandra + Scala + Netty native bundles). Colon-joined for java -cp, that exceeds Linux's MAX_ARG_STRLEN (128 KiB per argv element), and the process launch fails with Os { code: 7, kind: ArgumentListTooLong, ... }. Worth noting: JUnit test runs already set use_nailgun=False (junit.py), so this case was never nailgun-eligible in the first place, which is exactly why gating on use_nailgun rather than JDK version is the right fix, not just a safer one.

Verified pants test on the JVM compile (javac/scalac/kotlinc), JUnit/ScalaTest, and pants run/deploy-jar paths still pass with this change.

@benjyw

benjyw commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

@jgranstrom good to go?

@jgranstrom

Copy link
Copy Markdown
Contributor

My only reservation is that we still use the arg file unconditionally on the compile path. While the precedent is for running tests which never use nailgun, for that only the jvm_process path that is conditional is necessary, since tests run without nailgun and that is known there. So I'm wondering if we could scope out the compile paths at first, and possibly do the compile paths separately if they still end up hitting this issue?

The Java 9+ argfile optimization moved the classpath and program
arguments into an `@__jvm_args.txt` file to avoid OS-level "Argument
list too long" errors. This broke `pants run` and deploy-jar execution
against a Java 9+ JDK: those code paths embed a literal `{chroot}`
placeholder in classpath entries (substituted by the engine only within
a process's argv at spawn time, never within file contents), and the
InteractiveProcess run in the workspace doesn't have its cwd set to the
sandbox, so the bare `@__jvm_args.txt` reference was also unresolvable.

Skip the argfile and fall back to inline argv whenever a `{chroot}`
placeholder is present in the classpath or argv, which is exactly the
set of callers (run.py, run_deploy_jar.py) relying on that
substitution. All other callers, which don't use `{chroot}`, keep using
the argfile.

Also updates a stale OpenAPI generator test that asserted on argv
content that now lives in the argfile, and adds a release note.
Nailgun sends the classpath and program arguments to the already-running
server over a socket rather than via argv, so it was never subject to the
OS argument-length limit the argfile works around, and gets no benefit
from routing through it. Gating the argfile on JDK version alone
unconditionally disabled nailgun for every Java 9+ process, including the
common case (e.g. compilation) where nailgun was requested and the
argfile wasn't needed.

Gate on `not request.use_nailgun` instead, so the argfile is only used
when the caller already isn't going to use nailgun (as JUnit/ScalaTest
test execution and `pants run`/deploy-jar already do), rather than
disabling nailgun universally to make room for it.

Verified with `PY=python3.14 ./pants test` on:
- src/python/pants/jvm/jdk_rules_test.py
- src/python/pants/jvm/run_integration_test.py
- src/python/pants/backend/openapi/util_rules/generator_process_test.py
- src/python/pants/backend/java/compile/javac_test.py
- src/python/pants/backend/scala/compile/scalac_test.py
- src/python/pants/backend/kotlin/compile/kotlinc_test.py
- src/python/pants/jvm/test/junit_test.py
- src/python/pants/backend/scala/test/scalatest_test.py

and `PY=python3.14 ./pants fmt lint check` on all changed files.
@robertpi
robertpi force-pushed the fix/classpath-length-issue branch from 0023c68 to a0098f7 Compare September 8, 2026 11:18
The release note still described the javac/scalac/kotlinc compiler
argfile change that was split out of this PR into a separate
follow-up, since it wasn't tied to an observed failure the way the
JvmProcess/nailgun fix was. Narrow the note to what this PR actually
does now.
@robertpi

robertpi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

@jgranstrom okay, I agree the change to the compilers was speculative and quite possibly unnecessary, so I've removed it, so the change should just be scoped to the tests and another JVM process that's disabled Nailgun.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants