From 309aecd70f4a66a7de253341b30f2c54a0cc4013 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 18 Aug 2026 20:07:22 +0200 Subject: [PATCH] Fix --profile function timing stack leak on nextfile inside a function Under --profile, executeNextfile unwound the runtime call frames without touching activeProfilingFunctions, so every nextfile executed from inside a user-defined function permanently leaked a deque entry (unbounded growth on long profiled runs) and the abandoned calls were missing from the function timing report, or accounted at program exit with wildly wrong durations. resetCallState() now records exits for all active profiling functions before unwinding, and executeNextfile delegates to it instead of its inline two-line unwind. This also fixes a second leak on the same path: elementArgumentReferences was never cleared on nextfile. The exception abort paths, which already went through resetCallState(), settle the profiling stack the same way. `next` inside a user-defined function, also named by the issue, is currently rejected at compile time (gawk accepts it); that compat gap is tracked separately in #580. Fixes #557 Co-Authored-By: Claude Fable 5 --- src/main/java/io/jawk/backend/AVM.java | 11 +++++-- src/site/markdown/behavior-changes.md | 6 ++++ src/test/java/io/jawk/CliOptionTest.java | 40 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/jawk/backend/AVM.java b/src/main/java/io/jawk/backend/AVM.java index f7144235..194ef256 100644 --- a/src/main/java/io/jawk/backend/AVM.java +++ b/src/main/java/io/jawk/backend/AVM.java @@ -2889,9 +2889,15 @@ private String normalizeIndirectFunctionName(String functionName) { /** * Unwinds every call frame and clears the per-call runtime state, after an - * {@code exit} statement or an abandoned execution. + * {@code exit} or {@code nextfile} statement or an abandoned execution. + * When profiling, the abandoned function calls are recorded as exited at + * this point, so the timing report stays accurate and the active-function + * stack does not leak entries (see #557). */ private void resetCallState() { + if (profiling) { + recordAllFunctionExits(System.nanoTime()); + } runtimeStack.popAllFrames(); elementArgumentReferences.clear(); clearOperandStack(); @@ -4180,8 +4186,7 @@ private void executeNextfile(PositionTracker position) { "`nextfile' cannot be called from an ENDFILE rule"); } // nextfile can be invoked from user-defined functions: unwind them. - runtimeStack.popAllFrames(); - clearOperandStack(); + resetCallState(); if (endFileAddress == null || withinBeginFileBlocks && jrt.hasPendingInputFileError(resolvedInputSource)) { // No ENDFILE rules to run, or the file could not be opened: skip diff --git a/src/site/markdown/behavior-changes.md b/src/site/markdown/behavior-changes.md index aa59fabb..dd4e3035 100644 --- a/src/site/markdown/behavior-changes.md +++ b/src/site/markdown/behavior-changes.md @@ -20,6 +20,12 @@ released version automatically via .github/scripts/stamp-behavior-changes.sh. ## Unreleased +- The `--profile` function timing report now records a user-defined function call abandoned by + `nextfile` as exited when `nextfile` unwinds it, with its actual duration. Previously each such + call leaked an entry on the internal function timing stack (unbounded memory growth on long + profiled runs), and the abandoned calls were either missing from the report or accounted with + wildly wrong durations at program exit + ([#557](https://github.com/jawkio/jawk/issues/557)). - A conditional expression whose result feeds a further operation on a literal — shapes like `(v ? v : 24) * 2`, `-(v ? 5 : 24)`, or `$(v ? 1 : 2)` — now evaluates correctly under the default tuple optimization. Previously the peephole literal fold merged the false branch's diff --git a/src/test/java/io/jawk/CliOptionTest.java b/src/test/java/io/jawk/CliOptionTest.java index fcd28ddb..8efbf2cc 100644 --- a/src/test/java/io/jawk/CliOptionTest.java +++ b/src/test/java/io/jawk/CliOptionTest.java @@ -41,6 +41,8 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.junit.Test; import org.junit.Rule; import org.junit.rules.TemporaryFolder; @@ -118,6 +120,44 @@ public void profileOptionRecordsIndirectUserFunctions() throws Exception { assertTrue(result.errorOutput().contains("typeof")); } + @Test + public void profileOptionRecordsFunctionsUnwoundByNextfile() throws Exception { + AwkTestSupport.TestResult result = AwkTestSupport + .cliTest("CLI --profile records functions unwound by nextfile") + .argument("--profile") + .script("function inner() { nextfile } function outer() { inner() } { outer() }") + .file("f1", "a1\na2\n") + .file("f2", "b1\nb2\n") + .file("f3", "c1\nc2\n") + .operand("{{f1}}", "{{f2}}", "{{f3}}") + .expect("") + .run(); + + result.assertExpected(); + // Each file triggers exactly one outer() -> inner() -> nextfile chain; + // nextfile abandons both calls, which must still be recorded once each. + assertEquals(3, profiledFunctionCount(result.errorOutput(), "inner")); + assertEquals(3, profiledFunctionCount(result.errorOutput(), "outer")); + } + + /** + * Extracts the execution count of one function from a {@code --profile} + * report. + * + * @param report the profiling report text + * @param functionName the function to look up + * @return the reported execution count + */ + private static long profiledFunctionCount(String report, String functionName) { + Matcher matcher = Pattern + .compile("^\\s{2}" + Pattern.quote(functionName) + "\\s+(\\d+)\\s", Pattern.MULTILINE) + .matcher(report); + assertTrue( + "function `" + functionName + "' missing from profiling report:\n" + report, + matcher.find()); + return Long.parseLong(matcher.group(1)); + } + @Test public void profileOptionWithFilenameWritesReportToFile() throws Exception { File profile = tempFolder.newFile("profile.txt");