Skip to content

Commit 32952c9

Browse files
Noor-ul-ain001claudeCopilot
authored
feat(workflows): make shell step timeout configurable (#3327) (#3328)
* feat(workflows): make shell step timeout configurable (#3327) The `shell` step hardcoded a 300s subprocess timeout, so any command that legitimately runs longer than five minutes (a full build, a linter aggregator, an integration-test target) was killed with TimeoutExpired and failed the whole run, with no YAML knob to raise the limit. Add an optional `timeout` field (seconds) that defaults to 300 for backward compatibility and is threaded through to `subprocess.run`. The timeout failure message now reports the configured value instead of a hardcoded 300. `validate` rejects a `timeout` that is not a positive number (bool is rejected explicitly, since it is an int subclass but a config error rather than a duration). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test(workflows): cover non-finite timeout rejection in shell step The isfinite guard added in 955d46a rejects YAML .inf/.nan timeouts, but no test asserted it. inf and nan are floats that pass a plain > 0 check (nan <= 0 is False), so without an explicit case a regression could silently reaccept them and crash subprocess.run(timeout=...) at runtime. Addresses the remaining Copilot review comment on PR #3328. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(workflows): document configurable shell step timeout Address Copilot review feedback on #3328: the per-step `timeout` option was not reflected in the public workflow docs. The Shell Steps section only showed `run:`, so readers couldn't discover `timeout:`, its unit (seconds), or its default (300). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor(workflows): consolidate shell-step timeout validation into one path Address Copilot review feedback on #3328: - Remove the dead "fall back to default" timeout block in execute(): it re-read `timeout` from config immediately after, so the fallback was discarded and its comment contradicted the new fail-on-invalid behavior. - Extract a single `_timeout_error()` helper shared by execute() and validate() so both reject the same values with the same message, instead of two drifting copies of the check. - Hoist the duplicated inline `import math` to module scope. - Add test_execute_fails_cleanly_on_invalid_timeout: asserts execute() fails the step (rather than raising) on an unvalidated string/bool/inf/0 timeout, covering the engine-skips-validate path Copilot flagged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 82c078b commit 32952c9

3 files changed

Lines changed: 173 additions & 121 deletions

File tree

src/specify_cli/workflows/steps/shell/__init__.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import math
67
import subprocess
78
from typing import Any
89

@@ -25,15 +26,20 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
2526
run_cmd = str(run_cmd)
2627

2728
cwd = context.project_root or "."
28-
# Defensive: the engine does not auto-validate step config, so an
29-
# invalid ``timeout`` (string, None, ...) would otherwise raise a
30-
# TypeError from subprocess.run() and crash the whole run. Mirror
31-
# the engine's handling of unvalidated ``continue_on_error`` by
32-
# only honoring well-formed values and falling back to the default.
29+
# Per-step execution timeout in seconds; defaults to 300 for backward
30+
# compatibility. The engine does not auto-validate step config, so
31+
# validate here as well — a caller that skips WorkflowEngine.validate()
32+
# must fail the step cleanly rather than crash subprocess.run() with a
33+
# TypeError (or silently coerce ``timeout: true`` to a 1s duration,
34+
# since bool is an int subclass).
3335
timeout = config.get("timeout", 300)
34-
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
35-
timeout = 300
36-
36+
timeout_error = self._timeout_error(config)
37+
if timeout_error is not None:
38+
return StepResult(
39+
status=StepStatus.FAILED,
40+
error=timeout_error,
41+
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
42+
)
3743
# NOTE: shell=True is required to support pipes, redirects, and
3844
# multi-command expressions in workflow YAML. Workflow authors
3945
# control commands; catalog-installed workflows should be reviewed
@@ -92,6 +98,32 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
9298
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
9399
)
94100

101+
@staticmethod
102+
def _timeout_error(config: dict[str, Any]) -> str | None:
103+
"""Return an error message if ``config['timeout']`` is invalid, else None.
104+
105+
Shared by execute() and validate() so both paths reject the same
106+
values with the same message. An absent ``timeout`` is valid (the
107+
default is used). bool is a subclass of int, but ``timeout: true`` is a
108+
config error rather than a duration, so it is rejected explicitly.
109+
Non-finite floats (YAML ``.inf``/``.nan``) pass a plain ``> 0`` check
110+
but would raise in subprocess.run(), so they are rejected too.
111+
"""
112+
if "timeout" not in config:
113+
return None
114+
timeout = config["timeout"]
115+
if (
116+
isinstance(timeout, bool)
117+
or not isinstance(timeout, (int, float))
118+
or not math.isfinite(timeout)
119+
or timeout <= 0
120+
):
121+
return (
122+
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
123+
f"positive number of seconds, got {timeout!r}."
124+
)
125+
return None
126+
95127
def validate(self, config: dict[str, Any]) -> list[str]:
96128
errors = super().validate(config)
97129
if "run" not in config:
@@ -114,16 +146,7 @@ def validate(self, config: dict[str, Any]) -> list[str]:
114146
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
115147
f"be 'json' when present, got {output_format!r}."
116148
)
117-
if "timeout" in config:
118-
timeout = config["timeout"]
119-
# bool is an int subclass, so reject it explicitly.
120-
if (
121-
isinstance(timeout, bool)
122-
or not isinstance(timeout, int)
123-
or timeout <= 0
124-
):
125-
errors.append(
126-
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
127-
f"positive integer (seconds) when present, got {timeout!r}."
128-
)
149+
timeout_error = self._timeout_error(config)
150+
if timeout_error is not None:
151+
errors.append(timeout_error)
129152
return errors

tests/test_workflows.py

Lines changed: 124 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,107 +1381,6 @@ def test_validate_accepts_string_and_expression_run(self):
13811381
assert step.validate({"id": "s", "run": "echo hi"}) == []
13821382
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
13831383

1384-
def test_timeout_is_configurable(self, monkeypatch):
1385-
"""A 'timeout' field overrides the 300s default (#3327)."""
1386-
import subprocess as sp
1387-
1388-
from specify_cli.workflows.steps.shell import ShellStep
1389-
from specify_cli.workflows.base import StepContext, StepStatus
1390-
1391-
seen = {}
1392-
real_run = sp.run
1393-
1394-
def spy_run(*args, **kwargs):
1395-
seen["timeout"] = kwargs.get("timeout")
1396-
return real_run(*args, **kwargs)
1397-
1398-
monkeypatch.setattr(
1399-
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
1400-
)
1401-
step = ShellStep()
1402-
result = step.execute(
1403-
{"id": "t", "run": "echo hi", "timeout": 1800}, StepContext()
1404-
)
1405-
assert result.status == StepStatus.COMPLETED
1406-
assert seen["timeout"] == 1800
1407-
1408-
def test_timeout_defaults_to_300(self, monkeypatch):
1409-
import subprocess as sp
1410-
1411-
from specify_cli.workflows.steps.shell import ShellStep
1412-
from specify_cli.workflows.base import StepContext, StepStatus
1413-
1414-
seen = {}
1415-
real_run = sp.run
1416-
1417-
def spy_run(*args, **kwargs):
1418-
seen["timeout"] = kwargs.get("timeout")
1419-
return real_run(*args, **kwargs)
1420-
1421-
monkeypatch.setattr(
1422-
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
1423-
)
1424-
result = ShellStep().execute({"id": "t", "run": "echo hi"}, StepContext())
1425-
assert result.status == StepStatus.COMPLETED
1426-
assert seen["timeout"] == 300
1427-
1428-
def test_timeout_error_reports_configured_value(self, monkeypatch):
1429-
import subprocess as sp
1430-
1431-
from specify_cli.workflows.steps.shell import ShellStep
1432-
from specify_cli.workflows.base import StepContext, StepStatus
1433-
1434-
def raise_timeout(*args, **kwargs):
1435-
raise sp.TimeoutExpired(cmd="x", timeout=kwargs.get("timeout"))
1436-
1437-
monkeypatch.setattr(
1438-
"specify_cli.workflows.steps.shell.subprocess.run", raise_timeout
1439-
)
1440-
result = ShellStep().execute(
1441-
{"id": "t", "run": "sleep 999", "timeout": 7}, StepContext()
1442-
)
1443-
assert result.status == StepStatus.FAILED
1444-
assert "7 seconds" in result.error
1445-
1446-
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
1447-
def test_execute_ignores_unvalidated_bad_timeout(self, bad, monkeypatch):
1448-
"""execute() falls back to 300 when config skipped validation (#3327)."""
1449-
import subprocess as sp
1450-
1451-
from specify_cli.workflows.steps.shell import ShellStep
1452-
from specify_cli.workflows.base import StepContext, StepStatus
1453-
1454-
seen = {}
1455-
real_run = sp.run
1456-
1457-
def spy_run(*args, **kwargs):
1458-
seen["timeout"] = kwargs.get("timeout")
1459-
return real_run(*args, **kwargs)
1460-
1461-
monkeypatch.setattr(
1462-
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
1463-
)
1464-
result = ShellStep().execute(
1465-
{"id": "t", "run": "echo hi", "timeout": bad}, StepContext()
1466-
)
1467-
assert result.status == StepStatus.COMPLETED
1468-
assert seen["timeout"] == 300
1469-
1470-
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
1471-
def test_validate_rejects_bad_timeout(self, bad):
1472-
from specify_cli.workflows.steps.shell import ShellStep
1473-
1474-
errors = ShellStep().validate({"id": "s", "run": "echo hi", "timeout": bad})
1475-
assert any("'timeout'" in e for e in errors)
1476-
1477-
def test_validate_accepts_positive_int_timeout(self):
1478-
from specify_cli.workflows.steps.shell import ShellStep
1479-
1480-
assert (
1481-
ShellStep().validate({"id": "s", "run": "echo hi", "timeout": 1800}) == []
1482-
)
1483-
1484-
14851384
def test_output_format_json_exposes_data(self, tmp_path):
14861385
from specify_cli.workflows.steps.shell import ShellStep
14871386
from specify_cli.workflows.base import StepContext, StepStatus
@@ -1538,6 +1437,130 @@ def test_validate_rejects_unknown_output_format(self):
15381437
errors = step.validate({"id": "emit", "run": "exit 0", "output_format": "yaml"})
15391438
assert any("'output_format' must be 'json'" in e for e in errors)
15401439

1440+
def test_configured_timeout_is_passed_to_subprocess(self, monkeypatch):
1441+
"""A ``timeout:`` value on the step overrides the 300s default and is
1442+
threaded through to ``subprocess.run`` (issue #3327)."""
1443+
import subprocess
1444+
1445+
from specify_cli.workflows.steps.shell import ShellStep
1446+
from specify_cli.workflows.base import StepContext, StepStatus
1447+
1448+
captured: dict[str, object] = {}
1449+
1450+
def fake_run(*args, **kwargs):
1451+
captured["timeout"] = kwargs.get("timeout")
1452+
return subprocess.CompletedProcess(
1453+
args=args[0] if args else "", returncode=0, stdout="", stderr=""
1454+
)
1455+
1456+
monkeypatch.setattr(subprocess, "run", fake_run)
1457+
step = ShellStep()
1458+
result = step.execute(
1459+
{"id": "qa", "run": "echo hi", "timeout": 1800}, StepContext()
1460+
)
1461+
assert result.status == StepStatus.COMPLETED
1462+
assert captured["timeout"] == 1800
1463+
1464+
def test_default_timeout_preserved_when_omitted(self, monkeypatch):
1465+
"""Omitting ``timeout:`` preserves the historical 300s default."""
1466+
import subprocess
1467+
1468+
from specify_cli.workflows.steps.shell import ShellStep
1469+
from specify_cli.workflows.base import StepContext
1470+
1471+
captured: dict[str, object] = {}
1472+
1473+
def fake_run(*args, **kwargs):
1474+
captured["timeout"] = kwargs.get("timeout")
1475+
return subprocess.CompletedProcess(
1476+
args=args[0] if args else "", returncode=0, stdout="", stderr=""
1477+
)
1478+
1479+
monkeypatch.setattr(subprocess, "run", fake_run)
1480+
step = ShellStep()
1481+
step.execute({"id": "qa", "run": "echo hi"}, StepContext())
1482+
assert captured["timeout"] == 300
1483+
1484+
def test_timeout_error_reports_configured_value(self, monkeypatch):
1485+
"""The timeout failure message reflects the configured duration, not a
1486+
hardcoded 300."""
1487+
import subprocess
1488+
1489+
from specify_cli.workflows.steps.shell import ShellStep
1490+
from specify_cli.workflows.base import StepContext, StepStatus
1491+
1492+
def fake_run(*args, **kwargs):
1493+
raise subprocess.TimeoutExpired(cmd="echo hi", timeout=5)
1494+
1495+
monkeypatch.setattr(subprocess, "run", fake_run)
1496+
step = ShellStep()
1497+
result = step.execute(
1498+
{"id": "qa", "run": "echo hi", "timeout": 5}, StepContext()
1499+
)
1500+
assert result.status == StepStatus.FAILED
1501+
assert "5 seconds" in (result.error or "")
1502+
1503+
def test_execute_fails_cleanly_on_invalid_timeout(self, monkeypatch):
1504+
"""execute() must fail the step (not raise) on an invalid timeout even
1505+
when validate() was skipped — the engine does not auto-validate step
1506+
config, so an unvalidated string/bool/non-finite timeout would
1507+
otherwise crash subprocess.run() and take down the whole run."""
1508+
import subprocess
1509+
1510+
from specify_cli.workflows.steps.shell import ShellStep
1511+
from specify_cli.workflows.base import StepContext, StepStatus
1512+
1513+
def fail_if_called(*args, **kwargs):
1514+
raise AssertionError("subprocess.run should not run on invalid timeout")
1515+
1516+
monkeypatch.setattr(subprocess, "run", fail_if_called)
1517+
step = ShellStep()
1518+
# A string would raise TypeError; ``True`` would silently become a 1s
1519+
# timeout (bool is an int subclass); ``.inf`` would raise at runtime.
1520+
for bad in ("30", True, float("inf"), 0):
1521+
result = step.execute(
1522+
{"id": "qa", "run": "echo hi", "timeout": bad}, StepContext()
1523+
)
1524+
assert result.status == StepStatus.FAILED
1525+
assert "'timeout' must be a positive number" in (result.error or "")
1526+
1527+
def test_validate_rejects_non_positive_timeout(self):
1528+
from specify_cli.workflows.steps.shell import ShellStep
1529+
1530+
step = ShellStep()
1531+
for bad in (0, -30):
1532+
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
1533+
assert any("'timeout' must be a positive number" in e for e in errors)
1534+
1535+
def test_validate_rejects_non_numeric_timeout(self):
1536+
from specify_cli.workflows.steps.shell import ShellStep
1537+
1538+
step = ShellStep()
1539+
# A string and a bool are both invalid (bool is an int subclass but a
1540+
# config error, not a duration).
1541+
for bad in ("30", True):
1542+
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
1543+
assert any("'timeout' must be a positive number" in e for e in errors)
1544+
1545+
def test_validate_rejects_non_finite_timeout(self):
1546+
from specify_cli.workflows.steps.shell import ShellStep
1547+
1548+
step = ShellStep()
1549+
# inf/nan are floats and slip past a plain ``> 0`` check (``nan <= 0``
1550+
# is False), but ``subprocess.run(timeout=...)`` would then fail at
1551+
# runtime. YAML ``.inf``/``.nan`` scalars parse to these via safe_load.
1552+
for bad in (float("inf"), float("-inf"), float("nan")):
1553+
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
1554+
assert any("'timeout' must be a positive number" in e for e in errors)
1555+
1556+
def test_validate_accepts_positive_numeric_timeout(self):
1557+
from specify_cli.workflows.steps.shell import ShellStep
1558+
1559+
step = ShellStep()
1560+
for good in (1, 300, 1800, 12.5):
1561+
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": good})
1562+
assert not any("'timeout'" in e for e in errors)
1563+
15411564
class _StubStdin:
15421565
"""Stdin stub exposing only a fixed ``isatty`` result.
15431566

workflows/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,14 @@ Run a shell command and capture output:
112112
- id: run-tests
113113
type: shell
114114
run: "cd {{ inputs.project_dir }} && npm test"
115+
timeout: 1800 # Optional: max seconds before the command is killed (default 300)
115116
```
116117
118+
`timeout` is the maximum time in seconds the command may run before it is
119+
killed and the step fails; it must be a positive number and defaults to
120+
`300` (five minutes) when omitted. Raise it for long-running gates such as
121+
full builds, linter aggregators, or integration-test targets.
122+
117123
### Init Steps
118124

119125
Bootstrap a project the same way `specify init` does — scaffolding

0 commit comments

Comments
 (0)