This is an automated email from the ASF dual-hosted git repository.

Cole-Greer pushed a commit to branch docs-3.7
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git

commit 0d8d9426456e558bd9cc492a9ce09567cefa2172
Author: Cole Greer <[email protected]>
AuthorDate: Wed Jun 3 10:28:26 2026 -0700

    Fail docs build on Gremlin execution errors instead of silent skip
    
    Rework the docs build so a genuine Gremlin error fails the build rather
    than rendering as a silently-empty block. Investigation confirmed no
    executed gremlin-groovy block is expected to error: error output goes to
    the console's stderr (the "Display stack trace?" prompt), which neither
    the old nor new build captured, and all error examples in the docs are
    hand-authored [source,text] blocks.
    
    GremlinConsole now records the stderr error prompt and surfaces it from
    execute() as a GremlinExecutionException; the treeprocessor propagates it
    (fatal) and the silent dry-run fallback is removed. The 90s timeout
    remains a failsafe and single restart-and-retry recovery is preserved.
    
    Enabling this surfaced several previously-masked setup issues, also fixed:
    - buildStatements tracks bracket depth so multi-line Groovy closures
      (e.g. "(1..10).each { ... }; []") stay grouped instead of hanging at a
      continuation prompt.
    - initGraphIfNeeded closes the prior graph and clears /tmp/neo4j and
      /tmp/tinkergraph.kryo before each block, so a stale Neo4j store lock no
      longer hangs Neo4jGraph.open (mirrors the old preprocessor).
    - SugarLoader runs on a freshly restarted console so it takes effect on a
      pristine Groovy metaclass.
    - process-docs.sh resolves the Neo4j DB impl onto the console classpath,
      strips only the conflicting io.netty 4.1.24 (keeping netty-3.9.x that
      Neo4j needs), and registers TinkerGraph and Credential plugins.
    
    REQUIREMENTS.md FR-4 updated. A remaining Neo4j/Spark Scala classpath
    conflict requiring per-book plugin isolation is tracked separately.
    
    (tinkerpop-6jq.14)
    Assisted-by: Kiro:claude-opus-4.8 [Kiro CLI]
---
 bin/process-docs.sh                                |  33 ++++-
 tools/tinkerpop-docs/REQUIREMENTS.md               |  14 +-
 .../tinkerpop/gremlin/docs/GremlinConsole.java     |  62 +++++----
 .../gremlin/docs/GremlinTreeprocessor.java         | 141 ++++++++++++---------
 .../tinkerpop/gremlin/docs/GremlinConsoleTest.java |  62 ++++++++-
 .../gremlin/docs/GremlinTreeprocessorTest.java     |  67 +++++++++-
 6 files changed, 281 insertions(+), 98 deletions(-)

diff --git a/bin/process-docs.sh b/bin/process-docs.sh
index ea5345900d..81ac12c7db 100755
--- a/bin/process-docs.sh
+++ b/bin/process-docs.sh
@@ -130,9 +130,40 @@ for plugin in ${PLUGINS}; do
   fi
 done
 
+# 3b. Resolve the Neo4j database implementation onto the console classpath.
+# neo4j-gremlin declares neo4j-tinkerpop-api-impl as a 
Gremlin-Plugin-Dependencies manifest
+# entry (test-scoped / gated behind the includeNeo4j profile), so it is not 
bundled by the
+# local jar copy above. The old console ':install' flow fetched it via 
DependencyGrabber;
+# here we resolve it (and its transitive deps) from Maven so Neo4jGraph blocks 
can execute.
+NEO4J_IMPL_VERSION="0.9-3.4.0"
+NEO4J_PLUGIN_LIB="${CONSOLE_HOME}/ext/neo4j-gremlin/lib"
+if [ -d "${NEO4J_PLUGIN_LIB}" ] && ! ls 
"${NEO4J_PLUGIN_LIB}"/neo4j-tinkerpop-api-impl-*.jar >/dev/null 2>&1; then
+  echo " * resolving Neo4j implementation 
(neo4j-tinkerpop-api-impl:${NEO4J_IMPL_VERSION})"
+  NEO4J_POM="${TP_HOME}/target/neo4j-impl-pom.xml"
+  cat > "${NEO4J_POM}" <<POM
+<project 
xmlns="http://maven.apache.org/POM/4.0.0";><modelVersion>4.0.0</modelVersion>
+<groupId>org.apache.tinkerpop.docs</groupId><artifactId>neo4j-impl-resolver</artifactId><version>1</version>
+<dependencies><dependency><groupId>org.neo4j</groupId><artifactId>neo4j-tinkerpop-api-impl</artifactId><version>${NEO4J_IMPL_VERSION}</version></dependency></dependencies></project>
+POM
+  mvn -q -f "${NEO4J_POM}" dependency:copy-dependencies 
-DoutputDirectory="${NEO4J_PLUGIN_LIB}"
+  # Drop ONLY the conflicting io.netty 4.x jar that Neo4j pulls in 
(netty-all-4.1.24): it
+  # contains an older 
io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker13 that
+  # shadows the console driver's 4.1.125 class and breaks ':remote' server 
connections with a
+  # NoSuchMethodError. Keep netty-3.9.x (org.jboss.netty package) -- it does 
NOT conflict and
+  # is required by Neo4j 3.4's IO layer.
+  rm -f "${NEO4J_PLUGIN_LIB}"/netty-all-4.*.jar
+  cp "${NEO4J_PLUGIN_LIB}/"*.jar "${CONSOLE_HOME}/lib/" 2>/dev/null
+fi
+
 # 4. Register plugins in console
 echo "Registering plugins..."
-PLUGIN_CLASSES="org.apache.tinkerpop.gremlin.hadoop.jsr223.HadoopGremlinPlugin
+# TinkerGraphGremlinPlugin must be (re)registered explicitly: the console 
rewrites plugins.txt
+# to the set of successfully-activated plugins on startup, and a transient 
activation hiccup
+# while bringing up the newly-added plugins can otherwise drop tinkergraph -- 
leaving
+# TinkerFactory/TinkerGraph unavailable and failing the first doc block.
+PLUGIN_CLASSES="org.apache.tinkerpop.gremlin.tinkergraph.jsr223.TinkerGraphGremlinPlugin
+org.apache.tinkerpop.gremlin.groovy.jsr223.dsl.credential.CredentialGraphGremlinPlugin
+org.apache.tinkerpop.gremlin.hadoop.jsr223.HadoopGremlinPlugin
 org.apache.tinkerpop.gremlin.spark.jsr223.SparkGremlinPlugin
 org.apache.tinkerpop.gremlin.neo4j.jsr223.Neo4jGremlinPlugin
 org.apache.tinkerpop.gremlin.sparql.jsr223.SparqlGremlinPlugin"
diff --git a/tools/tinkerpop-docs/REQUIREMENTS.md 
b/tools/tinkerpop-docs/REQUIREMENTS.md
index 0c7d444a04..2df1d336c2 100644
--- a/tools/tinkerpop-docs/REQUIREMENTS.md
+++ b/tools/tinkerpop-docs/REQUIREMENTS.md
@@ -77,9 +77,17 @@ Supported graph names: `modern`, `classic`, `theCrew`, 
`kitchenSink`, `gratefulD
 
 ### FR-4: Error Handling in Code Blocks
 
-- When a Gremlin statement produces a runtime error, the extension SHALL 
capture the error message and include it in the console output — this IS the 
expected output for documentation purposes.
-- Errors in code blocks SHALL NOT fail the docs build.
-- The "Display stack trace? [yN]" prompt SHALL be dismissed automatically 
without user intervention.
+- When a Gremlin statement produces a runtime error (signalled by the console's
+  `Display stack trace? [yN]` prompt), the extension SHALL fail the docs 
build, reporting the
+  failing statement. Executed `gremlin-groovy` blocks are not expected to 
error; documentation
+  examples that intentionally show errors are authored as manual 
`[source,text]`/`[source,<lang>]`
+  blocks (Gremlin error output goes to the console's stderr and is not 
captured into rendered
+  output by design).
+- The "Display stack trace? [yN]" prompt SHALL be answered automatically 
(without user
+  intervention) solely to unblock the console process and detect the error; 
the build then fails.
+- A statement exceeding the execution timeout, or a console that dies and does 
not recover after
+  a single restart-and-retry, SHALL fail the build. The timeout is a failsafe 
against indefinite
+  hanging, not an expected path.
 
 ### FR-5: Manual Language Variant Tabs
 
diff --git 
a/tools/tinkerpop-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinConsole.java
 
b/tools/tinkerpop-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinConsole.java
index 530a26a79a..2ae49ee26a 100644
--- 
a/tools/tinkerpop-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinConsole.java
+++ 
b/tools/tinkerpop-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinConsole.java
@@ -53,6 +53,10 @@ public class GremlinConsole implements Closeable {
     private final long timeoutMs;
     private volatile boolean running;
     private boolean inEscape;
+    private volatile boolean errorPromptSeen;
+    private volatile String lastErrorText = "";
+    private final StringBuilder errorCapture = new StringBuilder();
+    private static final int MAX_ERROR_CAPTURE = 8192;
 
     /**
      * Creates a new GremlinConsole by starting the {@code bin/gremlin.sh} 
subprocess.
@@ -93,14 +97,23 @@ public class GremlinConsole implements Closeable {
      *
      * @param statement the Gremlin statement to execute
      * @return the console output for this statement
-     * @throws IOException             if an I/O error occurs
-     * @throws ConsoleTimeoutException if the prompt is not received within 
the timeout period
+     * @throws IOException              if an I/O error occurs
+     * @throws ConsoleTimeoutException  if the prompt is not received within 
the timeout period
+     * @throws GremlinExecutionException if the statement produced a Gremlin 
error
      */
     public String execute(final String statement) throws IOException, 
ConsoleTimeoutException {
-        dismissPendingErrorPrompt();
+        synchronized (stderrStdinLock) {
+            errorPromptSeen = false;
+            lastErrorText = "";
+            errorCapture.setLength(0);
+        }
         stdin.write(statement + "\n");
         stdin.flush();
-        return readUntilPrompt();
+        final String output = readUntilPrompt();
+        if (errorPromptSeen) {
+            throw new GremlinExecutionException(statement, lastErrorText);
+        }
+        return output;
     }
 
     @Override
@@ -190,27 +203,12 @@ public class GremlinConsole implements Closeable {
         }
     }
 
-    /**
-     * Dismisses any pending error prompt before sending a new statement.
-     */
-    private void dismissPendingErrorPrompt() throws IOException {
-        synchronized (stderrStdinLock) {
-            if (stderr.ready()) {
-                final StringBuilder errBuf = new StringBuilder();
-                while (stderr.ready()) {
-                    errBuf.append((char) stderr.read());
-                }
-                if (errBuf.toString().contains(ERROR_PROMPT)) {
-                    stdin.write("\n");
-                    stdin.flush();
-                }
-            }
-        }
-    }
-
     /**
      * Background thread that monitors stderr and automatically dismisses 
error prompts.
-     * Uses a sliding window of the last N characters where N = 
ERROR_PROMPT.length().
+     * The {@code Display stack trace? [yN]} prompt is the signal that the 
current statement
+     * raised a Gremlin error; the prompt is answered to unblock the process 
and the error is
+     * recorded so {@link #execute(String)} can surface it as a {@link 
GremlinExecutionException}.
+     * Error report text precedes the prompt on stderr, so it is captured for 
the message.
      */
     private void dismissErrorPrompts() {
         final int windowSize = ERROR_PROMPT.length();
@@ -222,11 +220,17 @@ public class GremlinConsole implements Closeable {
                         final int ch = stderr.read();
                         if (ch == -1) return;
                         errBuffer.append((char) ch);
+                        if (errorCapture.length() < MAX_ERROR_CAPTURE) {
+                            errorCapture.append((char) ch);
+                        }
 
                         if (errBuffer.toString().contains(ERROR_PROMPT)) {
                             stdin.write("\n");
                             stdin.flush();
+                            lastErrorText = errorCapture.toString().trim();
+                            errorPromptSeen = true;
                             errBuffer.setLength(0);
+                            errorCapture.setLength(0);
                         } else if (errBuffer.length() > windowSize) {
                             errBuffer.delete(0, errBuffer.length() - 
windowSize);
                         }
@@ -267,4 +271,16 @@ public class GremlinConsole implements Closeable {
             super(message);
         }
     }
+
+    /**
+     * Thrown when an executed statement produced a Gremlin error (signalled 
by the console's
+     * {@code Display stack trace? [yN]} prompt). Such errors must fail the 
docs build rather
+     * than render as silently-empty output.
+     */
+    public static class GremlinExecutionException extends RuntimeException {
+        public GremlinExecutionException(final String statement, final String 
errorText) {
+            super("Gremlin statement failed: " + statement
+                    + (errorText == null || errorText.isEmpty() ? "" : "\n" + 
errorText));
+        }
+    }
 }
diff --git 
a/tools/tinkerpop-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeprocessor.java
 
b/tools/tinkerpop-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeprocessor.java
index 41e5c238b0..5185cdccde 100644
--- 
a/tools/tinkerpop-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeprocessor.java
+++ 
b/tools/tinkerpop-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeprocessor.java
@@ -429,37 +429,12 @@ public class GremlinTreeprocessor extends Treeprocessor {
         try {
             return doBuildConsoleOutput(block, dryRun);
         } catch (final ConsoleRestartedException e) {
-            // Console was restarted — retry the entire block on the fresh 
console
+            // Console was restarted — retry the entire block once on the 
fresh console
             LOG.info("Retrying block after console restart");
-            try {
-                return doBuildConsoleOutput(block, dryRun);
-            } catch (final ConsoleRestartedException e2) {
-                // Block is genuinely broken — skip it
-                LOG.warning("Block failed after retry, skipping: " + 
e2.getMessage());
-                return buildDryRunOutput(block);
-            }
+            return doBuildConsoleOutput(block, dryRun);
         }
     }
 
-    private String buildDryRunOutput(final Block block) {
-        final String source = block.getSource();
-        if (source == null || source.isEmpty()) return "";
-        final StringBuilder output = new StringBuilder();
-        final String[] lines = source.split("\\r?\\n");
-        final List<String> statements = buildDisplayStatements(lines);
-        for (final String statement : statements) {
-            final String[] stmtLines = statement.split("\\r?\\n");
-            for (int l = 0; l < stmtLines.length; l++) {
-                if (l == 0) {
-                    output.append(PROMPT).append(stmtLines[l]).append("\n");
-                } else {
-                    output.append("         
").append(stmtLines[l]).append("\n");
-                }
-            }
-        }
-        return output.toString().stripTrailing();
-    }
-
     private String doBuildConsoleOutput(final Block block, final boolean 
dryRun) {
         if (!dryRun) {
             ensureConsoleStarted();
@@ -474,17 +449,17 @@ public class GremlinTreeprocessor extends Treeprocessor {
         final List<String> displayStatements = buildDisplayStatements(lines);
         final List<String> execStatements = buildStatements(lines);
 
-        // Sugar syntax permanently mutates the Groovy metaclass, so run sugar
-        // blocks on a fresh console and mark it dirty to force a restart 
after.
+        // Sugar syntax permanently mutates the Groovy metaclass and only 
takes effect when
+        // SugarLoader.load() runs on a pristine metaclass -- i.e. BEFORE any 
traversal has been
+        // used in the JVM. Since the console is long-lived and has executed 
prior blocks, always
+        // restart to get a fresh console, then load sugar before initializing 
the graph/source.
         final boolean needsSugar = !dryRun && 
execStatements.stream().anyMatch(GremlinTreeprocessor::usesSugarSyntax);
         if (needsSugar && getActiveExecutor() != null) {
-            if (sugarLoaded) {
-                restartConsole();
-            }
-            final String graphName = extractGraphName(block);
-            initGraphIfNeeded(graphName);
+            restartConsole();
             
executeSafely("org.apache.tinkerpop.gremlin.groovy.loaders.SugarLoader.load()");
             sugarLoaded = true;
+            final String graphName = extractGraphName(block);
+            initGraphIfNeeded(graphName);
         } else if (!dryRun && getActiveExecutor() != null) {
             if (sugarLoaded) {
                 // Previous block loaded sugar; restart to get a clean 
metaclass.
@@ -526,28 +501,7 @@ public class GremlinTreeprocessor extends Treeprocessor {
      * Groups source lines into complete statements for execution. Strips 
callouts.
      */
     static List<String> buildStatements(final String[] lines) {
-        final List<String> statements = new ArrayList<>();
-        final StringBuilder current = new StringBuilder();
-        boolean inTripleQuote = false;
-        for (final String line : lines) {
-            final String cleaned = stripCallouts(line);
-            // Track triple-quote state
-            final int tqCount = countOccurrences(cleaned, "\"\"\"");
-            if (current.length() == 0) {
-                current.append(cleaned);
-            } else if (inTripleQuote || (cleaned.length() > 0 && 
Character.isWhitespace(cleaned.charAt(0)))) {
-                current.append("\n").append(cleaned);
-            } else {
-                statements.add(current.toString());
-                current.setLength(0);
-                current.append(cleaned);
-            }
-            if (tqCount % 2 != 0) inTripleQuote = !inTripleQuote;
-        }
-        if (current.length() > 0) {
-            statements.add(current.toString());
-        }
-        return statements;
+        return groupStatements(lines, true);
     }
 
     private static int countOccurrences(final String str, final String sub) {
@@ -561,21 +515,42 @@ public class GremlinTreeprocessor extends Treeprocessor {
      * Groups source lines into complete statements for display. Preserves 
callouts.
      */
     static List<String> buildDisplayStatements(final String[] lines) {
+        return groupStatements(lines, false);
+    }
+
+    /**
+     * Groups source lines into complete statements. A new statement starts 
only at a
+     * non-indented line when the accumulated statement has balanced brackets 
and is not
+     * inside a triple-quoted string. Tracking bracket depth keeps multi-line 
Groovy
+     * constructs (e.g. {@code (1..10).each \{ ... \}}) together even though 
their closing
+     * line is not indented, which would otherwise send an incomplete 
statement to the
+     * console and hang at a continuation prompt.
+     *
+     * @param strip when {@code true}, callouts are stripped from the emitted 
text (for
+     *              execution); when {@code false}, the original line is 
emitted (for display)
+     */
+    private static List<String> groupStatements(final String[] lines, final 
boolean strip) {
         final List<String> statements = new ArrayList<>();
         final StringBuilder current = new StringBuilder();
         boolean inTripleQuote = false;
+        int depth = 0;
         for (final String line : lines) {
-            final String stripped = stripCallouts(line);
-            final int tqCount = countOccurrences(stripped, "\"\"\"");
+            final String detect = stripCallouts(line);
+            final String content = strip ? detect : line;
+            final int tqCount = countOccurrences(detect, "\"\"\"");
+            final boolean touchesTriple = inTripleQuote || tqCount > 0;
             if (current.length() == 0) {
-                current.append(line);
-            } else if (inTripleQuote || (stripped.length() > 0 && 
Character.isWhitespace(stripped.charAt(0)))) {
-                current.append("\n").append(line);
+                current.append(content);
+            } else if (inTripleQuote || depth > 0
+                    || (detect.length() > 0 && 
Character.isWhitespace(detect.charAt(0)))) {
+                current.append("\n").append(content);
             } else {
                 statements.add(current.toString());
                 current.setLength(0);
-                current.append(line);
+                depth = 0;
+                current.append(content);
             }
+            if (!touchesTriple) depth += bracketDelta(detect);
             if (tqCount % 2 != 0) inTripleQuote = !inTripleQuote;
         }
         if (current.length() > 0) {
@@ -584,6 +559,36 @@ public class GremlinTreeprocessor extends Treeprocessor {
         return statements;
     }
 
+    /**
+     * Net change in bracket nesting ({@code (} {@code [} {@code \{}) 
contributed by a line,
+     * ignoring brackets inside single- or double-quoted string literals (so 
GString
+     * interpolation like {@code ${it}} does not affect the count).
+     */
+    private static int bracketDelta(final String s) {
+        int d = 0;
+        boolean sq = false;
+        boolean dq = false;
+        for (int i = 0; i < s.length(); i++) {
+            final char c = s.charAt(i);
+            if (sq) {
+                if (c == '\\') i++;
+                else if (c == '\'') sq = false;
+            } else if (dq) {
+                if (c == '\\') i++;
+                else if (c == '"') dq = false;
+            } else if (c == '\'') {
+                sq = true;
+            } else if (c == '"') {
+                dq = true;
+            } else if (c == '{' || c == '(' || c == '[') {
+                d++;
+            } else if (c == '}' || c == ')' || c == ']') {
+                d--;
+            }
+        }
+        return d;
+    }
+
     /**
      * Strips AsciiDoc callout markers (e.g. {@code <1>}, {@code <2>}) from 
the end of a line.
      */
@@ -628,6 +633,15 @@ public class GremlinTreeprocessor extends Treeprocessor {
             return;
         }
 
+        // Clear file-backed graph stores reused across blocks. Neo4j holds an 
exclusive
+        // store lock, so a stale '/tmp/neo4j' left by a prior block (or prior 
build run)
+        // makes the next Neo4jGraph.open('/tmp/neo4j') hang acquiring the 
lock. Close any
+        // prior graph to release its lock, then delete the dirs -- mirroring 
the old
+        // preprocessor which cleared these before every graph-init block.
+        executeSafely("try { if (binding.hasVariable('graph') && graph != 
null) graph.close() } catch (e) {}");
+        executeSafely("['/tmp/neo4j', '/tmp/tinkergraph.kryo'].each { p -> "
+                + "def f = new File(p); if (f.exists()) f.deleteDir() }");
+
         final String initStatement;
         if (graphName == null) {
             initStatement = "graph = TinkerGraph.open()";
@@ -647,6 +661,9 @@ public class GremlinTreeprocessor extends Treeprocessor {
     private String executeSafely(final String statement) {
         try {
             return getActiveExecutor().execute(statement);
+        } catch (final GremlinConsole.GremlinExecutionException e) {
+            // A genuine Gremlin error must fail the build, not render as 
empty output.
+            throw e;
         } catch (final GremlinConsole.ConsoleTimeoutException | IOException e) 
{
             // Console may have died — restart it and propagate to retry at 
block level
             LOG.warning("Console appears dead, restarting: " + e.getMessage());
diff --git 
a/tools/tinkerpop-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinConsoleTest.java
 
b/tools/tinkerpop-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinConsoleTest.java
index 15ce98f154..d1a7fe5c86 100644
--- 
a/tools/tinkerpop-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinConsoleTest.java
+++ 
b/tools/tinkerpop-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinConsoleTest.java
@@ -29,9 +29,12 @@ import java.io.PipedInputStream;
 import java.io.PipedOutputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.instanceOf;
 import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.notNullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
 
 /**
@@ -136,7 +139,10 @@ public class GremlinConsoleTest {
 
     @Test
     public void shouldDismissErrorPromptOnStderr() throws Exception {
-        final String fullStdout = "gremlin>" + "gremlin>";
+        // The async dismisser must answer the "Display stack trace? [yN]" 
prompt on stderr
+        // (sending a newline to stdin) so the console process is never left 
blocked. Surfacing
+        // the error to the caller is covered by 
shouldThrowExecutionExceptionWhenErrorPromptSeen.
+        final String fullStdout = "gremlin>";
         final String stderrContent = "Display stack trace? [yN]";
         final ByteArrayOutputStream capturedStdin = new 
ByteArrayOutputStream();
         final GremlinConsole console = createConsoleWithStdin(fullStdout, 
stderrContent, capturedStdin);
@@ -147,8 +153,6 @@ public class GremlinConsoleTest {
             while (capturedStdin.size() == 0 && System.currentTimeMillis() < 
deadline) {
                 Thread.sleep(10);
             }
-            final String result = console.execute("g.V()");
-            assertThat(result, is(""));
             // Verify that a newline was sent to dismiss the error prompt
             final String sent = 
capturedStdin.toString(StandardCharsets.UTF_8.name());
             assertThat(sent.contains("\n"), is(true));
@@ -157,6 +161,58 @@ public class GremlinConsoleTest {
         }
     }
 
+    @Test
+    public void shouldThrowExecutionExceptionWhenErrorPromptSeen() throws 
Exception {
+        // Mirror the real console ordering: after the statement is sent, the 
error report +
+        // "Display stack trace?" prompt arrive on stderr and the process 
blocks on stdin until
+        // answered, so the next gremlin> prompt on stdout appears only AFTER 
the dismisser
+        // replies. execute() must then surface a GremlinExecutionException so 
the build fails.
+        final PipedOutputStream stdoutFeeder = new PipedOutputStream();
+        final PipedInputStream stdoutStream = new 
PipedInputStream(stdoutFeeder);
+        stdoutFeeder.write("gremlin>".getBytes(StandardCharsets.UTF_8));
+        stdoutFeeder.flush();
+
+        final PipedOutputStream stderrFeeder = new PipedOutputStream();
+        final PipedInputStream stderrStream = new 
PipedInputStream(stderrFeeder);
+        final ByteArrayOutputStream capturedStdin = new 
ByteArrayOutputStream();
+        final GremlinConsole console = new GremlinConsole(
+                new MockProcess(stdoutStream, stderrStream, capturedStdin), 
5_000);
+
+        final AtomicReference<Throwable> thrown = new AtomicReference<>();
+        final Thread exec = new Thread(() -> {
+            try {
+                console.execute("g.V().fail('boom')");
+            } catch (final Throwable t) {
+                thrown.set(t);
+            }
+        });
+        exec.start();
+        try {
+            // Emit the error report + prompt on stderr only after execute() 
has started, so the
+            // per-statement error flag is reset before the dismisser observes 
the prompt.
+            Thread.sleep(200);
+            stderrFeeder.write("fail() Step Triggered\nDisplay stack trace? 
[yN]".getBytes(StandardCharsets.UTF_8));
+            stderrFeeder.flush();
+            // Wait for the dismisser to answer (an extra newline beyond the 
statement itself,
+            // which execute() also wrote to stdin), then release the next 
prompt on stdout.
+            final int statementBytes = 
"g.V().fail('boom')\n".getBytes(StandardCharsets.UTF_8).length;
+            final long deadline = System.currentTimeMillis() + 5000;
+            while (capturedStdin.size() <= statementBytes && 
System.currentTimeMillis() < deadline) {
+                Thread.sleep(10);
+            }
+            stdoutFeeder.write("gremlin>".getBytes(StandardCharsets.UTF_8));
+            stdoutFeeder.flush();
+            exec.join(5000);
+        } finally {
+            console.close();
+            stdoutFeeder.close();
+            stderrFeeder.close();
+        }
+        assertThat(thrown.get(), is(notNullValue()));
+        assertThat(thrown.get(), 
instanceOf(GremlinConsole.GremlinExecutionException.class));
+        assertThat(thrown.get().getMessage(), 
containsString("g.V().fail('boom')"));
+    }
+
     @Test
     public void shouldThrowIOExceptionWhenProcessDiesMidRead() throws 
Exception {
         final PipedOutputStream feeder = new PipedOutputStream();
diff --git 
a/tools/tinkerpop-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeprocessorTest.java
 
b/tools/tinkerpop-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeprocessorTest.java
index 55c93a7e62..ac15a64679 100644
--- 
a/tools/tinkerpop-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeprocessorTest.java
+++ 
b/tools/tinkerpop-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeprocessorTest.java
@@ -23,7 +23,6 @@ import org.asciidoctor.Attributes;
 import org.asciidoctor.Options;
 import org.junit.Test;
 
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -164,16 +163,41 @@ public class GremlinTreeprocessorTest {
     }
 
     @Test
-    public void shouldCaptureErrorsWithoutFailingBuild() {
-        final GremlinTreeprocessor.StatementExecutor failingExecutor = 
statement -> {
-            throw new IOException("Simulated error");
+    public void shouldFailBuildWhenStatementErrors() {
+        // A statement that raises a Gremlin error (signalled by the console 
via a
+        // GremlinExecutionException) must fail the docs build rather than 
render as a
+        // silently-empty block.
+        final String erroringStatement = "g.V().fail('we failed!')";
+        final GremlinTreeprocessor.StatementExecutor erroringExecutor = 
statement -> {
+            if (statement.equals(erroringStatement)) {
+                throw new GremlinConsole.GremlinExecutionException(statement, 
"fail() Step Triggered");
+            }
+            return "==>ok";
         };
-        final GremlinTreeprocessor processor = new 
GremlinTreeprocessor(failingExecutor);
+        final GremlinTreeprocessor processor = new 
GremlinTreeprocessor(erroringExecutor);
 
         try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
             asciidoctor.unregisterAllExtensions();
             asciidoctor.javaExtensionRegistry().treeprocessor(processor);
-            final String input = "= 
Test\n\n[gremlin-groovy,modern]\n----\ninvalid()\n----\n";
+            final String input = "= Test\n\n[gremlin-groovy,modern]\n----\n" + 
erroringStatement + "\n----\n";
+            asciidoctor.convert(input, Options.builder().build());
+            throw new AssertionError("Expected build to fail with 
GremlinExecutionException");
+        } catch (final GremlinConsole.GremlinExecutionException e) {
+            assertThat(e.getMessage(), containsString(erroringStatement));
+        }
+    }
+
+    @Test
+    public void shouldNotFailBuildForEmptyResultBlock() {
+        // A block that legitimately produces no ==> result (e.g. a void 
iterate() or an empty
+        // service list) is NOT an error and must build successfully.
+        final RecordingExecutor executor = new RecordingExecutor("");
+        final GremlinTreeprocessor processor = new 
GremlinTreeprocessor(executor);
+
+        try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
+            asciidoctor.unregisterAllExtensions();
+            asciidoctor.javaExtensionRegistry().treeprocessor(processor);
+            final String input = "= 
Test\n\n[gremlin-groovy,modern]\n----\ng.V().iterate()\n----\n";
             final String result = asciidoctor.convert(input, 
Options.builder().build());
             assertThat(result, is(notNullValue()));
             assertThat(processor.getGremlinBlockCount(), is(1));
@@ -199,6 +223,37 @@ public class GremlinTreeprocessorTest {
         }
     }
 
+    @Test
+    public void shouldKeepMultiLineClosureAsSingleStatement() {
+        // A Groovy closure whose closing line is not indented (e.g. "}; []") 
must stay grouped
+        // with its opening line; splitting it would send an incomplete 
statement to the console
+        // and hang at a continuation prompt.
+        final String[] lines = {
+                "(1..10).each {",
+                "  g.addV(\"product\").property(\"name\",\"product 
#${it}\").iterate()",
+                "}; []",
+                "g.V().count()"
+        };
+        final List<String> statements = 
GremlinTreeprocessor.buildStatements(lines);
+        assertThat(statements.size(), is(2));
+        assertThat(statements.get(0), containsString("(1..10).each {"));
+        assertThat(statements.get(0), containsString("}; []"));
+        assertThat(statements.get(1), is("g.V().count()"));
+    }
+
+    @Test
+    public void shouldNotCountBracketsInsideStrings() {
+        // Brackets inside string literals (including GString interpolation) 
must not affect
+        // statement grouping.
+        final String[] lines = {
+                "x = '}'",
+                "y = \"${a}\"",
+                "z = 1"
+        };
+        final List<String> statements = 
GremlinTreeprocessor.buildStatements(lines);
+        assertThat(statements.size(), is(3));
+    }
+
     @Test
     public void shouldHandleCrewGraph() {
         final RecordingExecutor executor = new RecordingExecutor("==>result");

Reply via email to