-
Notifications
You must be signed in to change notification settings - Fork 29
Bound the wait on the Lua interpreter in tests #1231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4e690c0
3ccbba2
6fac1f0
e4da96d
cc994da
596b6fa
7871731
c1ac493
a587e86
a2e1606
eeabf21
c9cf53a
03e7c13
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| package tests.wurstscript.tests; | ||
|
|
||
| import org.testng.annotations.Test; | ||
|
|
||
| import static org.testng.Assert.assertTrue; | ||
| import static org.testng.Assert.fail; | ||
|
|
||
| /** | ||
| * Tests for the harness that runs emitted Lua, rather than for anything it compiles. | ||
| * <p> | ||
| * Both cases here used to stop the whole suite instead of failing a test: the runner drained the | ||
| * spawned interpreter's stderr to the end before reading stdout, and waited for the process without | ||
| * a bound. A program that fills the stdout pipe blocks writing while the runner blocks reading the | ||
| * other pipe, and neither side ever moves. There is nothing to see when that happens - no output, | ||
| * no failing test, no timeout - so it is worth holding onto tests that would notice it coming back. | ||
| * <p> | ||
| * Both are Lua only. Run through the Jass configurations as well, the non-terminating one would | ||
| * hang the interpreter instead, which is the same problem in a place this fix does not reach. | ||
| */ | ||
| public class LuaRunnerTests extends WurstScriptTest { | ||
|
|
||
| /** Long enough that a program which does terminate still does, short enough to wait for. */ | ||
| @Override | ||
| protected int luaExecutionTimeoutSeconds() { | ||
| return 5; | ||
| } | ||
|
|
||
| @Test | ||
| public void aProgramThatDoesNotTerminateFailsItsOwnTest() { | ||
| try { | ||
| test().testLua(true).luaOnly(true).executeProg().lines( | ||
| "package test", | ||
| "native testSuccess()", | ||
| "init", | ||
| " var i = 0", | ||
| " while true", | ||
| " i += 1", | ||
| " testSuccess()" | ||
| ); | ||
| fail("expected the runner to give up on a program that does not terminate"); | ||
| } catch (Error e) { | ||
| assertTrue(e.getMessage() != null && e.getMessage().contains("did not terminate"), | ||
| "expected a timeout, got: " + e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Enough lines to fill the pipe buffer several times over. With the streams read one after the | ||
| * other this deadlocks: the program blocks writing stdout, the runner blocks reading stderr. | ||
| */ | ||
| @Test | ||
| public void aProgramThatFloodsStdoutStillFinishes() { | ||
| test().testLua(true).luaOnly(true).executeProg().lines( | ||
| "package test", | ||
| "native testSuccess()", | ||
| "native println(string s)", | ||
| "init", | ||
| " for i = 1 to 20000", | ||
| " println(\"filling the pipe buffer with a reasonably long line of output\")", | ||
| " testSuccess()" | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The same drain with the output arriving as one line rather than many. This checks that it | ||
| * finishes, which is what the two cases above check too — it does not observe how much of the | ||
| * line was retained, and would pass against a collector that kept all of it. Keeping a bounded | ||
| * amount is worth doing regardless, but a program printing a megabyte without a newline is not | ||
| * something this suite expects, so it is not worth test-only machinery to assert. | ||
| */ | ||
| @Test | ||
| public void aProgramPrintingWithoutNewlinesStillFinishes() { | ||
| test().testLua(true).luaOnly(true).executeProg().lines( | ||
| "package test", | ||
| "native testSuccess()", | ||
| "native println(string s)", | ||
| "init", | ||
| " var line = \"\"", | ||
| " for i = 1 to 4000", | ||
| " line += \"this line keeps growing and is never broken by a newline \"", | ||
| " println(line)", | ||
| " testSuccess()" | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,21 @@ | |
| public class WurstScriptTest { | ||
|
|
||
| public static final String TEST_OUTPUT_PATH = "./test-output/"; | ||
|
|
||
| /** Generous enough for the slowest test program, short enough that a loop fails the run. */ | ||
| private static final int LUA_EXECUTION_TIMEOUT_SECONDS = 60; | ||
|
|
||
| /** | ||
| * How much of a program's output is kept for the failure message. Draining never stops - that | ||
| * is what deadlocks the run - but retaining everything from a program that loops while printing | ||
| * would exhaust the worker before the timeout fires. | ||
| */ | ||
| private static final int RETAINED_OUTPUT_LIMIT = 64 * 1024; | ||
|
|
||
| /** Overridden by the tests covering the runner itself, so they need seconds rather than a minute. */ | ||
| protected int luaExecutionTimeoutSeconds() { | ||
| return LUA_EXECUTION_TIMEOUT_SECONDS; | ||
| } | ||
| private static volatile String resolvedLuaExecutable; | ||
| private static volatile String resolvedLuacExecutable; | ||
| private static volatile String extractedLuaWin; | ||
|
|
@@ -527,7 +542,6 @@ private void translateAndTestLua(String name, boolean executeProg, WurstGui gui, | |
| throw new org.testng.SkipException( | ||
| "Skipped Lua execution (translation and luac syntax check still ran): " + e.getMessage()); | ||
| } | ||
| String line; | ||
| // Preload the WC3 Lua runtime (Reforged blizzard.j dump + native shim) | ||
| // when available, so tests execute against real BJ implementations. | ||
| // The generated script only installs fallbacks for natives that are | ||
|
|
@@ -550,29 +564,27 @@ private void translateAndTestLua(String name, boolean executeProg, WurstGui gui, | |
| Process p = Runtime.getRuntime().exec(args); | ||
| StringBuilder errors = new StringBuilder(); | ||
| StringBuilder output = new StringBuilder(); | ||
| try (BufferedReader input = new BufferedReader(new InputStreamReader(p.getErrorStream()))) { | ||
| while ((line = input.readLine()) != null) { | ||
| System.err.println(line); | ||
| errors.append(line); | ||
| errors.append("\n"); | ||
| } | ||
| // Both pipes must be drained while the program runs, and the wait must end: a | ||
| // generated program that loops forever would otherwise hang the whole suite, | ||
| // and one that fills the stdout pipe would deadlock against a stderr-first read. | ||
| java.util.concurrent.atomic.AtomicBoolean sawTestSuccess = | ||
| new java.util.concurrent.atomic.AtomicBoolean(false); | ||
| Thread outCollector = collectStreamAsync(p.getInputStream(), output, "testSuccess", sawTestSuccess); | ||
| Thread errCollector = collectStreamAsync(p.getErrorStream(), errors); | ||
| if (!p.waitFor(luaExecutionTimeoutSeconds(), TimeUnit.SECONDS)) { | ||
| p.destroyForcibly(); | ||
| throw new Error(currentTestEnv + ": Lua program did not terminate within " | ||
| + luaExecutionTimeoutSeconds() + "s: " + luaFile.getName()); | ||
| } | ||
| outCollector.join(); | ||
| errCollector.join(); | ||
|
|
||
| if (errors.length() > 0) { | ||
| System.err.print(errors); | ||
| throw new TestFailException(errors.toString()); | ||
| } | ||
|
|
||
| boolean success = false; | ||
| try (BufferedReader input = new BufferedReader(new InputStreamReader(p.getInputStream()))) { | ||
| while ((line = input.readLine()) != null) { | ||
| if (line.equals("testSuccess")) { | ||
| success = true; | ||
| } | ||
| output.append(line); | ||
| output.append("\n"); | ||
| } | ||
| } | ||
| if (!success) { | ||
| if (!sawTestSuccess.get()) { | ||
| throw new Error(currentTestEnv + ": Succeed function not called"); | ||
| } | ||
| } | ||
|
|
@@ -621,11 +633,65 @@ private void checkLuaSyntax(String luacExecutable, File luaFile) throws IOExcept | |
| } | ||
|
|
||
| private Thread collectStreamAsync(InputStream stream, StringBuilder out) { | ||
| return collectStreamAsync(stream, out, null, null); | ||
| } | ||
|
|
||
| /** | ||
| * Drains {@code stream} into {@code out}, keeping only the first {@link #RETAINED_OUTPUT_LIMIT} | ||
| * characters. Reading never stops — that is what blocks the process and deadlocks the run — but | ||
| * a program that loops while printing would fill the heap before the timeout could fire. | ||
| * <p> | ||
| * Whatever the caller is looking for is recognised here rather than read back out of | ||
| * {@code out} afterwards, because it may arrive after the limit: a program that prints a | ||
| * million lines and then succeeds has still succeeded. | ||
| */ | ||
| private Thread collectStreamAsync(InputStream stream, StringBuilder out, | ||
| String watchedLine, java.util.concurrent.atomic.AtomicBoolean sawWatchedLine) { | ||
| Thread t = new Thread(() -> { | ||
| try (BufferedReader input = new BufferedReader(new InputStreamReader(stream))) { | ||
| String line; | ||
| while ((line = input.readLine()) != null) { | ||
| out.append(line).append("\n"); | ||
| // Fixed-size chunks rather than lines: a line is only bounded by what the program | ||
| // chose to print, and reading one materialises all of it before any limit here could | ||
| // apply. Nothing below holds more than the retained limit plus one chunk. | ||
| char[] chunk = new char[8 * 1024]; | ||
| StringBuilder pendingLine = new StringBuilder(); | ||
| boolean lineIsLongerThanWatched = false; | ||
| boolean truncated = false; | ||
| try (Reader input = new InputStreamReader(stream)) { | ||
| int read; | ||
| while ((read = input.read(chunk)) >= 0) { | ||
| if (watchedLine != null) { | ||
| for (int i = 0; i < read; i++) { | ||
| char c = chunk[i]; | ||
| if (c == '\n') { | ||
| if (!lineIsLongerThanWatched && pendingLine.length() == watchedLine.length() | ||
| && watchedLine.contentEquals(pendingLine)) { | ||
| sawWatchedLine.set(true); | ||
| } | ||
| pendingLine.setLength(0); | ||
| lineIsLongerThanWatched = false; | ||
| } else if (c != '\r' && !lineIsLongerThanWatched) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a tested Lua program prints an embedded carriage return—for example, AGENTS.md reference: AGENTS.md:L62-L62 Useful? React with 👍 / 👎. |
||
| // Only ever as long as what is being looked for; past that the | ||
| // line cannot be it, so there is no reason to keep any of it. | ||
| if (pendingLine.length() == watchedLine.length()) { | ||
| lineIsLongerThanWatched = true; | ||
| pendingLine.setLength(0); | ||
| } else { | ||
| pendingLine.append(c); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| int room = RETAINED_OUTPUT_LIMIT - out.length(); | ||
| if (room > 0) { | ||
| out.append(chunk, 0, Math.min(read, room)); | ||
| } | ||
| // Said as soon as anything is dropped, including when that happens part way | ||
| // through the last chunk there is: otherwise the output ends at exactly the | ||
| // limit and reads as though that were all the program had to say. | ||
| if (read > room && !truncated) { | ||
| truncated = true; | ||
| out.append("\n... further output dropped after ") | ||
| .append(RETAINED_OUTPUT_LIMIT).append(" characters\n"); | ||
| } | ||
| } | ||
| } catch (IOException ignored) { | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test only checks that execution finishes, so the previous
readLine()implementation also passes: retaining this roughly quarter-megabyte line does not fail the test, and the followingtestSuccessline is still recognized. Consequently, reverting to the unbounded-per-line collector would remain undetected; expose/assert the retained length or otherwise make this case fail when one complete oversized line is materialized or retained.AGENTS.md reference: AGENTS.md:L62-L64
Useful? React with 👍 / 👎.