kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

exec_target.sh (20530B)


      1 # test/lib/exec_target.sh — shared per-target exec helper for test harnesses.
      2 #
      3 # Sourced by test/{link,cg,parse}/run.sh. Provides three execution modes,
      4 # each parameterized by a `<arch>-<os>` target tag:
      5 #
      6 #   exec_target_run TAG EXE OUT ERR
      7 #       Synchronous one-shot. Sets RUN_RC. Used for kernel images and
      8 #       negative-test cases that need an immediate rc.
      9 #
     10 #   exec_target_queue TAG NAME EXE OUT ERR RC
     11 #       Append a case to the internal queue. The tag is stored alongside
     12 #       so flush can group cases by target and run one batched runner
     13 #       invocation per group.
     14 #
     15 #   exec_target_queue_size
     16 #       Total queue size across all targets.
     17 #
     18 #   exec_target_flush
     19 #       Drain the queue. Cases are grouped by tag and each group runs
     20 #       through one `podman run` (linux targets) or a native loop (macos
     21 #       targets). On podman hosts this amortizes the ~150 ms per-launch
     22 #       client round-trip across the whole suite.
     23 #
     24 #   exec_target_supported TAG
     25 #       Returns 0 if some runner is available for tag on this host.
     26 #
     27 # Recognized tags: <arch>-<os> where arch is aa64/x64/rv64 (the long form
     28 # aarch64 is accepted as an alias for aa64) and os
     29 # is linux/macos. linux tags map to podman --platform strings and an
     30 # optional user-mode qemu binary. macos tags require a Darwin host whose
     31 # native arch matches; Mach-O cannot be loaded by the Linux kernel and
     32 # Linux ELF cannot be loaded by Darwin, so cross-OS exec is unsupported
     33 # (callers see exec_target_supported return 1 and SKIP).
     34 #
     35 # Caller contract:
     36 #   - Sets the following before sourcing or calling: have_qemu (host
     37 #     qemu-aarch64), have_podman, is_aarch64, QEMU_BIN (path to
     38 #     qemu-aarch64). Already detected by every harness.
     39 #   - For batched podman, sets EXEC_TARGET_MOUNT_ROOT to a host
     40 #     directory that contains every exe / out / err / rc path that
     41 #     will be queued. The same path is bind-mounted at the same path
     42 #     inside the container.
     43 #   - Optional: RUN_AARCH64_IMAGE / RUN_X64_IMAGE / RUN_RV64_IMAGE
     44 #     override the container image. The defaults are pinned, per-arch,
     45 #     content-addressed alpine digests (see test/lib/test_images.sh);
     46 #     provision them once with `make test-images`. Every `podman run`
     47 #     below uses --pull=never, so the run path never touches the network.
     48 
     49 # Pinned per-arch image references (sets RUN_<ARCH>_IMAGE defaults and provides
     50 # kit_test_image_for_arch). Sourced relative to this file's location.
     51 . "$(dirname "${BASH_SOURCE[0]}")/test_images.sh"
     52 
     53 # VM execution backend: the `<arch>-freebsd` / `<arch>-windows` runner plus the
     54 # boot/teardown lifecycle. The stateless linux/macos runners live in this file;
     55 # the stateful VM runner (which an expensive-to-boot VM needs) lives there.
     56 . "$(dirname "${BASH_SOURCE[0]}")/exec_vm.sh"
     57 
     58 # Bare-metal backend: the `<arch>-freestanding` runner. Freestanding images have
     59 # no OS — they boot under qemu-system and exit via per-arch semihosting / a test
     60 # device. exec_bare.sh owns the reset stubs, linker scripts, and exit oracles.
     61 . "$(dirname "${BASH_SOURCE[0]}")/exec_bare.sh"
     62 
     63 # Work dir for the bare backend's per-arch stub + linked images.
     64 EXEC_BARE_WORK="${EXEC_BARE_WORK:-${EXEC_TARGET_MOUNT_ROOT:-${TMPDIR:-/tmp}/kit-exec-bare}}"
     65 
     66 # Internal queue arrays. Each entry's tag is recorded alongside the
     67 # rest so flush can split into per-target batched runs.
     68 EXEC_TARGET_TAGS=()
     69 EXEC_TARGET_NAMES=()
     70 EXEC_TARGET_EXES=()
     71 EXEC_TARGET_OUTS=()
     72 EXEC_TARGET_ERRS=()
     73 EXEC_TARGET_RCS=()
     74 
     75 # ---- tag parsing -----------------------------------------------------------
     76 #
     77 # _exec_target_arch TAG  → echoes arch portion ("aarch64", "x64", "rv64").
     78 # _exec_target_os TAG    → echoes os portion ("linux", "macos").
     79 #
     80 # Bare-arch tags ("aarch64", "x64", "rv64") are accepted and mean
     81 # "<arch>-linux" — preserves call-site compatibility while the harness
     82 # transition to <arch>-<os> tags is in progress.
     83 
     84 _exec_target_arch() {
     85     case "$1" in
     86         *-*) printf '%s' "${1%%-*}" ;;   # first field (handles <arch>-<os>[-<libc>])
     87         *)   printf '%s' "$1" ;;
     88     esac
     89 }
     90 
     91 _exec_target_os() {
     92     case "$1" in
     93         *-*) printf '%s' "${1#*-}" ;;
     94         *)   printf 'linux' ;;
     95     esac
     96 }
     97 
     98 # ---- per-target capability/dispatch knobs ----------------------------------
     99 
    100 _exec_target_platform() {
    101     case "$(_exec_target_arch "$1")" in
    102         aa64|aarch64) echo "linux/arm64" ;;
    103         x64)          echo "linux/amd64" ;;
    104         rv64)         echo "linux/riscv64" ;;
    105         *)            echo "" ;;
    106     esac
    107 }
    108 
    109 # Per-arch pinned image (content-addressed alpine digest, musl libc). The pins
    110 # live in test/lib/test_images.sh and are provisioned by `make test-images`;
    111 # RUN_<ARCH>_IMAGE overrides them (e.g. for a glibc base). Distinct digests per
    112 # arch mean local storage can never confuse one arch's rootfs for another's.
    113 _exec_target_image() {
    114     local arch; arch="$(_exec_target_arch "$1")"
    115     # glibc targets run in a glibc (Debian) image; musl/default in alpine. The
    116     # arch-qualified names disambiguate the manifest so podman never confuses one
    117     # arch's rootfs for another's (the alpine pins use digests for the same end).
    118     if [ "$(_exec_target_os "$1")" = "linux-glibc" ]; then
    119         case "$arch" in
    120             aa64|aarch64) printf '%s' "${RUN_GLIBC_AARCH64_IMAGE:-docker.io/arm64v8/debian:bookworm-slim}" ;;
    121             x64)          printf '%s' "${RUN_GLIBC_X64_IMAGE:-docker.io/amd64/debian:bookworm-slim}" ;;
    122             rv64)         printf '%s' "${RUN_GLIBC_RV64_IMAGE:-docker.io/riscv64/debian:trixie-slim}" ;;
    123             *)            printf 'debian:bookworm-slim' ;;
    124         esac
    125         return
    126     fi
    127     local img; img="$(kit_test_image_for_arch "$arch")"
    128     [ -n "$img" ] && printf '%s' "$img" || printf 'alpine:latest'
    129 }
    130 
    131 # Memoized: is this arch's pinned image present in local storage? The harnesses
    132 # run with --pull=never, so a missing image means the container runner is
    133 # unavailable until `make test-images` provisions it. Cached per arch so
    134 # exec_target_supported stays a constant cost across hundreds of cases.
    135 _exec_target_image_present() {
    136     local img var cached
    137     img="$(_exec_target_image "$1")"
    138     # Memoize per IMAGE (not per arch): a given arch can map to either the
    139     # alpine (musl) or the debian (glibc) image, and those presence answers differ.
    140     var="_EXEC_TARGET_IMG_$(printf '%s' "$img" | tr -c 'A-Za-z0-9' _)"
    141     cached="${!var:-}"
    142     if [ -z "$cached" ]; then
    143         if podman image exists "$img" 2>/dev/null; then cached=yes; else cached=no; fi
    144         printf -v "$var" '%s' "$cached"
    145     fi
    146     [ "$cached" = yes ]
    147 }
    148 
    149 # True when the host can exec this target without container/qemu help.
    150 #
    151 # linux targets: matching arch on a Linux host (e.g. aarch64-linux on
    152 # a Linux/aarch64 host). On Darwin, Linux ELF cannot be loaded by the
    153 # kernel even when the arch matches, so this is Linux-host-only.
    154 #
    155 # macos targets: matching arch on a Darwin host. The Linux kernel
    156 # cannot load Mach-O, so this is Darwin-host-only.
    157 _exec_target_native() {
    158     local arch os host_kernel host_arch
    159     arch="$(_exec_target_arch "$1")"
    160     os="$(_exec_target_os "$1")"
    161     host_kernel="$(uname -s 2>/dev/null)"
    162     host_arch="$(uname -m 2>/dev/null)"
    163     case "$os" in
    164         linux|linux-glibc)
    165             [ "$host_kernel" = "Linux" ] || return 1
    166             _exec_target_arch_matches_host "$arch" "$host_arch"
    167             ;;
    168         macos)
    169             [ "$host_kernel" = "Darwin" ] || return 1
    170             _exec_target_arch_matches_host "$arch" "$host_arch" && return 0
    171             # Rosetta 2: an Apple-silicon Darwin host transparently translates
    172             # and runs x86_64 Mach-O binaries — executing the file directly is
    173             # enough (no `arch -x86_64` wrapper). Treat x64-on-arm64 as native
    174             # when Rosetta is installed.
    175             if [ "$arch" = "x64" ] && \
    176                { [ "$host_arch" = "arm64" ] || [ "$host_arch" = "aarch64" ]; }; then
    177                 _exec_target_rosetta_available && return 0
    178             fi
    179             return 1
    180             ;;
    181         *)  return 1 ;;
    182     esac
    183 }
    184 
    185 _exec_target_arch_matches_host() {
    186     local arch="$1" host_arch="$2"
    187     case "$arch" in
    188         aa64|aarch64) [ "$host_arch" = "aarch64" ] || [ "$host_arch" = "arm64" ] ;;
    189         x64)          [ "$host_arch" = "x86_64" ]  || [ "$host_arch" = "amd64" ] ;;
    190         rv64)         [ "$host_arch" = "riscv64" ] ;;
    191         *)            return 1 ;;
    192     esac
    193 }
    194 
    195 # True when Rosetta 2 is available to run x86_64 binaries on this host. Probe
    196 # once and cache — exec paths call this per binary.
    197 _exec_target_rosetta_available() {
    198     case "${_EXEC_TARGET_ROSETTA:-}" in
    199         yes) return 0 ;;
    200         no)  return 1 ;;
    201     esac
    202     if arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
    203         _EXEC_TARGET_ROSETTA=yes; return 0
    204     fi
    205     _EXEC_TARGET_ROSETTA=no; return 1
    206 }
    207 
    208 # True when podman can run this linux target without emulation. The podman
    209 # machine on Darwin/arm64 already runs linux/arm64, so passing `--platform
    210 # linux/arm64` there is redundant — and worse, triggers a registry manifest
    211 # lookup (~30 s) on every `podman run` even when the local image matches.
    212 _exec_target_podman_native() {
    213     case "$(_exec_target_arch "$1")" in
    214         aa64|aarch64) [ "${is_aarch64:-0}" -eq 1 ] ;;
    215         x64)          [ "$(uname -m 2>/dev/null)" = "x86_64" ] || \
    216                       [ "$(uname -m 2>/dev/null)" = "amd64" ] ;;
    217         rv64)         [ "$(uname -m 2>/dev/null)" = "riscv64" ] ;;
    218         *)            return 1 ;;
    219     esac
    220 }
    221 
    222 _exec_target_qemu() {
    223     case "$(_exec_target_arch "$1")" in
    224         aa64|aarch64) [ "${have_qemu:-0}" -eq 1 ] && echo "${QEMU_BIN:-}" ;;
    225         x64)     # No qemu-user fallback for x64 in current harnesses.
    226                  echo "" ;;
    227         rv64)    # qemu-riscv64 user-mode is the easiest way to exec
    228                  # rv64 ELFs on a non-rv64 host without podman.
    229                  if [ -n "${QEMU_RV64_BIN:-}" ]; then
    230                      echo "${QEMU_RV64_BIN}"
    231                  elif command -v qemu-riscv64 >/dev/null 2>&1; then
    232                      command -v qemu-riscv64
    233                  else
    234                      echo ""
    235                  fi
    236                  ;;
    237         *)       echo "" ;;
    238     esac
    239 }
    240 
    241 exec_target_supported() {
    242     local tag="$1" os
    243     os="$(_exec_target_os "$tag")"
    244     # VM-backed OSes: qemu + a provisioned/reachable VM (see exec_vm.sh).
    245     case "$os" in freebsd|windows) exec_vm_supported "$tag"; return $? ;; esac
    246     # Freestanding: qemu-system bare-metal (see exec_bare.sh).
    247     case "$os" in freestanding) exec_bare_supported "$(_exec_target_arch "$tag")"; return $? ;; esac
    248     # macOS has no podman/qemu fallback — Mach-O exec requires a Darwin
    249     # host with matching arch. Cross-OS exec (macOS-on-Linux) is not
    250     # supported.
    251     if [ "$os" = "macos" ]; then
    252         _exec_target_native "$tag"
    253         return $?
    254     fi
    255     _exec_target_native "$tag" && return 0
    256     [ -n "$(_exec_target_qemu "$tag")" ] && return 0
    257     # podman is only a usable runner once the arch's pinned image is provisioned
    258     # locally (run path is --pull=never); otherwise report unsupported so callers
    259     # SKIP cleanly instead of failing on a missing image.
    260     [ "${have_podman:-0}" -eq 1 ] && _exec_target_image_present "$tag" && return 0
    261     return 1
    262 }
    263 
    264 # Synchronous run; sets RUN_RC.
    265 exec_target_run() {
    266     local tag="$1" exe="$2" out="$3" err="$4"
    267     local os qemu
    268     os="$(_exec_target_os "$tag")"
    269     case "$os" in freebsd|windows) exec_vm_run "$tag" "$exe" "$out" "$err"; return ;; esac
    270     if [ "$os" = freestanding ]; then
    271         # `exe` is the corpus object; exec_bare links it with the per-arch stub
    272         # into a bootable image and runs it under qemu-system.
    273         local barch="$(_exec_target_arch "$tag")"
    274         exec_bare_setup "$barch" "$EXEC_BARE_WORK" >/dev/null 2>&1
    275         exec_bare_run "$barch" "$exe" "$EXEC_BARE_WORK" "$EXEC_BARE_WORK/.run.rc" >/dev/null 2>&1 \
    276             && RUN_RC="$(cat "$EXEC_BARE_WORK/.run.rc" 2>/dev/null || echo 127)" \
    277             || RUN_RC=127
    278         : > "$out"; : > "$err"
    279         return
    280     fi
    281     if _exec_target_native "$tag"; then
    282         "$exe" >"$out" 2>"$err"; RUN_RC=$?; return
    283     fi
    284     if [ "$os" = "macos" ]; then
    285         # Mach-O cannot run via podman/qemu — only Darwin-native.
    286         RUN_RC=127; return
    287     fi
    288     qemu="$(_exec_target_qemu "$tag")"
    289     if [ -n "$qemu" ]; then
    290         "$qemu" "$exe" >"$out" 2>"$err"; RUN_RC=$?; return
    291     fi
    292     if [ "${have_podman:-0}" -eq 1 ]; then
    293         local dir base platform image platform_flag=()
    294         dir="$(cd "$(dirname "$exe")" && pwd)"; base="$(basename "$exe")"
    295         platform="$(_exec_target_platform "$tag")"
    296         image="$(_exec_target_image "$tag")"
    297         # `--platform` triggers a registry manifest lookup (~30 s) even
    298         # when the local image already matches. Only pass it when podman
    299         # would otherwise have to emulate — i.e. the podman machine's
    300         # native arch differs from the target. (On Darwin/arm64 the
    301         # podman VM is already linux/arm64, so aarch64 targets skip the
    302         # flag even though the host can't load the ELF directly.)
    303         if ! _exec_target_podman_native "$tag"; then
    304             platform_flag=(--platform "$platform")
    305         fi
    306         # ${arr[@]+"${arr[@]}"} (not "${arr[@]}") so an empty platform_flag
    307         # expands to zero args under `set -u` on bash 3.2 — macOS /bin/sh —
    308         # where a bare "${arr[@]}" on an empty array is an unbound-var error.
    309         podman run --rm --pull=never ${platform_flag[@]+"${platform_flag[@]}"} --net=none \
    310             -v "$dir":/work:Z -w /work \
    311             "$image" "./$base" \
    312             >"$out" 2>"$err"
    313         RUN_RC=$?; return
    314     fi
    315     RUN_RC=127
    316 }
    317 
    318 # Queue an exe to run later. Stored verbatim; flush writes <rc_file> with
    319 # the integer exit code, and routes stdout/stderr to <out_file>/<err_file>.
    320 exec_target_queue() {
    321     EXEC_TARGET_TAGS+=("$1")
    322     EXEC_TARGET_NAMES+=("$2")
    323     EXEC_TARGET_EXES+=("$3")
    324     EXEC_TARGET_OUTS+=("$4")
    325     EXEC_TARGET_ERRS+=("$5")
    326     EXEC_TARGET_RCS+=("$6")
    327 }
    328 
    329 exec_target_queue_size() { echo "${#EXEC_TARGET_EXES[@]}"; }
    330 
    331 # Lifecycle hooks for stateful runners. The stateless runners (native/qemu/
    332 # podman) need neither; for VM tags these boot the VM lazily (idempotent) and
    333 # tear down every VM we booted at suite end. A consumer that may run VM tags
    334 # should `trap exec_target_teardown_all EXIT` once. No-ops for linux/macos.
    335 exec_target_setup() {
    336     case "$(_exec_target_os "$1")" in
    337         freebsd|windows) exec_vm_setup "$1" ;;
    338         freestanding)    exec_bare_setup "$(_exec_target_arch "$1")" "$EXEC_BARE_WORK" ;;
    339         *) return 0 ;;
    340     esac
    341 }
    342 exec_target_teardown_all() { exec_vm_teardown_all; }
    343 
    344 # Internal: drain every entry whose tag matches $1, using qemu (if
    345 # available for that arch), podman batched run, or the no-runner stub.
    346 _exec_target_flush_tag() {
    347     local tag="$1" os
    348     os="$(_exec_target_os "$tag")"
    349     local idx=()
    350     local i=0 n="${#EXEC_TARGET_EXES[@]}"
    351     while [ $i -lt "$n" ]; do
    352         [ "${EXEC_TARGET_TAGS[$i]}" = "$tag" ] && idx+=("$i")
    353         i=$((i+1))
    354     done
    355     [ "${#idx[@]}" -eq 0 ] && return 0
    356 
    357     # VM-backed OSes: one batched VM session (boots lazily, stays warm). See
    358     # exec_vm.sh. Must branch before the native/qemu/podman logic, which would
    359     # otherwise try to run a FreeBSD/Windows binary under a Linux runner.
    360     case "$os" in
    361         freebsd|windows) exec_vm_flush_tag "$tag" "${idx[@]}"; return $? ;;
    362         freestanding)
    363             # Bare-metal: each queued "exe" is a corpus object; link + boot it.
    364             local barch bk
    365             barch="$(_exec_target_arch "$tag")"
    366             exec_bare_setup "$barch" "$EXEC_BARE_WORK" >/dev/null 2>&1
    367             for bk in "${idx[@]}"; do
    368                 exec_bare_run "$barch" "${EXEC_TARGET_EXES[$bk]}" "$EXEC_BARE_WORK" \
    369                     "${EXEC_TARGET_RCS[$bk]}" >/dev/null 2>&1 \
    370                     || echo 127 > "${EXEC_TARGET_RCS[$bk]}"
    371                 : > "${EXEC_TARGET_OUTS[$bk]}"; : > "${EXEC_TARGET_ERRS[$bk]}"
    372             done
    373             return 0 ;;
    374     esac
    375 
    376     local k
    377     # Native exec (Linux-on-Linux, Darwin-on-Darwin) — same loop.
    378     if _exec_target_native "$tag"; then
    379         for k in "${idx[@]}"; do
    380             "${EXEC_TARGET_EXES[$k]}" \
    381                 >"${EXEC_TARGET_OUTS[$k]}" 2>"${EXEC_TARGET_ERRS[$k]}"
    382             echo $? >"${EXEC_TARGET_RCS[$k]}"
    383         done
    384         return 0
    385     fi
    386     # macOS: no fallback — mark as 127 so callers can SKIP cleanly.
    387     if [ "$os" = "macos" ]; then
    388         for k in "${idx[@]}"; do
    389             : >"${EXEC_TARGET_OUTS[$k]}"
    390             : >"${EXEC_TARGET_ERRS[$k]}"
    391             echo 127 >"${EXEC_TARGET_RCS[$k]}"
    392         done
    393         return 0
    394     fi
    395 
    396     local qemu; qemu="$(_exec_target_qemu "$tag")"
    397     if [ -n "$qemu" ]; then
    398         for k in "${idx[@]}"; do
    399             "$qemu" "${EXEC_TARGET_EXES[$k]}" \
    400                 >"${EXEC_TARGET_OUTS[$k]}" 2>"${EXEC_TARGET_ERRS[$k]}"
    401             echo $? >"${EXEC_TARGET_RCS[$k]}"
    402         done
    403         return 0
    404     fi
    405     if [ "${have_podman:-0}" -eq 1 ]; then
    406         if [ -z "${EXEC_TARGET_MOUNT_ROOT:-}" ]; then
    407             echo "exec_target_flush: EXEC_TARGET_MOUNT_ROOT must be set" >&2
    408             return 2
    409         fi
    410         local platform image platform_flag=() case_to
    411         # Per-case wall-clock cap inside the batched container. Without it a
    412         # single hanging exe (e.g. a miscompiled loop, or qemu-user wedging on
    413         # one binary) blocks the whole single-container run, leaving every
    414         # later case with no .rc — which the caller reads back as 127 and
    415         # reports as a mass failure. With it, a hang is killed (rc 137) and the
    416         # loop moves on, so a real hang fails exactly one case. Override with
    417         # EXEC_CASE_TIMEOUT (seconds); generous by default for slow TCG.
    418         case_to="${EXEC_CASE_TIMEOUT:-20}"
    419         platform="$(_exec_target_platform "$tag")"
    420         image="$(_exec_target_image "$tag")"
    421         if ! _exec_target_podman_native "$tag"; then
    422             platform_flag=(--platform "$platform")
    423         fi
    424         # Manifest is fed via stdin; one tab-separated line per case.
    425         # The in-container shell loop runs each exe and writes its rc
    426         # to the bind-mounted .rc file, so the host can poll results
    427         # after `podman run` returns. stdout/stderr from individual
    428         # exes go to their .out/.err files inside the same mount.
    429         {
    430             for k in "${idx[@]}"; do
    431                 printf '%s\t%s\t%s\t%s\n' \
    432                     "${EXEC_TARGET_EXES[$k]}" \
    433                     "${EXEC_TARGET_OUTS[$k]}" \
    434                     "${EXEC_TARGET_ERRS[$k]}" \
    435                     "${EXEC_TARGET_RCS[$k]}"
    436             done
    437         } | podman run -i --rm --pull=never ${platform_flag[@]+"${platform_flag[@]}"} --net=none \
    438                 -e EXEC_CASE_TIMEOUT="$case_to" \
    439                 -v "$EXEC_TARGET_MOUNT_ROOT":"$EXEC_TARGET_MOUNT_ROOT":Z \
    440                 "$image" \
    441                 /bin/sh -c '
    442 set -u
    443 _to="${EXEC_CASE_TIMEOUT:-20}"
    444 if command -v timeout >/dev/null 2>&1; then _t="timeout -s KILL $_to"; else _t=""; fi
    445 while IFS="	" read -r exe out err rc; do
    446     $_t "$exe" >"$out" 2>"$err"
    447     echo $? >"$rc"
    448 done
    449 '
    450         return 0
    451     fi
    452     # No runner: mark each as 127, matching the prior fallback.
    453     for k in "${idx[@]}"; do
    454         : >"${EXEC_TARGET_OUTS[$k]}"
    455         : >"${EXEC_TARGET_ERRS[$k]}"
    456         echo 127 >"${EXEC_TARGET_RCS[$k]}"
    457     done
    458 }
    459 
    460 # Drain the queue. Reads back via the .rc files written into the
    461 # bind-mounted tree; callers iterate their own bookkeeping arrays after
    462 # this returns. Each tag present in the queue runs in its own batch.
    463 exec_target_flush() {
    464     [ "${#EXEC_TARGET_EXES[@]}" -eq 0 ] && return 0
    465 
    466     # Distinct tags in queue order. Bash 3.2 has no associative arrays;
    467     # use a small linear scan.
    468     local seen=() a present k
    469     local i=0 n="${#EXEC_TARGET_TAGS[@]}"
    470     while [ $i -lt "$n" ]; do
    471         a="${EXEC_TARGET_TAGS[$i]}"
    472         present=0
    473         for k in "${seen[@]:-}"; do [ "$k" = "$a" ] && present=1 && break; done
    474         [ "$present" -eq 0 ] && seen+=("$a")
    475         i=$((i+1))
    476     done
    477 
    478     local rc=0
    479     for a in "${seen[@]}"; do
    480         _exec_target_flush_tag "$a" || rc=$?
    481     done
    482 
    483     EXEC_TARGET_TAGS=()
    484     EXEC_TARGET_NAMES=()
    485     EXEC_TARGET_EXES=()
    486     EXEC_TARGET_OUTS=()
    487     EXEC_TARGET_ERRS=()
    488     EXEC_TARGET_RCS=()
    489     return $rc
    490 }