diff --git a/tests/tools/test_terminal_compound_background.py b/tests/tools/test_terminal_compound_background.py index eeef435772e89..44b4d40a1f271 100644 --- a/tests/tools/test_terminal_compound_background.py +++ b/tests/tools/test_terminal_compound_background.py @@ -66,6 +66,43 @@ def test_multiple_rewrites_in_one_script(self): assert rewrite(cmd) == "A && { B & }\nfalse || { C & }" +class TestTrailingCommandStaysValid: + """A command following the backgrounded job on the SAME line must stay + syntactically valid: ``A && B & C`` rewritten to ``A && { B & } C`` is a + bash syntax error (a brace group needs a separator before the next + command). The rewrite must splice in a ``;``. + """ + + def test_trailing_command_same_line(self): + assert rewrite("A && B & C") == "A && { B & }; C" + + def test_trailing_command_realistic(self): + cmd = "build && python app.py & echo started" + assert rewrite(cmd) == "build && { python app.py & }; echo started" + + def test_trailing_command_no_space_after_amp(self): + assert rewrite("A && B &C") == "A && { B & }; C" + + def test_rewritten_output_is_valid_bash(self): + # The actual defect: the pre-fix output failed `bash -n`. + import subprocess + + for cmd in ( + "echo a && sleep 100 & echo done", + "A && B & C", + "build && run & tail -f log", + ): + out = rewrite(cmd) + proc = subprocess.run( + ["bash", "-n", "-c", out], capture_output=True, text=True + ) + assert proc.returncode == 0, f"{out!r} failed bash -n: {proc.stderr}" + + def test_trailing_command_idempotent(self): + once = rewrite("A && B & C") + assert rewrite(once) == once + + class TestPreserved: """Commands that DON'T have the bug MUST pass through unchanged.""" diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 3e81eff9f6763..504af68fdcd5e 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -744,7 +744,20 @@ def _rewrite_compound_background(command: str) -> str: suffix = result[amp_pos + 1 :] # `{` needs a trailing space in bash; the closing `}` needs to be # preceded by `;` or `&` — we're providing `&` from the backgrounding. - result = prefix + "{ " + middle + "& }" + suffix + # + # A brace group must also be *followed* by a separator before any + # further command on the same line: `A && B & C` would otherwise + # become `A && { B & } C`, which bash rejects with + # "syntax error near unexpected token". When a command directly + # follows the backgrounded job (`& C`), splice in a `;` so the + # rewrite stays valid. Tails that already begin with a separator + # — a newline-delimited next statement, or a `&&`/`||`/`|` chain — + # need nothing. + tail = suffix.lstrip(" \t") + if tail and tail[0] not in (";", "&", "|", "\n", ")"): + result = prefix + "{ " + middle + "& }; " + tail + else: + result = prefix + "{ " + middle + "& }" + suffix return result