[llvm] [Lit] Introduce a shared interface for ongoing process invocations and in-proc builtins (PR #195117)

Benjamin Stott via llvm-commits llvm-commits at lists.llvm.org
Mon May 11 03:56:47 PDT 2026


https://github.com/BStott6 updated https://github.com/llvm/llvm-project/pull/195117

>From ece7b4860c6c573e03d6ad866e7fa9841b8de63f Mon Sep 17 00:00:00 2001
From: BStott <Benjamin.Stott at sony.com>
Date: Mon, 27 Apr 2026 18:00:53 +0100
Subject: [PATCH 1/2] [Ignore] Changes from previous PR

---
 llvm/utils/lit/lit/InprocBuiltins.py | 391 +++++++++++++++++++++------
 llvm/utils/lit/lit/TestRunner.py     |  54 ++--
 llvm/utils/lit/tests/shtest-glob.py  |   3 +-
 llvm/utils/lit/tests/shtest-shell.py |  37 +--
 4 files changed, 356 insertions(+), 129 deletions(-)

diff --git a/llvm/utils/lit/lit/InprocBuiltins.py b/llvm/utils/lit/lit/InprocBuiltins.py
index b20aeb6993726..9ef85c7833e84 100644
--- a/llvm/utils/lit/lit/InprocBuiltins.py
+++ b/llvm/utils/lit/lit/InprocBuiltins.py
@@ -1,87 +1,297 @@
+from __future__ import annotations
+
+import abc
 import getopt
+import io
 import os
 import pathlib
-import platform
 import shutil
 import stat
 import subprocess
-from io import StringIO
+from dataclasses import dataclass
+from typing import Callable, Optional
 
 import lit.util
+from lit.ShCommands import Command
 from lit.ShellEnvironment import (
     InternalShellError,
-    ShellCommandResult,
-    expand_glob_expressions,
+    ShellEnvironment,
     kIsWindows,
-    processRedirects,
     updateEnv,
 )
 
 
-def executeBuiltinCd(cmd, shenv):
+class InprocBuiltinIOObject(abc.ABC):
+    """
+    Base class for IO streams used for in-process built-ins. This class has two
+    specializations: InprocBuiltinIOFile, wrapping a file open in text mode, and
+    InprocBuiltinIOMemory, wrapping a BytesIO object. These streams provide
+    a text IO interface, but support reading and writing binary data too.
+
+    The main reason that this exists is to solve the conflict that binary IO is
+    required for daemonized testing, as many tests involve tools reading and
+    writing binary files and piping binary data between themselves, but on z/OS
+    files must be opened in text mode so that the character encoding is correct.
+    So we need an IO object that can be both a file open in text mode and a
+    in-memory stream that can store binary data.
+    """
+
+    @abc.abstractmethod
+    def read(self) -> str:
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def read_binary(self) -> bytes:
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def write(self, data: str) -> int:
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def write_binary(self, data: bytes) -> int:
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def seek(self, pos: int):
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def flush(self):
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def get_filename(self) -> str | None:
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def get_encoding(self) -> str | None:
+        raise NotImplemented
+
+
+ at dataclass
+class InprocBuiltinIOFile(InprocBuiltinIOObject):
+    """
+    Specialization of InprocBuiltinIOObject wrapping a file which is open in
+    text mode. Files must be opened in text mode so that character encoding
+    tags are correctly handled on z/OS. Binary IO is still supported via the
+    OS APIs (needed for daemonized testing).
+    """
+
+    file: io.TextIOBase
+
+    def read(self) -> str:
+        return self.file.read()
+
+    def read_binary(self) -> bytes:
+        self.file.flush()
+        data = bytearray()
+        chunk_size = 1024
+
+        while True:
+            chunk = os.read(self.file.fileno(), chunk_size)
+            if not chunk:
+                break
+            data.extend(chunk)
+
+        return bytes(data)
+
+    def write(self, data: str) -> int:
+        return self.file.write(data)
+
+    def write_binary(self, data: bytes) -> int:
+        self.file.flush()
+        return os.write(self.file.fileno(), data)
+
+    def seek(self, pos: int):
+        self.file.seek(pos)
+
+    def flush(self):
+        self.file.flush()
+
+    def get_filename(self) -> str | None:
+        if hasattr(self.file, "name") and isinstance(self.file.name, str):
+            return self.file.name
+        return None
+
+    def get_encoding(self) -> str | None:
+        return str(self.file.encoding)
+
+
+ at dataclass
+class InprocBuiltinIOMemory(InprocBuiltinIOObject):
+    """
+    Specialization of InprocBuiltinIOObject wrapping an in-memory binary stream.
+    """
+
+    obj: io.BytesIO
+    encoding: str = "utf-8"
+
+    def read(self) -> str:
+        return self.obj.read().decode(self.encoding, errors="replace")
+
+    def read_binary(self) -> bytes:
+        return self.obj.read()
+
+    def write(self, data: str) -> int:
+        return self.obj.write(data.encode(self.encoding, errors="replace"))
+
+    def write_binary(self, data: bytes) -> int:
+        return self.obj.write(data)
+
+    def seek(self, pos: int):
+        self.obj.seek(pos)
+
+    def flush(self):
+        pass
+
+    def get_filename(self) -> str | None:
+        return None
+
+    def get_encoding(self) -> str | None:
+        return self.encoding
+
+
+class InprocBuiltinIO:
+    """
+    Holds IO streams for an inproc builtin invocation.
+
+    NB: If stderr is redirected to be the same stream as stdout, then
+    `stder == stdout` is True.
+    """
+
+    stdin: InprocBuiltinIOObject
+    stdout: InprocBuiltinIOObject
+    stderr: InprocBuiltinIOObject
+
+    def __init__(self, stdin, stdout, stderr):
+        """
+        Configure the IO streams for an in-process builtin command in
+        the same way that IO streams are configured when calling
+        `subprocess.Popen`.
+
+        Each of stdin, stdout and stderr may be:
+        - A file object open in binary mode.
+        - `subprocess.PIPE`
+        - `subprocess.STDOUT` (for stderr)
+        - None
+        """
+
+        # If stderr is redirected to stdout, we make sure to use the same
+        # stream for both so that the order of output is preserved.
+        stderr_redirected_to_stdout = (
+            stdout == subprocess.PIPE and stderr == subprocess.STDOUT
+        )
+
+        # Replace sentinel values with in-memory streams.
+        def resolve_io_obj(stream) -> InprocBuiltinIOObject:
+            if stream == subprocess.PIPE or stream is None:
+                return InprocBuiltinIOMemory(io.BytesIO())
+            elif isinstance(stream, InprocBuiltinIOObject):
+                return stream
+            else:
+                return InprocBuiltinIOFile(stream)
+
+        self.stdin = resolve_io_obj(stdin)
+        self.stdout = resolve_io_obj(stdout)
+        if stderr_redirected_to_stdout:
+            # Make sure stderr and stdout are directed to the same stream.
+            self.stderr = self.stdout
+        else:
+            self.stderr = resolve_io_obj(stderr)
+
+
+InprocBuiltinExecuteFn = Callable[
+    [Command, "list[str]", ShellEnvironment, InprocBuiltinIO],
+    int,
+]
+"""
+Function called by an in-process builtin command.
+Parameters:
+    - `cmd`: The command itself.
+    - `args`: glob-expanded list of arguments (including argv[0] as the program name).
+    - `shenv`: The shell environment.
+    - `io`: Holds the input and output streams for the invocation. These are file-like objects (files, StringIO)
+
+The return value is the exit code.
+"""
+
+
+ at dataclass
+class InprocBuiltin:
+    """
+    Represents a command that is run as an in-process builtins.
+    """
+
+    execute: InprocBuiltinExecuteFn
+
+
+def executeBuiltinCd(
+    cmd: Command, args: list[str], shenv: ShellEnvironment, io: InprocBuiltinIO
+) -> int:
     """executeBuiltinCd - Change the current directory."""
-    if len(cmd.args) != 2:
+    if len(args) != 2:
         raise InternalShellError(cmd, "'cd' supports only one argument")
     # Update the cwd in the parent environment.
-    shenv.change_dir(cmd.args[1])
+    shenv.change_dir(args[1])
     # The cd builtin always succeeds. If the directory does not exist, the
     # following Popen calls will fail instead.
-    return ShellCommandResult(cmd, "", "", 0, False)
+    return 0
 
 
-def executeBuiltinPushd(cmd, shenv):
+def executeBuiltinPushd(
+    cmd: Command, args: list[str], shenv: ShellEnvironment, io: InprocBuiltinIO
+) -> int:
     """executeBuiltinPushd - Change the current dir and save the old."""
-    if len(cmd.args) != 2:
+    if len(args) != 2:
         raise InternalShellError(cmd, "'pushd' supports only one argument")
     shenv.dirStack.append(shenv.cwd)
-    shenv.change_dir(cmd.args[1])
-    return ShellCommandResult(cmd, "", "", 0, False)
+    shenv.change_dir(args[1])
+    return 0
 
 
-def executeBuiltinPopd(cmd, shenv):
+def executeBuiltinPopd(
+    cmd: Command, args: list[str], shenv: ShellEnvironment, io: InprocBuiltinIO
+) -> int:
     """executeBuiltinPopd - Restore a previously saved working directory."""
-    if len(cmd.args) != 1:
+    if len(args) != 1:
         raise InternalShellError(cmd, "'popd' does not support arguments")
     if not shenv.dirStack:
         raise InternalShellError(cmd, "popd: directory stack empty")
     shenv.cwd = shenv.dirStack.pop()
-    return ShellCommandResult(cmd, "", "", 0, False)
+    return 0
 
 
-def executeBuiltinExport(cmd, shenv):
+def executeBuiltinExport(
+    cmd: Command, args: list[str], shenv: ShellEnvironment, io: InprocBuiltinIO
+) -> int:
     """executeBuiltinExport - Set an environment variable."""
-    if len(cmd.args) != 2:
+    if len(args) != 2:
         raise InternalShellError(cmd, "'export' supports only one argument")
-    updateEnv(shenv, cmd.args)
-    return ShellCommandResult(cmd, "", "", 0, False)
+    updateEnv(shenv, args)
+    return 0
 
 
-def executeBuiltinEcho(cmd, shenv):
+def executeBuiltinEcho(
+    cmd: Command, args: list[str], shenv: ShellEnvironment, io: InprocBuiltinIO
+) -> int:
     """Interpret a redirected echo or @echo command"""
-    opened_files = []
-    stdin, stdout, stderr = processRedirects(cmd, subprocess.PIPE, shenv, opened_files)
-    if stdin != subprocess.PIPE or stderr != subprocess.PIPE:
-        raise InternalShellError(
-            cmd, f"stdin and stderr redirects not supported for {cmd.args[0]}"
-        )
-
-    # Some tests have un-redirected echo commands to help debug test failures.
-    # Buffer our output and return it to the caller.
-    is_redirected = True
-    if stdout == subprocess.PIPE:
-        is_redirected = False
-        stdout = StringIO()
-    elif kIsWindows:
+    if (
+        kIsWindows
+        and isinstance(io.stdout, InprocBuiltinIOFile)
+        and io.stdout.get_filename()
+    ):
         # Reopen stdout with `newline=""` to avoid CRLF translation.
         # The versions of echo we are replacing on Windows all emit plain LF,
         # and the LLVM tests now depend on this.
-        stdout = open(stdout.name, stdout.mode, encoding="utf-8", newline="")
-        opened_files.append((None, None, stdout, None))
+        io.stdout.file = os.fdopen(
+            io.stdout.file.fileno(), io.stdout.file.mode, encoding="utf-8", newline=""
+        )
 
     # Implement echo flags. We only support -e and -n, and not yet in
     # combination. We have to ignore unknown flags, because `echo "-D FOO"`
     # prints the dash.
-    args = cmd.args[1:]
+    args = args[1:]
     interpret_escapes = False
     write_newline = True
     while len(args) >= 1 and args[0] in ("-e", "-n"):
@@ -100,24 +310,21 @@ def maybeUnescape(arg):
 
     if args:
         for arg in args[:-1]:
-            stdout.write(maybeUnescape(arg))
-            stdout.write(" ")
-        stdout.write(maybeUnescape(args[-1]))
+            io.stdout.write(maybeUnescape(arg))
+            io.stdout.write(" ")
+        io.stdout.write(maybeUnescape(args[-1]))
     if write_newline:
-        stdout.write("\n")
-
-    for name, mode, f, path in opened_files:
-        f.close()
+        io.stdout.write("\n")
 
-    output = "" if is_redirected else stdout.getvalue()
-    return ShellCommandResult(cmd, output, "", 0, False)
+    return 0
 
 
-def executeBuiltinMkdir(cmd, cmd_shenv):
+def executeBuiltinMkdir(
+    cmd: Command, args: list[str], cmd_shenv: ShellEnvironment, io: InprocBuiltinIO
+):
     """executeBuiltinMkdir - Create new directories."""
-    args = expand_glob_expressions(cmd.args, cmd_shenv.cwd)[1:]
     try:
-        opts, args = getopt.gnu_getopt(args, "p")
+        opts, args = getopt.gnu_getopt(args[1:], "p")
     except getopt.GetoptError as err:
         raise InternalShellError(cmd, "Unsupported: 'mkdir':  %s" % str(err))
 
@@ -131,7 +338,6 @@ def executeBuiltinMkdir(cmd, cmd_shenv):
     if len(args) == 0:
         raise InternalShellError(cmd, "Error: 'mkdir' is missing an operand")
 
-    stderr = StringIO()
     exitCode = 0
     for dir in args:
         dir = pathlib.Path(dir)
@@ -144,16 +350,17 @@ def executeBuiltinMkdir(cmd, cmd_shenv):
             try:
                 dir.mkdir(exist_ok=True)
             except OSError as err:
-                stderr.write("Error: 'mkdir' command failed, %s\n" % str(err))
+                io.stderr.write(("Error: 'mkdir' command failed, %s\n" % str(err)))
                 exitCode = 1
-    return ShellCommandResult(cmd, "", stderr.getvalue(), exitCode, False)
+    return exitCode
 
 
-def executeBuiltinRm(cmd, cmd_shenv):
+def executeBuiltinRm(
+    cmd: Command, args: list[str], cmd_shenv: ShellEnvironment, io: InprocBuiltinIO
+):
     """executeBuiltinRm - Removes (deletes) files or directories."""
-    args = expand_glob_expressions(cmd.args, cmd_shenv.cwd)[1:]
     try:
-        opts, args = getopt.gnu_getopt(args, "frR", ["--recursive"])
+        opts, args = getopt.gnu_getopt(args[1:], "frR", ["--recursive"])
     except getopt.GetoptError as err:
         raise InternalShellError(cmd, "Unsupported: 'rm':  %s" % str(err))
 
@@ -176,7 +383,6 @@ def on_rm_error(func, path, exc_info):
         os.chmod(path, stat.S_IMODE(os.stat(path).st_mode) | stat.S_IWRITE)
         os.remove(path)
 
-    stderr = StringIO()
     exitCode = 0
     for path in args:
         cwd = cmd_shenv.cwd
@@ -189,9 +395,9 @@ def on_rm_error(func, path, exc_info):
                 os.remove(path)
             elif os.path.isdir(path):
                 if not recursive:
-                    stderr.write("Error: %s is a directory\n" % path)
+                    io.stderr.write(("Error: %s is a directory\n" % path))
                     exitCode = 1
-                if platform.system() == "Windows":
+                if kIsWindows:
                     # NOTE: use ctypes to access `SHFileOperationsW` on Windows to
                     # use the NT style path to get access to long file paths which
                     # cannot be removed otherwise.
@@ -255,26 +461,28 @@ class SHFILEOPSTRUCTW(Structure):
                     os.chmod(path, stat.S_IMODE(os.stat(path).st_mode) | stat.S_IWRITE)
                 os.remove(path)
         except OSError as err:
-            stderr.write("Error: 'rm' command failed, %s" % str(err))
+            io.stderr.write(("Error: 'rm' command failed, %s" % str(err)))
             exitCode = 1
-    return ShellCommandResult(cmd, "", stderr.getvalue(), exitCode, False)
+    return exitCode
 
 
-def executeBuiltinUmask(cmd, shenv):
+def executeBuiltinUmask(
+    cmd: Command, args: list[str], shenv: ShellEnvironment, io: InprocBuiltinIO
+):
     """executeBuiltinUmask - Change the current umask."""
     if os.name != "posix":
         raise InternalShellError(cmd, "'umask' not supported on this system")
-    if len(cmd.args) != 2:
+    if len(args) != 2:
         raise InternalShellError(cmd, "'umask' supports only one argument")
     try:
         # Update the umask in the parent environment.
-        shenv.umask = int(cmd.args[1], 8)
+        shenv.umask = int(args[1], 8)
     except ValueError as err:
         raise InternalShellError(cmd, "Error: 'umask': %s" % str(err))
-    return ShellCommandResult(cmd, "", "", 0, False)
+    return 0
 
 
-def executeBuiltinUlimit(cmd, shenv):
+def executeBuiltinUlimit(cmd: Command, args: list[str], shenv, io: InprocBuiltinIO):
     """executeBuiltinUlimit - Change the current limits."""
     try:
         # Try importing the resource module (available on POSIX systems) and
@@ -282,34 +490,59 @@ def executeBuiltinUlimit(cmd, shenv):
         import resource
     except ImportError:
         raise InternalShellError(cmd, "'ulimit' not supported on this system")
-    if len(cmd.args) != 3:
+    if len(args) != 3:
         raise InternalShellError(cmd, "'ulimit' requires two arguments")
     try:
-        if cmd.args[2] == "unlimited":
+        if args[2] == "unlimited":
             new_limit = resource.RLIM_INFINITY
         else:
-            new_limit = int(cmd.args[2])
+            new_limit = int(args[2])
     except ValueError as err:
         raise InternalShellError(cmd, "Error: 'ulimit': %s" % str(err))
-    if cmd.args[1] == "-v":
+    if args[1] == "-v":
         if new_limit != resource.RLIM_INFINITY:
             new_limit = new_limit * 1024
         shenv.ulimit["RLIMIT_AS"] = new_limit
-    elif cmd.args[1] == "-n":
+    elif args[1] == "-n":
         shenv.ulimit["RLIMIT_NOFILE"] = new_limit
-    elif cmd.args[1] == "-s":
+    elif args[1] == "-s":
         if new_limit != resource.RLIM_INFINITY:
             new_limit = new_limit * 1024
         shenv.ulimit["RLIMIT_STACK"] = new_limit
-    elif cmd.args[1] == "-f":
+    elif args[1] == "-f":
         shenv.ulimit["RLIMIT_FSIZE"] = new_limit
     else:
-        raise InternalShellError(
-            cmd, "'ulimit' does not support option: %s" % cmd.args[1]
-        )
-    return ShellCommandResult(cmd, "", "", 0, False)
+        raise InternalShellError(cmd, "'ulimit' does not support option: %s" % args[1])
+    return 0
 
 
-def executeBuiltinColon(cmd, cmd_shenv):
+def executeBuiltinColon(
+    cmd: Command, args: list[str], cmd_shenv: ShellEnvironment, io: InprocBuiltinIO
+):
     """executeBuiltinColon - Discard arguments and exit with status 0."""
-    return ShellCommandResult(cmd, "", "", 0, False)
+    return 0
+
+
+def get_default_inproc_builtins() -> dict[str, InprocBuiltin]:
+    """
+    get_default_inproc_builtins - Returns the map of command names to Lit's
+    in-process built-in implementations.
+    The entries are a pair of the callable for the builtin and a bool
+    that determines whether this inproc builtin may fall back to a
+    command of the same name in cases where in-proc builtins cannot be
+    used (e.g. as the argument to not --crash).
+    """
+
+    return {
+        "@echo": InprocBuiltin(executeBuiltinEcho),
+        "cd": InprocBuiltin(executeBuiltinCd),
+        "export": InprocBuiltin(executeBuiltinExport),
+        "echo": InprocBuiltin(executeBuiltinEcho),
+        "mkdir": InprocBuiltin(executeBuiltinMkdir),
+        "popd": InprocBuiltin(executeBuiltinPopd),
+        "pushd": InprocBuiltin(executeBuiltinPushd),
+        "rm": InprocBuiltin(executeBuiltinRm),
+        "ulimit": InprocBuiltin(executeBuiltinUlimit),
+        "umask": InprocBuiltin(executeBuiltinUmask),
+        ":": InprocBuiltin(executeBuiltinColon),
+    }
diff --git a/llvm/utils/lit/lit/TestRunner.py b/llvm/utils/lit/lit/TestRunner.py
index 82852f1852705..89eebe27f94c0 100644
--- a/llvm/utils/lit/lit/TestRunner.py
+++ b/llvm/utils/lit/lit/TestRunner.py
@@ -11,11 +11,11 @@
 import threading
 import traceback
 
-import lit.InprocBuiltins as InprocBuiltins
 import lit.ShUtil as ShUtil
 import lit.Test as Test
 import lit.util
 from lit.BooleanExpression import BooleanExpression
+from lit.InprocBuiltins import InprocBuiltinIO, get_default_inproc_builtins
 from lit.ShCommands import Command
 from lit.ShellEnvironment import (
     InternalShellError,
@@ -254,19 +254,7 @@ def _executeShCmd(cmd, shenv, results, timeoutHelper):
     builtin_commands_dir = os.path.join(
         os.path.dirname(os.path.abspath(__file__)), "builtin_commands"
     )
-    inproc_builtins = {
-        "cd": InprocBuiltins.executeBuiltinCd,
-        "export": InprocBuiltins.executeBuiltinExport,
-        "echo": InprocBuiltins.executeBuiltinEcho,
-        "@echo": InprocBuiltins.executeBuiltinEcho,
-        "mkdir": InprocBuiltins.executeBuiltinMkdir,
-        "popd": InprocBuiltins.executeBuiltinPopd,
-        "pushd": InprocBuiltins.executeBuiltinPushd,
-        "rm": InprocBuiltins.executeBuiltinRm,
-        "ulimit": InprocBuiltins.executeBuiltinUlimit,
-        "umask": InprocBuiltins.executeBuiltinUmask,
-        ":": InprocBuiltins.executeBuiltinColon,
-    }
+    inproc_builtins = get_default_inproc_builtins()
     # To avoid deadlock, we use a single stderr stream for piped
     # output. This is null until we have seen some output using
     # stderr.
@@ -354,9 +342,43 @@ def _executeShCmd(cmd, shenv, results, timeoutHelper):
                     j,
                     "Unsupported: '{}' cannot be part" " of a pipeline".format(args[0]),
                 )
-            result = inproc_builtin(Command(args, j.redirects), cmd_shenv)
+
+            stdin, stdout, stderr = processRedirects(
+                j, subprocess.PIPE, shenv, opened_files
+            )
+
+            builtin_io = InprocBuiltinIO(stdin, stdout, stderr)
+
+            args = expand_glob_expressions(args, cmd_shenv.cwd)
+
+            exit_code = inproc_builtin.execute(
+                Command(args, j.redirects), args, cmd_shenv, builtin_io
+            )
+
+            builtin_io.stdout.flush()
+            builtin_io.stderr.flush()
+
             if not_count % 2:
-                result.exitCode = int(not result.exitCode)
+                exit_code = int(not exit_code)
+
+            # Gather output from the streams.
+            out = ""
+            if stdout == subprocess.PIPE:
+                builtin_io.stdout.seek(0)
+                out = builtin_io.stdout.read()
+
+            err = ""
+            if stderr == subprocess.PIPE:
+                builtin_io.stderr.seek(0)
+                err = builtin_io.stderr.read()
+
+            result = ShellCommandResult(
+                j,
+                out,
+                err,
+                exit_code,
+                False,
+            )
             result.command.args = j.args
             results.append(result)
             return result.exitCode
diff --git a/llvm/utils/lit/tests/shtest-glob.py b/llvm/utils/lit/tests/shtest-glob.py
index ba609e036c166..190745f8133d4 100644
--- a/llvm/utils/lit/tests/shtest-glob.py
+++ b/llvm/utils/lit/tests/shtest-glob.py
@@ -4,8 +4,7 @@
 # RUN: | FileCheck -dump-input=fail -match-full-lines --implicit-check-not=Error: %s
 # END.
 
-# CHECK: UNRESOLVED: shtest-glob :: glob-echo.txt ({{[^)]*}})
-# CHECK: TypeError: string argument expected, got 'GlobItem'
+# CHECK: PASS: shtest-glob :: glob-echo.txt ({{[^)]*}})
 
 # CHECK:      FAIL: shtest-glob :: glob-mkdir.txt ({{[^)]*}})
 # CHECK:      # | Error: 'mkdir' command failed, {{.*}}example_file1.input'
diff --git a/llvm/utils/lit/tests/shtest-shell.py b/llvm/utils/lit/tests/shtest-shell.py
index 498f6bb0adc11..fa5d63b5e9e29 100644
--- a/llvm/utils/lit/tests/shtest-shell.py
+++ b/llvm/utils/lit/tests/shtest-shell.py
@@ -497,37 +497,10 @@
 # CHECK-NEXT: # error: command failed with exit status: 1
 #      CHECK: ***
 
-# CHECK: FAIL: shtest-shell :: echo-at-redirect-stderr.txt
-# CHECK: *** TEST 'shtest-shell :: echo-at-redirect-stderr.txt' FAILED ***
-# CHECK: @echo 2> {{.*}}
-# CHECK: # executed command: @echo
-# CHECK: # .---command stderr{{-*}}
-# CHECK: # | stdin and stderr redirects not supported for @echo
-# CHECK: error: command failed with exit status:
-
-# CHECK: FAIL: shtest-shell :: echo-at-redirect-stdin.txt
-# CHECK: *** TEST 'shtest-shell :: echo-at-redirect-stdin.txt' FAILED ***
-# CHECK: @echo < {{.*}}
-# CHECK: # executed command: @echo
-# CHECK: # .---command stderr{{-*}}
-# CHECK: # | stdin and stderr redirects not supported for @echo
-# CHECK: error: command failed with exit status:
-
-# CHECK: FAIL: shtest-shell :: echo-redirect-stderr.txt
-# CHECK: *** TEST 'shtest-shell :: echo-redirect-stderr.txt' FAILED ***
-# CHECK: echo 2> {{.*}}
-# CHECK: # executed command: echo
-# CHECK: # .---command stderr{{-*}}
-# CHECK: # | stdin and stderr redirects not supported for echo
-# CHECK: error: command failed with exit status:
-
-# CHECK: FAIL: shtest-shell :: echo-redirect-stdin.txt
-# CHECK: *** TEST 'shtest-shell :: echo-redirect-stdin.txt' FAILED ***
-# CHECK: echo < {{.*}}
-# CHECK: # executed command: echo
-# CHECK: # .---command stderr{{-*}}
-# CHECK: # | stdin and stderr redirects not supported for echo
-# CHECK: error: command failed with exit status:
+# CHECK: PASS: shtest-shell :: echo-at-redirect-stderr.txt
+# CHECK: PASS: shtest-shell :: echo-at-redirect-stdin.txt
+# CHECK: PASS: shtest-shell :: echo-redirect-stderr.txt
+# CHECK: PASS: shtest-shell :: echo-redirect-stdin.txt
 
 # CHECK: FAIL: shtest-shell :: error-0.txt
 # CHECK: *** TEST 'shtest-shell :: error-0.txt' FAILED ***
@@ -634,4 +607,4 @@
 
 # CHECK: PASS: shtest-shell :: valid-shell.txt
 # CHECK: Unresolved Tests (1)
-# CHECK: Failed Tests (37)
+# CHECK: Failed Tests (33)

>From 1cb6f875b60db497e713ce17ac04b78b743a1582 Mon Sep 17 00:00:00 2001
From: BStott <Benjamin.Stott at sony.com>
Date: Thu, 15 Jan 2026 12:26:35 +0000
Subject: [PATCH 2/2] Introduce a shared interface for ongoing process
 invocations and in-process builtin invocations

---
 llvm/utils/lit/lit/TestRunner.py | 159 +++++++++++++++++++++++++------
 1 file changed, 132 insertions(+), 27 deletions(-)

diff --git a/llvm/utils/lit/lit/TestRunner.py b/llvm/utils/lit/lit/TestRunner.py
index 89eebe27f94c0..16828c8a63d06 100644
--- a/llvm/utils/lit/lit/TestRunner.py
+++ b/llvm/utils/lit/lit/TestRunner.py
@@ -1,5 +1,6 @@
 from __future__ import absolute_import, annotations
 
+import abc
 import os
 import pathlib
 import re
@@ -10,6 +11,9 @@
 import tempfile
 import threading
 import traceback
+import typing
+from dataclasses import dataclass
+from typing import Any
 
 import lit.ShUtil as ShUtil
 import lit.Test as Test
@@ -185,6 +189,108 @@ def executeShCmd(cmd, shenv, results, timeout=0):
     return (finalExitCode, timeoutInfo)
 
 
+ at dataclass
+class InprocBuiltinResult:
+    """
+    Result of invoking an in-process builtin command. This stores its exit code
+    and stdout/stderr streams.
+    """
+
+    exit_code: int
+    stdout: typing.TextIO
+    stderr: typing.TextIO
+
+
+class CommandInvocation(abc.ABC):
+    """
+    Result of invoking a command: implementations hold either a Popen for an
+    out-of-proc command or an InprocCommandResult for an in-process command.
+    This is designed to mirror the functionality of Popen for in-process
+    commands too.
+    """
+
+    @abc.abstractmethod
+    def wait(self) -> int:
+        """
+        Wraps `Popen.wait`. For in-process builtin commands, there is nothing
+        to wait for, so just returns the exit code.
+        """
+
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def communicate(self) -> tuple[str, str]:
+        """
+        Wraps `Popen.communicate`. For in-process builtin commands, this is
+        the same as `read_output`.
+        """
+
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def stdout(self) -> typing.TextIO:
+        raise NotImplemented
+
+    @abc.abstractmethod
+    def stderr(self) -> typing.TextIO:
+        raise NotImplemented
+
+
+ at dataclass
+class ProcessInvocation(CommandInvocation):
+    """
+    CommandInvocation wrapping a `subprocess.Popen`; the result of invoking an
+    out-of-process command.
+    """
+
+    popen: subprocess.Popen
+
+    def wait(self) -> int:
+        return self.popen.wait()
+
+    def communicate(self) -> tuple[str, str]:
+        return self.popen.communicate()
+
+    def stdout(self) -> typing.TextIO:
+        return self.popen.stdout
+
+    def stderr(self) -> typing.TextIO:
+        return self.popen.stderr
+
+
+ at dataclass
+class InprocBuiltinInvocation(CommandInvocation):
+    """
+    CommandInvocation wrapping an `InprocBuiltinResult`; the result of invoking an
+    in-process builtin command.
+    """
+
+    result: InprocBuiltinResult
+
+    def wait(self) -> int:
+        # In-process builtins are not run asynchronously.
+        return self.result.exit_code
+
+    def communicate(self) -> tuple[str, str]:
+        if self.stdout():
+            stdout = self.stdout().read()
+        else:
+            stdout = ""
+
+        if self.stderr():
+            stderr = self.stderr().read()
+        else:
+            stderr = ""
+
+        return stdout, stderr
+
+    def stdout(self) -> typing.TextIO:
+        return self.result.stdout
+
+    def stderr(self) -> typing.TextIO:
+        return self.result.stderr
+
+
 def _expandLateSubstitutions(cmd, arguments, cwd, normalize_slashes=False):
     for i, arg in enumerate(arguments):
         if not isinstance(arg, str):
@@ -243,7 +349,7 @@ def _executeShCmd(cmd, shenv, results, timeoutHelper):
         raise ValueError("Unknown shell command: %r" % cmd.op)
     assert isinstance(cmd, ShUtil.Pipeline)
 
-    procs = []
+    invocations = []
     proc_not_counts = []
     proc_not_fail_if_crash = []
     default_stdin = subprocess.PIPE
@@ -487,20 +593,19 @@ def _executeShCmd(cmd, shenv, results, timeoutHelper):
             old_umask = -1
             if cmd_shenv.umask != -1:
                 old_umask = os.umask(cmd_shenv.umask)
-            procs.append(
-                subprocess.Popen(
-                    args,
-                    cwd=cmd_shenv.cwd,
-                    executable=executable,
-                    stdin=stdin,
-                    stdout=stdout,
-                    stderr=stderr,
-                    env=cmd_shenv.env,
-                    close_fds=kUseCloseFDs,
-                    universal_newlines=True,
-                    errors="replace",
-                )
+            proc = subprocess.Popen(
+                args,
+                cwd=cmd_shenv.cwd,
+                executable=executable,
+                stdin=stdin,
+                stdout=stdout,
+                stderr=stderr,
+                env=cmd_shenv.env,
+                close_fds=kUseCloseFDs,
+                universal_newlines=True,
+                errors="replace",
             )
+            invocations.append(ProcessInvocation(proc))
             if old_umask != -1:
                 os.umask(old_umask)
             proc_not_counts.append(not_count)
@@ -509,7 +614,7 @@ def _executeShCmd(cmd, shenv, results, timeoutHelper):
             else:
                 proc_not_fail_if_crash.append(False)
             # Let the helper know about this process
-            timeoutHelper.addProcess(procs[-1])
+            timeoutHelper.addProcess(proc)
         except OSError as e:
             raise InternalShellError(
                 j, "Could not create process ({}) due to {}".format(executable, e)
@@ -517,14 +622,14 @@ def _executeShCmd(cmd, shenv, results, timeoutHelper):
 
         # Immediately close stdin for any process taking stdin from us.
         if stdin == subprocess.PIPE:
-            procs[-1].stdin.close()
-            procs[-1].stdin = None
+            proc.stdin.close()
+            proc.stdin = None
 
         # Update the current stdin source.
         if stdout == subprocess.PIPE:
-            default_stdin = procs[-1].stdout
+            default_stdin = invocations[-1].stdout()
         elif stderrIsStdout:
-            default_stdin = procs[-1].stderr
+            default_stdin = invocations[-1].stderr()
         else:
             default_stdin = subprocess.PIPE
 
@@ -536,16 +641,16 @@ def _executeShCmd(cmd, shenv, results, timeoutHelper):
         f.close()
 
     # FIXME: There is probably still deadlock potential here. Yawn.
-    procData = [None] * len(procs)
-    procData[-1] = procs[-1].communicate()
+    procData = [None] * len(invocations)
+    procData[-1] = invocations[-1].communicate()
 
-    for i in range(len(procs) - 1):
-        if procs[i].stdout is not None:
-            out = procs[i].stdout.read()
+    for i in range(len(invocations) - 1):
+        if invocations[i].stdout():
+            out = invocations[i].stdout().read()
         else:
             out = ""
-        if procs[i].stderr is not None:
-            err = procs[i].stderr.read()
+        if invocations[i].stderr():
+            err = invocations[i].stderr().read()
         else:
             err = ""
         procData[i] = (out, err)
@@ -558,7 +663,7 @@ def _executeShCmd(cmd, shenv, results, timeoutHelper):
 
     exitCode = None
     for i, (out, err) in enumerate(procData):
-        res = procs[i].wait()
+        res = invocations[i].wait()
         # Detect Ctrl-C in subprocess.
         if res == -signal.SIGINT:
             raise KeyboardInterrupt



More information about the llvm-commits mailing list