Skip to content

fix(workbench): raise ExecError immediately on terminal_run failure#445

Open
ianpittwood wants to merge 3 commits into
mainfrom
worktree-fix-issue-439-terminal-run-timeout
Open

fix(workbench): raise ExecError immediately on terminal_run failure#445
ianpittwood wants to merge 3 commits into
mainfrom
worktree-fix-issue-439-terminal-run-timeout

Conversation

@ianpittwood

Copy link
Copy Markdown
Contributor

Summary

  • terminal_run's shell wrapper only wrote its done-marker via &&, so a fast-failing command (non-zero exit) never wrote the marker at all -- the polling loop then waited out the full configured timeout and raised a generic, misleading ExecError, discarding the real output.
  • The marker is now written unconditionally via ;, with the command's exit code appended (marker:$?). A new pure helper, _parse_done_marker, parses it.
  • terminal_run now raises ExecError immediately with the real captured output when the exit code is non-zero, instead of spinning until the timeout elapses. Commands that are genuinely still running (no marker at all) still time out as before.

Fixes #439

Test plan

  • Added TestParseDoneMarker (6 cases) covering the new pure helper
  • Added TestTerminalRun (4 cases, mocked Playwright page) covering: marker written unconditionally, success path, immediate ExecError on non-zero exit, and the still-hung timeout path
  • Full selftest suite passes
  • just check (ruff lint + format) passes

Demo

Fix #439: terminal_run swallows real command errors as a generic timeout

Root cause: terminal_run's shell wrapper only wrote the done-marker via &&, so a fast-failing command (non-zero exit) never wrote the marker at all. The polling loop then waited out the full timeout and raised a generic 'timed out' error, discarding the real output sitting in the temp file the whole time.

The fix writes the marker unconditionally via ; with the exit code appended (marker:$?), and a new pure helper _parse_done_marker parses it. terminal_run now raises ExecError immediately with the real captured output when the exit code is non-zero, instead of waiting out the full configured timeout.

Before: the old && marker is silently dropped on failure

This reproduces the exact shell wrapper that used to run inside the IDE terminal.

tmpfile=$(mktemp); cmd="false"; done_marker="VIP_DONE_old"; sh -c "${cmd} > ${tmpfile} 2>&1 && echo \"${done_marker}\" >> ${tmpfile}"; echo "--- tmpfile contents after a failing command ---"; cat "${tmpfile}"; echo "--- (marker present? )"; grep -q "${done_marker}" "${tmpfile}" && echo yes || echo "no -- polling loop would spin until timeout"
--- tmpfile contents after a failing command ---
--- (marker present? )
no -- polling loop would spin until timeout

After: the new ; marker is always written, with the exit code

This reproduces the new shell wrapper used by the fixed terminal_run.

tmpfile=$(mktemp); cmd="ls /this/path/does/not/exist"; done_marker="VIP_DONE_new"; sh -c "${cmd} > ${tmpfile} 2>&1; echo \"${done_marker}:\$?\" >> ${tmpfile}"; echo "--- tmpfile contents after a failing command ---"; cat "${tmpfile}"; echo "--- (marker present? )"; grep -q "${done_marker}" "${tmpfile}" && echo "yes -- terminal_run parses this and raises ExecError immediately with the real output" || echo no
--- tmpfile contents after a failing command ---
ls: cannot access '/this/path/does/not/exist': No such file or directory
VIP_DONE_new:2
--- (marker present? )
yes -- terminal_run parses this and raises ExecError immediately with the real output

New unit tests covering the fix

_parse_done_marker is a pure, Playwright-free helper (matching this module's existing pattern) that parses the marker+exit-code line. terminal_run itself is also covered end-to-end with a mocked Playwright page, confirming: the marker is written unconditionally, success still returns output, a non-zero exit raises ExecError immediately (single poll, not a full timeout loop), and a genuinely hung command still times out.

uv run pytest selftests/test_workbench_exec.py -k "TestParseDoneMarker or TestTerminalRun" -v -n0 2>&1 | grep -E "PASSED|FAILED|ERROR|passed|failed" | sed -E "s/ in [0-9.]+s//"
selftests/test_workbench_exec.py::TestParseDoneMarker::test_returns_none_when_marker_absent PASSED [ 10%]
selftests/test_workbench_exec.py::TestParseDoneMarker::test_parses_success_exit_code PASSED [ 20%]
selftests/test_workbench_exec.py::TestParseDoneMarker::test_parses_nonzero_exit_code PASSED [ 30%]
selftests/test_workbench_exec.py::TestParseDoneMarker::test_marker_line_removed_from_output PASSED [ 40%]
selftests/test_workbench_exec.py::TestParseDoneMarker::test_empty_output_between_marker PASSED [ 50%]
selftests/test_workbench_exec.py::TestParseDoneMarker::test_does_not_match_different_marker PASSED [ 60%]
selftests/test_workbench_exec.py::TestTerminalRun::test_writes_done_marker_unconditionally PASSED [ 70%]
selftests/test_workbench_exec.py::TestTerminalRun::test_returns_output_on_success PASSED [ 80%]
selftests/test_workbench_exec.py::TestTerminalRun::test_raises_exec_error_immediately_on_nonzero_exit PASSED [ 90%]
selftests/test_workbench_exec.py::TestTerminalRun::test_still_times_out_when_marker_never_appears PASSED [100%]
====================== 10 passed, 48 deselected =======================

Full selftest suite still passes

uv run pytest selftests/ --ignore=selftests/test_load_engine.py -q 2>&1 | grep -E "passed|failed" | sed -E "s/ in [0-9.]+s//"
870 passed, 26 warnings

Lint and format checks pass

env -u VIRTUAL_ENV just check 2>&1
uv run ruff check src/ selftests/ examples/ docker/
All checks passed!
uv run ruff format --check src/ selftests/ examples/ docker/
149 files already formatted

🤖 Generated with Claude Code

terminal_run only wrote its done-marker via `&&`, so a fast-failing
command never wrote it, and the polling loop waited out the full
timeout before raising a generic, misleading error with the real
output discarded. The marker is now written unconditionally with the
exit code appended, so failures surface immediately with the actual
captured output.

Fixes #439

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 8, 2026 19:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes terminal_run in the Workbench exec helpers so fast-failing terminal commands don’t get misreported as generic timeouts; instead, completion is detected via an unconditional done-marker that includes the command’s exit code, enabling immediate ExecError with the real captured output.

Changes:

  • Write the terminal “done marker” unconditionally (; instead of &&) and append :$? so failures still produce a completion marker.
  • Add _parse_done_marker to parse (output, exit_code) and use it in terminal_run to raise immediately on non-zero exit.
  • Add selftests for _parse_done_marker and terminal_run behaviors; add a Showboat demo validating the regression and test runs.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/vip_tests/workbench/exec.py Writes an unconditional marker with exit code, adds _parse_done_marker, and raises ExecError immediately on non-zero exit.
selftests/test_workbench_exec.py Adds unit tests covering marker parsing and the updated terminal_run behavior (success, failure, timeout).
validation_docs/demo-fix-terminal-run-timeout.md Adds a demo reproducing the old failure mode and showing the fix + test/lint runs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/vip_tests/workbench/exec.py
Comment thread src/vip_tests/workbench/exec.py Outdated
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

@ian-flores ian-flores left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core fix is right and well covered. && becomes ;, $? is captured, a non-zero exit raises straight away, and the except ExecError: raise guard sits ahead of except Exception: pass in both poll paths. That last part is the easy one to get wrong, and it's correct here.

I traced every caller. Raising on a non-zero exit is safe everywhere: the clone/commit/push/deploy steps want it, file_exists runs an if/fi that always exits 0, git ls-remote exits 0, and the branch-delete cleanup already sits inside except ExecError. It even makes that cleanup faster.

Two things to fix before merge. Both live in _parse_done_marker, so the shell wrapper and its tests don't move. Suggestion is inline.

  1. Glued marker (real, latent today). The wrapper appends echo "{marker}:$?". echo adds a trailing newline but no leading one, so if a command's output doesn't end in \n, the marker sticks to the last line: fooVIP_DONE_abc:0. The line.strip().startswith(prefix) check then never matches, the loop runs to timeout, and you get the generic "timed out" error. That is #439 again, this time on the success path. The old substring check tolerated it, so this is a regression. Nothing in the repo trips it now, since git, rsconnect, and the if/fi all print newline-terminated or empty output, but terminal_run is general DSL and a future printf foo or echo -n caller would hang.

  2. Malformed exit code (minor). int(...) on a non-numeric suffix, say a half-written marker line, throws ValueError. except Exception: pass swallows it and the call times out instead of failing clean. $? is always numeric so this is close to impossible, but the reworked helper handles it for free by reading a not-yet-numeric suffix as still running.

Comment thread src/vip_tests/workbench/exec.py
Searching for the marker only at a line's start missed it when cmd's
final output line had no trailing newline, since `>>` glues the marker
onto that line with no separator -- causing terminal_run to poll until
timeout again, the exact failure mode #439 fixed. Search for the marker
anywhere in a line, keep any leading output on that line, and treat a
non-digit exit-code suffix as still-running instead of raising.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread selftests/test_workbench_exec.py
The immediate-ExecError-on-failure logic is duplicated in both the
RStudio/Positron (read_file) and VS Code (editor-open) polling loops,
but only the former had test coverage -- a regression in the VS Code
branch would have gone undetected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@posit-vip-triage

Copy link
Copy Markdown
Contributor

📸 Preview Screenshots

8 screenshots captured across both preview deployments.

Preview links for this PR:


🌐 Website Preview

Home

URL: https://posit-dev.github.io/vip/pr-preview-site/pr-445/

website home

Getting Started

URL: https://posit-dev.github.io/vip/pr-preview-site/pr-445/getting-started/

website getting-started

Test Inventory

URL: https://posit-dev.github.io/vip/pr-preview-site/pr-445/tests/

website tests

Feature Matrix

URL: https://posit-dev.github.io/vip/pr-preview-site/pr-445/feature-matrix/

website feature-matrix

Shiny App

URL: https://posit-dev.github.io/vip/pr-preview-site/pr-445/shiny-app/

website shiny-app

Example Report (embedded)

URL: https://posit-dev.github.io/vip/pr-preview-site/pr-445/report/

website report


📊 Report Preview

Summary Page

URL: https://posit-dev.github.io/vip/pr-preview/pr-445/

report home

Detailed Results

URL: https://posit-dev.github.io/vip/pr-preview/pr-445/details.html

report details


Notes: The report pages (report-home, report-details) were captured as viewport screenshots (not full-page) because the Quarto-rendered pages triggered a font-loading timeout on full-page capture; the visible content was captured successfully. All other pages were captured as full-page screenshots.

Warning

Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • fonts.gstatic.com
  • mtalk.google.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "fonts.gstatic.com"
    - "mtalk.google.com"

See Network Configuration for more information.

Generated by Capture preview screenshots for PRs · 81.3 AIC · ⌖ 9.79 AIC · ⊞ 6.1K ·

@posit-vip-triage

Copy link
Copy Markdown
Contributor

📸 Preview Screenshots

Automatically captured screenshots for the preview deployments on this PR.

8 screenshots captured across 8 pages.


🌐 Website Preview

Base URL: https://posit-dev.github.io/vip/pr-preview-site/pr-445/

Home

website home

Getting Started

website getting started

Test Inventory

website tests

Feature Matrix

website feature matrix

Example Report

website report

Shiny App

website shiny app


📊 Report Preview

Base URL: https://posit-dev.github.io/vip/pr-preview/pr-445/

Summary (index)

report home

Detailed Results

report details


📋 URLs captured

Website pages (6):

Report pages (2):

Note: Report screenshots are viewport-only (full-page capture timed out on JS-heavy Quarto pages). All website screenshots are full-page.

Warning

Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • fonts.gstatic.com
  • mtalk.google.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "fonts.gstatic.com"
    - "mtalk.google.com"

See Network Configuration for more information.

Generated by Capture preview screenshots for PRs · 80.2 AIC · ⌖ 9.61 AIC · ⊞ 6.1K ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

terminal_run swallows real command errors as a generic 120s timeout

3 participants