Skip to content

JDTLS spawns redundant per-module processes in Java monorepos, causing OOM #11368

@csaroff

Description

@csaroff

Summary

In Java monorepos (e.g. Gradle multi-module projects), opencode spawns a separate jdtls process per submodule that the agent visits. Each process uses ~200-300MB of RSS, so a session where the agent explores 5 submodules accumulates ~1.2GB of jdtls processes. Across multiple terminal sessions this quickly leads to OOM.

Root cause

The JDTLS root function uses NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]) (server.ts:1132). This walks up from the file being edited and returns the nearest directory containing a build file.

In a monorepo, every submodule has its own build.gradle or pom.xml, so each submodule the agent touches resolves to a different root. Since LSP client deduplication keys on root + serverID (index.ts:231), each unique root spawns a new jdtls process.

This is unnecessary — jdtls natively supports multi-module projects. Pointing it at the monorepo root (where settings.gradle or gradlew lives) lets it discover and handle all submodules with a single process.

Observed behavior

On a Gradle monorepo with ~100 submodules, 2 terminal sessions accumulated 5 jdtls processes:

PID 6789  → cwd: entry/foo
PID 9070  → cwd: entry/bar/qux
PID 13287 → cwd: entry/bar/corge
PID 13292 → cwd: entry/foo  (duplicate, different session)
PID 17572 → cwd: entry/baz

Each has a unique -data temp dir and ~200-300MB RSS.

Expected behavior

A single jdtls process rooted at the monorepo root, regardless of how many submodules the agent visits.

Suggested fix

Give JDTLS the same root-finding strategy that KotlinLS already uses (server.ts:1234-1245) — prefer settings.gradle / settings.gradle.kts / gradlew (monorepo root markers) before falling back to build.gradle / pom.xml:

root: async (file) => {
  const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file)
  if (settingsRoot) return settingsRoot
  const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file)
  if (wrapperRoot) return wrapperRoot
  return NearestRoot(["build.gradle.kts", "build.gradle", "pom.xml", ".project", ".classpath"])(file)
},

Related

Related to #7227 (LSP spawning for external directories) — both issues stem from the same pattern of spawning redundant LSP servers when a single instance would suffice.

Environment

  • opencode v1.1.43 (Homebrew)
  • macOS, Java 21

Metadata

Metadata

Assignees

Labels

perfIndicates a performance issue or need for optimization

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions