From 46ca9d605b8e1579a68a03826b772998a504c481 Mon Sep 17 00:00:00 2001 From: NathanGrand Date: Wed, 4 Mar 2026 16:15:31 +0000 Subject: [PATCH] Expose output streams on EngineBuilder (so that implementations that limit growth can be supplied). --- .../io/roastedroot/quickjs4j/core/Engine.java | 40 ++++++++++++++++--- .../core/BoundedByteArrayOutputStream.java | 35 ++++++++++++++++ .../quickjs4j/core/RunnerTest.java | 26 ++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 core/src/test/java/io/roastedroot/quickjs4j/core/BoundedByteArrayOutputStream.java diff --git a/core/src/main/java/io/roastedroot/quickjs4j/core/Engine.java b/core/src/main/java/io/roastedroot/quickjs4j/core/Engine.java index 2b877c4..e91ec1d 100644 --- a/core/src/main/java/io/roastedroot/quickjs4j/core/Engine.java +++ b/core/src/main/java/io/roastedroot/quickjs4j/core/Engine.java @@ -31,8 +31,8 @@ public final class Engine implements AutoCloseable { private static final int ALIGNMENT = 1; public static final ObjectMapper DEFAULT_OBJECT_MAPPER = new ObjectMapper(); - private final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - private final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + private final ByteArrayOutputStream stdout; + private final ByteArrayOutputStream stderr; private final WasiOptions wasiOpts; private final WasiPreview1 wasi; @@ -66,10 +66,14 @@ private Engine( ObjectMapper mapper, Function memoryFactory, ScriptCache cache, - Logger logger) { + Logger logger, + ByteArrayOutputStream stdout, + ByteArrayOutputStream stderr) { this.mapper = mapper; this.builtins = builtins; this.cache = cache; + this.stdout = stdout; + this.stderr = stderr; // builtins to make invoke dynamic javascript functions builtins.put( @@ -454,7 +458,7 @@ public String stderr() { try { stderr.flush(); } catch (IOException ex) { - throw new RuntimeException("Failed to flush stdout"); + throw new RuntimeException("Failed to flush stderr"); } return stderr.toString(UTF_8); @@ -533,6 +537,8 @@ public static final class Builder { private Function memoryFactory; private ScriptCache cache; private Logger logger; + private ByteArrayOutputStream stdout; + private ByteArrayOutputStream stderr; private Builder() {} @@ -566,6 +572,16 @@ public Builder withLogger(Logger logger) { return this; } + public Builder withStdout(ByteArrayOutputStream stdout) { + this.stdout = stdout; + return this; + } + + public Builder withStderr(ByteArrayOutputStream stderr) { + this.stderr = stderr; + return this; + } + public Engine build() { if (mapper == null) { mapper = DEFAULT_OBJECT_MAPPER; @@ -589,7 +605,21 @@ public Engine build() { if (logger == null) { logger = new SystemLogger(); } - return new Engine(finalBuiltins, finalInvokables, mapper, memoryFactory, cache, logger); + if (stdout == null) { + stdout = new ByteArrayOutputStream(); + } + if (stderr == null) { + stderr = new ByteArrayOutputStream(); + } + return new Engine( + finalBuiltins, + finalInvokables, + mapper, + memoryFactory, + cache, + logger, + stdout, + stderr); } } } diff --git a/core/src/test/java/io/roastedroot/quickjs4j/core/BoundedByteArrayOutputStream.java b/core/src/test/java/io/roastedroot/quickjs4j/core/BoundedByteArrayOutputStream.java new file mode 100644 index 0000000..d0b1db8 --- /dev/null +++ b/core/src/test/java/io/roastedroot/quickjs4j/core/BoundedByteArrayOutputStream.java @@ -0,0 +1,35 @@ +package io.roastedroot.quickjs4j.core; + +import java.io.ByteArrayOutputStream; + +public class BoundedByteArrayOutputStream extends ByteArrayOutputStream { + private final int maxBytes; + + public BoundedByteArrayOutputStream(int maxBytes) { + this.maxBytes = maxBytes; + } + + @Override + public synchronized void write(int b) { + if (size() >= maxBytes) { + throw new RuntimeException("Output stream exceeded limit of " + maxBytes + " bytes"); + } + super.write(b); + } + + @Override + public synchronized void write(byte[] b, int off, int len) { + if (size() + len > maxBytes) { + throw new RuntimeException("Output stream exceeded limit of " + maxBytes + " bytes"); + } + super.write(b, off, len); + } + + @Override + public synchronized void writeBytes(byte[] b) { + if (size() + b.length > maxBytes) { + throw new RuntimeException("Output stream exceeded limit of " + maxBytes + " bytes"); + } + super.writeBytes(b); + } +} diff --git a/core/src/test/java/io/roastedroot/quickjs4j/core/RunnerTest.java b/core/src/test/java/io/roastedroot/quickjs4j/core/RunnerTest.java index 4fac698..0858d8b 100644 --- a/core/src/test/java/io/roastedroot/quickjs4j/core/RunnerTest.java +++ b/core/src/test/java/io/roastedroot/quickjs4j/core/RunnerTest.java @@ -350,4 +350,30 @@ public void withExecutorService() { runner.compileAndExec("console.log('something something');"); } } + + @Test + public void boundedStdoutStopsExecution() throws Exception { + var boundedStdout = new BoundedByteArrayOutputStream(1024); + var es = Executors.newSingleThreadExecutor(); + var engine = Engine.builder().withStdout(boundedStdout).build(); + var runner = Runner.builder().withEngine(engine).withExecutorService(es).build(); + + // No timeout — the bounded stream should cause the error + var ex = + assertThrows( + RuntimeException.class, + () -> + runner.compileAndExec( + "while(true) { console.log('x'.repeat(100)); }")); + + assertTrue( + ex.getMessage().contains("exceeded limit"), + "Expected stream limit error, got: " + ex.getMessage()); + + // The executor thread should be free after the stream error + var probe = es.submit(() -> "ok"); + assertEquals("ok", probe.get(5, java.util.concurrent.TimeUnit.SECONDS)); + + runner.close(); + } }