[Bug]: ~5 MB of guest heap retained per closed Context on a shared isolated Engine when the context imports stdlib modules
@msimacek is already working on this.
Since Aug 17, 2026.
- Dominant language
- Python
- Stars
- 1.6k
- Forks
- 155
- Avg merge
- 9h 42m
- Merged PRs (30d)
- 36
Description
Describe the bug
We embed GraalPy with polyglot isolates: ONE shared Engine
(SandboxPolicy.UNTRUSTED, engine.MaxIsolateMemory=1GB) serving
short-lived one-shot Contexts. When each context evaluates a handful
of stdlib imports before its real work, every Context.close() leaves
~4.5-5 MB of REACHABLE objects in the isolate's guest heap. The live
set after Full GC grows monotonically (PrintGC: 23 -> 1031 MB across
~200 cycles) until the isolate dies with PolyglotException: MemoryError:
- polyglot 25.1.3: death between cycle ~150-175;
- polyglot 25.2.4: better (~2x more cycles) but the same cliff —
death between cycle ~300-325, with the average cycle degrading from
~72 ms to ~300 ms as the isolate spends most of its time in
back-to-back Full GCs.
The control experiment that separates this from "you are just creating
many contexts": the same cycle with context.eval("python", "x = 1")
instead of the imports is completely flat for 600 cycles — context
churn itself does not leak. Executing imports is the driver. Preempting
the usual questions: every cycle evaluates the identical source; no C
extensions, pure stdlib (datetime, decimal, ipaddress, re,
itertools); a single import decimal leaks more slowly (survives
250 cycles); we cannot easily take a guest heap dump out of an isolate,
the PrintGC live-set growth is our referent evidence — happy to run
any diagnostics you suggest.
Raising MaxIsolateMemory to 4GB just multiplies the cycle count
before the same cliff. Reproduces identically under
SandboxPolicy.TRUSTED + engine.SpawnIsolate=true (so not
UNTRUSTED-specific; that variant is where the PrintGC numbers come
from, TRUSTED allows engine.IsolateOption.PrintGC=true).
Not a duplicate of oracle/graal#8927 (host JVM heap static cache,
implicit per-context engines, no imports involved, closed completed
2026-02 — predates 25.2.4 where we still reproduce). Sibling but
distinct from oracle/graal#14252 we filed earlier: that one is about
isolates of already-CLOSED engines never being unmapped by the host;
this one is about retention inside a LIVE isolate while its engine
keeps serving. In our production the two compounded.
Operating system
macOS
CPU architecture
ARM64
GraalPy version
25.1.3 and 25.2.4 (community, python-isolate-*-community artifacts from Maven Central)
JDK version
OpenJDK 23.0.2 (macOS runs), OpenJDK 21.0.11 (Linux runs)
Context configuration
Engine engine = Engine.newBuilder("python")
.sandbox(SandboxPolicy.UNTRUSTED)
.out(OutputStream.nullOutputStream())
.err(OutputStream.nullOutputStream())
.option("engine.MaxIsolateMemory", "1GB")
.build();
// per cycle:
Context context = Context.newBuilder("python")
.engine(engine)
.out(OutputStream.nullOutputStream())
.err(OutputStream.nullOutputStream())
.option("sandbox.MaxCPUTime", "30s")
.option("sandbox.MaxHeapMemory", "512MB")
.option("sandbox.MaxASTDepth", "1000")
.option("sandbox.MaxStackFrames", "1024")
.option("sandbox.MaxThreads", "1")
.option("sandbox.MaxOutputStreamSize", "1MB")
.option("sandbox.MaxErrorStreamSize", "1MB")
.build();
Steps to reproduce
Full single-file reproducer (Gradle deps:
org.graalvm.polyglot:polyglot:25.2.4, org.graalvm.python:python:25.2.4,
org.graalvm.python:python-isolate-<platform>-community:25.2.4):
import java.io.OutputStream;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Engine;
import org.graalvm.polyglot.SandboxPolicy;
public class ImportLeak {
public static void main(String[] args) throws Exception {
int cycles = args.length > 0 ? Integer.parseInt(args[0]) : 400;
boolean control = args.length > 1 && args[1].equals("control");
String source = control ? "x = 1\n"
: "import datetime\nimport decimal\nimport ipaddress\nimport re\nimport itertools\n";
Engine engine = Engine.newBuilder("python")
.sandbox(SandboxPolicy.UNTRUSTED)
.out(OutputStream.nullOutputStream())
.err(OutputStream.nullOutputStream())
.option("engine.MaxIsolateMemory", "1GB")
.build();
for (int i = 0; i < cycles; i++) {
Context context = Context.newBuilder("python")
.engine(engine)
.out(OutputStream.nullOutputStream())
.err(OutputStream.nullOutputStream())
.option("sandbox.MaxCPUTime", "30s")
.option("sandbox.MaxHeapMemory", "512MB")
.option("sandbox.MaxASTDepth", "1000")
.option("sandbox.MaxStackFrames", "1024")
.option("sandbox.MaxThreads", "1")
.option("sandbox.MaxOutputStreamSize", "1MB")
.option("sandbox.MaxErrorStreamSize", "1MB")
.build();
long start = System.nanoTime();
try {
context.eval("python", source);
} finally {
context.close();
}
if (i % 25 == 0) {
System.out.printf("cycle %3d: %.0f ms%n", i, (System.nanoTime() - start) / 1_000_000.0);
}
}
engine.close();
System.out.println("DONE");
}
}
java ImportLeak 400— dies withMemoryErrorat ~150-175 (25.1.3)
or ~300-325 (25.2.4); watch the per-cycle time jump ~4-14x shortly
before death (the isolate enters back-to-back Full GCs).java ImportLeak 600 control— same lifecycle,x = 1instead of
imports: 600 cycles, flat timings, no failure.
Expected behavior
Closing a context releases (or makes collectable) the state created by
executing stdlib imports inside it; a shared engine should be able to
serve an unbounded number of short-lived contexts within a bounded
isolate heap. The documented one-context-per-request embedding pattern
currently has a hard lifetime of ~150-300 requests per 1GB isolate,
with a multi-second GC agony phase before each death.
Stack trace
Exception in thread "main" MemoryError: MemoryError
at org.graalvm.polyglot.PolyglotException.<init>(PolyglotException.java:122)
at com.oracle.truffle.polyglot.PolyglotImpl.guestToHostException(PolyglotImpl.java:1245)
at com.oracle.truffle.polyglot.isolate.PolyglotMarshallerConfig$ThrowableMarshaller.createPolyglotException(PolyglotMarshallerConfig.java:2296)
at org.graalvm.nativebridge.ForeignException.throwOriginalException(ForeignException.java:135)
...
Additional context
- Also reproduced on linux-aarch64 (Ubuntu 24.04, OpenJDK 21) with
25.2.4 — same growth curve and MemoryError. Our production service
(linux-amd64) lived through the matching death pattern (isolates
dying every ~150-200 contexts) until we minimized per-context
imports. - PrintGC evidence (TRUSTED +
engine.SpawnIsolate=true+
engine.IsolateOption.PrintGC=true, 25.1.3): live set after Full GC
grows 23 -> 131 -> 272 -> 400 -> 664 -> 795 -> 914 -> 1031 MB across
~200 cycles; a Full GC near the ceiling reclaims ~5%; 78% of wall
time is GC pauses in the final phase. - Workaround we use in production: proactively recycle the engine every
N contexts (a warm engine rebuild is only ~2-4 ms) — which is what
led us to the sibling issue oracle/graal#14252.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.