Skip to content

[Bug]: ~5 MB of guest heap retained per closed Context on a shared isolated Engine when the context imports stdlib modules #1083

Description

@Aqru

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");
    }
}
  1. java ImportLeak 400 — dies with MemoryError at ~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).
  2. java ImportLeak 600 control — same lifecycle, x = 1 instead 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 Polyglot isolates: Engine.close() silently skips isolate teardown after any eval — ~32GB reservation and committed heap stay mapped graal#14252.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions