[test] Test utils for verifying interrupt handling

New sh_wrappers:
'send_sigint' and 'expect_interrupt' work together by sending
an interrupt to a wrapped command, and verifying that the interrupt
status (exit code 130) propagates where expected.
Some inner command will signal when is waiting for interrupt via a
wait-file, at which point send_sigint will deliver the interrupt.
'sleepy_cat.sh' is an example that signals with a wait-file.

In the follow-up change, fakebuild will use this mechanism so that
we can verify end-to-end signal propagation through layers of wrapper
scripts.

Bug: 390427892
Change-Id: I53dcc162754f8d8dbad7c602d346bb00ed022ea8
Reviewed-on: https://fuchsia-review.googlesource.com/c/rsclient/+/1391953
Commit-Queue: David Fang <fangism@google.com>
Reviewed-by: Jay Zhuang <jayzhuang@google.com>
diff --git a/tools/bazel/sh_wrapper/test_lib/BUILD.bazel b/tools/bazel/sh_wrapper/test_lib/BUILD.bazel
index d4b41be..65f2d0e 100644
--- a/tools/bazel/sh_wrapper/test_lib/BUILD.bazel
+++ b/tools/bazel/sh_wrapper/test_lib/BUILD.bazel
@@ -84,6 +84,47 @@
     position = "nonterminal",
 )
 
+# This wrapper should be placed at the beginning of a test chain to verify
+# that the wrapped command exits with code 130 (interrupt).
+# This is typically used in conjunction with the `send_sigint_wrapper`.
+sh_wrapper(
+    name = "expect_interrupt",
+    args = [
+        "--expected_exit_code",
+        "130",
+        "--",
+    ],
+    executable = ":check_exit_code",
+    position = "nonterminal",
+)
+
+sh_binary(
+    name = "sleepy_cat",
+    srcs = ["sleepy_cat.sh"],
+)
+
+sh_wrapper(
+    name = "sleepy_cat_cmd",
+    executable = ":sleepy_cat",
+    position = "terminal",
+)
+
+sh_binary(
+    name = "send_sigint",
+    srcs = ["send_sigint.sh"],
+)
+
+# This wrapper sends a SIGINT to its child process, and is useful for testing
+# signal handling and graceful shutdown. It should be placed before the command
+# that is intended to receive the signal.
+# This is intended to be used with the `expect_interrupt` wrapper to verify
+# the exit code.
+sh_wrapper(
+    name = "send_sigint_wrapper",
+    executable = ":send_sigint",
+    position = "nonterminal",
+)
+
 sh_binary(
     name = "set_glog_v",
     srcs = ["set_glog_v.sh"],
diff --git a/tools/bazel/sh_wrapper/test_lib/check_exit_code.sh b/tools/bazel/sh_wrapper/test_lib/check_exit_code.sh
index 9059f27..6c0c5bd 100755
--- a/tools/bazel/sh_wrapper/test_lib/check_exit_code.sh
+++ b/tools/bazel/sh_wrapper/test_lib/check_exit_code.sh
@@ -15,7 +15,8 @@
 
 
 # This script wraps a command and verifies that it exits with an expected
-# code.
+# code.  This is useful for expect-fail test cases, and verifying exit status
+# propagation through layers of wrappers.
 
 set -euo pipefail
 
@@ -26,7 +27,8 @@
   cat <<EOF
 Usage: $0 [options] -- <command>
 
-Wraps a command and checks its exit code.
+Wraps a command and checks its exit code against an expected value.
+This converts expect-fail commands into successes for testing.
 
 Options:
   -h, --help            Show this help message.
diff --git a/tools/bazel/sh_wrapper/test_lib/send_sigint.sh b/tools/bazel/sh_wrapper/test_lib/send_sigint.sh
new file mode 100755
index 0000000..f8c2b4a
--- /dev/null
+++ b/tools/bazel/sh_wrapper/test_lib/send_sigint.sh
@@ -0,0 +1,82 @@
+#!/bin/bash
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This script sends a SIGINT to a wrapped command for testing signal
+# propagation and graceful shutdown.
+#
+# It is intended to be used as a non-terminal wrapper in a test chain,
+# and should be paired with the `expect_interrupt` wrapper to verify that
+# the command exits with the expected interrupt code (130).
+
+set -euo pipefail
+
+readonly SCRIPT_NAME=$(basename "$0")
+
+log() {
+  echo "[${SCRIPT_NAME}] $@" >&2
+}
+
+# Trap exit to see what code the script is exiting with.
+trap 'log "Exiting with status $?"' EXIT
+
+if [[ $# -eq 0 ]]; then
+  log "Usage: $0 <command> [args...]"
+  exit 1
+fi
+
+# Define a standard path for the wait file and export it for the child process.
+readonly WAIT_FILE="${TEST_TMPDIR}/sh_wrapper.wait"
+export SH_WRAPPER_TEST_WAIT_FILE="${WAIT_FILE}"
+log "Wait file is at: ${WAIT_FILE}"
+
+# Run the wrapped command in the background, in its own process group.
+log "Starting command in background: $@"
+set -m
+"$@" &
+readonly CMD_PID=$!
+set +m
+log "Command running with PID: ${CMD_PID}"
+
+# Poll for the wait file to appear.
+log "Polling for wait file..."
+for ((i=0; i<20; i++)); do
+  if [[ -f "${WAIT_FILE}" ]]; then
+    break
+  fi
+  sleep 0.1
+done
+
+if ! [[ -f "${WAIT_FILE}" ]]; then
+  log "Error: Timed out waiting for wrapped command to be ready."
+  log "Waiting for file: ${WAIT_FILE}"
+  # Send a SIGKILL to the entire process group.
+  kill -9 -"${CMD_PID}" || true
+  exit 1
+fi
+log "Wait file found."
+
+# Send the interrupt signal to the entire process group.
+log "Sending SIGINT to process group ${CMD_PID}"
+kill -INT -"${CMD_PID}"
+
+# Wait for the command to terminate and capture its exit code.
+log "Waiting for command to terminate..."
+exit_code=0
+wait "${CMD_PID}" || exit_code=$?
+log "Command terminated with exit code: ${exit_code}"
+
+# Propagate the exit code of the wrapped command.
+log "Propagating exit code ${exit_code}"
+exit ${exit_code}
diff --git a/tools/bazel/sh_wrapper/test_lib/sleepy_cat.sh b/tools/bazel/sh_wrapper/test_lib/sleepy_cat.sh
new file mode 100755
index 0000000..6180387
--- /dev/null
+++ b/tools/bazel/sh_wrapper/test_lib/sleepy_cat.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This script touches a wait file, sleeps for a specified duration, and then
+# executes 'cat' to wait indefinitely for a signal.
+
+set -euo pipefail
+
+# --- Argument and Environment Variable Parsing ---
+WAIT_FILE="${SH_WRAPPER_TEST_WAIT_FILE:-}"
+DELAY="${SLEEPY_CAT_DELAY:-0.1}"
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --wait_file)
+      WAIT_FILE="$2"
+      shift 2
+      ;;
+    --delay)
+      DELAY="$2"
+      shift 2
+      ;;
+    *)
+      echo "Unknown option: $1" >&2
+      exit 1
+      ;;
+  esac
+done
+
+if [[ -z "${WAIT_FILE}" ]]; then
+  echo "Error: --wait_file or SH_WRAPPER_TEST_WAIT_FILE must be set." >&2
+  exit 1
+fi
+
+# Signal readiness by touching the wait file.
+touch "${WAIT_FILE}"
+
+# Sleep for the specified duration.
+sleep "${DELAY}"
+
+# Replace this script with 'cat' to wait for a signal.
+exec cat
diff --git a/tools/bazel/sh_wrapper/tests/BUILD.bazel b/tools/bazel/sh_wrapper/tests/BUILD.bazel
index b8adf31..9093232 100644
--- a/tools/bazel/sh_wrapper/tests/BUILD.bazel
+++ b/tools/bazel/sh_wrapper/tests/BUILD.bazel
@@ -68,6 +68,16 @@
     ],
 )
 
+# A test to verify that the expect_interrupt wrapper correctly handles a SIGINT.
+sh_wrapper_test(
+    name = "expect_interrupt_test",
+    chain = [
+        "//tools/bazel/sh_wrapper/test_lib:expect_interrupt",
+        "//tools/bazel/sh_wrapper/test_lib:send_sigint_wrapper",
+        "//tools/bazel/sh_wrapper/test_lib:sleepy_cat_cmd",
+    ],
+)
+
 # The following commented-out tests should work, in principle,
 # but trigger an error complaining about duplicates.
 # TODO: resolve this later