oracle / oracle/graalpython

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

Open
#1,083 2 comments 0 reactions 1 assignee View on GitHub

@msimacek is already working on this.

Since Aug 17, 2026.

bug
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");
    }
}
  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 oracle/graal#14252.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.