This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/texera.git
commit 0f846092c61c2e9b3a0739be7507dc643a97607d Author: Yicong Huang <[email protected]> AuthorDate: Wed Jul 29 23:48:30 2026 -0400 feat(local-dev): support Linux (#7077) ### What changes were proposed in this PR? Makes `bin/local-dev.sh` work on Linux, not just macOS. The script already carried some Linux support — `_find_jdk17` globs `/usr/lib/jvm/*`, SDKMAN and asdf are probed, the docker install hint has a `Linux:` line — but the three platform probes underneath it were macOS-only. The first was fatal, the other two degraded silently. **1. Host LAN IP detection (the hard blocker).** `_detect_host_lan_ip` probed `route get default` and `ipconfig getifaddr en0..en10`. Neither exists on Linux: `route` is net-tools with different syntax, `ipconfig` is macOS/Windows, and the interfaces are `eth0`/`enp*`. So `_require_host_lan_ip` aborted every `up`/`auto`: ``` Before: linux + up -> FATAL: could not detect a host LAN IP After: linux + up -> LAN IP from `ip route` / `ip -4 addr`, stack comes up ``` Split into `_detect_host_lan_ip_darwin` / `_detect_host_lan_ip_linux` behind a `uname` dispatcher, so the macOS path is byte-for-byte what it was. The Linux probe follows the same two steps — interface backing the default route first, then a scan — and the scan's exclusion list is the interesting part: this address exists to be reachable from **both** the host JVMs and the lakekeeper container, so a container bridge (`docker0`, `br-*`, `veth*`) is wrong even though it is a perfectly good local IPv4, and an overlay/VPN address (tailscale, zerotier, wireguard) is not reachable from the docker bridge at all. On a typical box `hostname -I` prints `10.10.10.30 172.17.0.1`, and picking the second would defeat the whole point of the variable. The FATAL message also named only the macOS probes; on Linux none of them had run, which made the message actively misleading. It now names the probes that actually executed. **2. Artifact mtime.** `svc_artifact_mtime` used BSD `stat -f "%Sm" -t FMT`. GNU coreutils reads `-f` as "file system info" and the format strings as filenames — it writes a diagnostic to stderr, prints filesystem fields on stdout and exits 1 — so `watch`'s ARTIFACT MTIME column rendered that garbage (the JVM call site had no `2>/dev/null` guard at all). Replaced with `_file_mtime_str`, which probes for the dialect rather than branching on `uname`, so GNU coreutils on macOS also works. **3. lsof dependency.** `listen_pid_for_port` required lsof. It ships with macOS but is not a default package on Debian/Ubuntu/Fedora, and without it every native service read as `stopped` — `up` relaunched services that were already running, and `down` silently no-opped. Falls back to `ss` from iproute2, matching on the local-address column so `:18080` doesn't answer for `:8080`. `-H` is deliberately not used (it postdates the iproute2 in older distros), and the "empty output means nothing is listening" contract that `svc_running_pid` / `wait_for_port` / the status table rely on is preserved. Two smaller `_install_hint` fixes: the docker line named `docker-compose-plugin`, which only lives in Docker's own apt repo rather than stock Ubuntu (`docker-compose-v2`), and the node / yarn / bun cases had no `Linux:` line at all. **Docs.** `bin/local-dev/README.md` gains a *Supported platforms* section: a table of which probe is used on which platform, the Linux prerequisites (iproute2, docker + compose v2, and the `docker`-group re-login that trips everyone up once), and why a docker-bridge address is not a valid `HOST_LAN_IP` even though it looks like a fine local IPv4. The bash suite's header comment also stopped claiming a real stack "needs a Mac", and now states that platform-specific checks report a skip rather than a pass. ### Any related issues, documentation, discussions? Closes #7065 ### How was this PR tested? New coverage in the existing `infra`-job suite. The LAN-IP cases run against a fake `ip` so the assertions don't depend on the test machine's interfaces, and the port-listener cases run against a real listener with lsof shimmed out: ``` $ bash bin/local-dev/tests/test_local_dev_sh.sh ... ✓ linux LAN IP: default route interface wins → 10.10.10.30 ✓ linux LAN IP: no default route: skips the docker bridge → 10.10.10.30 ✓ linux LAN IP: no default route: skips bridges, veth and tailscale → 192.168.1.42 ✓ linux LAN IP: default route interface has no global v4: falls back to scan → 192.168.1.42 ✓ linux LAN IP: nothing but loopback (refuses) ✓ linux LAN IP: only excluded interfaces (refuses) ✓ linux LAN IP: no `ip` on PATH (refuses) ✓ _detect_host_lan_ip dispatches per platform ✓ host-LAN-IP failure message is not macOS-only ✓ _file_mtime_str formats YYYY-MM-DD HH:MM (2026-07-29 22:17) ✓ _file_mtime_str refuses a missing file quietly ✓ _file_mtime_str refuses a missing argument quietly ✓ BSD stat is confined to _file_mtime_str ✓ listen_pid_for_port finds the listener (46263 → 193102) ✓ listen_pid_for_port works without lsof (193102) ✓ listen_pid_for_port: unused port yields nothing ✓ docker hint names the distro compose package ✓ every install hint has a Linux line 68 passed, 0 failed ``` ``` $ python -m pytest bin/local-dev/tests/ -q 1 failed, 42 passed ``` That failure is `test_is_dirty_after_seed_then_edit`, **pre-existing and unrelated** — it reproduces identically on a pristine `upstream/main` checkout on this machine, and this PR touches neither `tui.py` nor that test. Filed as #7075 and fixed there. End-to-end on Ubuntu 24.04.4 / x86_64, docker 29.1.3, with `HOST_LAN_IP` deliberately **not** exported — i.e. the exact case that used to abort: ``` [step] env HOST_LAN_IP='<unset>' [step] down [rc] down=0 [step] up (unfiltered) ... ✓ postgres :5432 healthy ✓ minio :9000 healthy ✓ lakefs :8000 healthy ✓ lakekeeper :8181 healthy ✓ litellm :4000 healthy ✓ config-service :9094 healthy ✓ access-control-service :9096 healthy ✓ file-service :9092 healthy ✓ workflow-compiling-service :9090 healthy ✓ computing-unit-master :8085 healthy ✓ computing-unit-managing-service :8082 healthy ✓ texera-web :8080 healthy ✓ agent-service :3001 healthy ✓ frontend :4200 healthy ✓ 14 of 14 services healthy [rc] up=0 ``` The detected address matches the kernel's own answer and is reachable at the MinIO port, which is what the variable is for: ``` detected=10.10.10.30 $ ip -4 route get 1.1.1.1 | awk '{print $7; exit}' 10.10.10.30 $ curl http://10.10.10.30:9000/minio/health/live -> HTTP 200 ``` lsof removed from `PATH` (shimmed to `exit 127`) now reports the same state rather than "everything stopped" — same shell, same PIDs, so the comparison is apples to apples: ``` ########## status: real lsof ########## ● config-service 9094 150646 running ● texera-web 8080 151007 running Status: 14 of 14 services running ########## status: lsof shimmed out (exit 127) ########## ● config-service 9094 150646 running ● texera-web 8080 151007 running Status: 14 of 14 services running ``` And `watch`'s ARTIFACT MTIME column renders a timestamp instead of GNU stat's diagnostics: ``` SERVICE PORT PID ARTIFACT MTIME SRC STATE ● config-service :9094 150646 2026-07-29 20:33 running ● access-control-service :9096 150670 2026-07-29 20:33 running ``` macOS is unchanged by construction: the Darwin probe body is untouched, and a test asserts the dispatcher still routes to it. I have no Mac to run the suite on, so CI's `infra (macos-latest)` job is what covers that side. The checks that can only hold on one platform — the `ss` fallback in particular, since `ss` is Linux-only and macOS ships lsof in the base system — are guarded and report a skip there rather than a pass. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5) --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> --- bin/local-dev/README.md | 34 ++++ bin/local-dev/main.sh | 130 +++++++++++++-- bin/local-dev/tests/test_local_dev_sh.sh | 262 ++++++++++++++++++++++++++++++- 3 files changed, 406 insertions(+), 20 deletions(-) diff --git a/bin/local-dev/README.md b/bin/local-dev/README.md index a560f9098b..e1a1f175fc 100644 --- a/bin/local-dev/README.md +++ b/bin/local-dev/README.md @@ -35,6 +35,40 @@ bin/local-dev.sh --help # full reference Everything in this folder is wired up by the wrapper at `bin/local-dev.sh`; you should not need to invoke any file inside `bin/local-dev/` directly. +## Supported platforms + +macOS and Linux. Where the two differ, `main.sh` picks the right probe by +platform — there is nothing to configure: + +| Concern | macOS | Linux | +| --- | --- | --- | +| Host LAN IP (the MinIO endpoint) | `route get default`, `ipconfig getifaddr` | `ip route show default`, `ip -4 addr show scope global` | +| Artifact mtime (`watch`'s ARTIFACT MTIME column) | BSD `stat -f` | GNU `stat -c` | +| Port → PID | `lsof` | `lsof`, falling back to `ss` | + +On top of the toolchain in [AGENTS.md](../../AGENTS.md) — JDK 17, Scala 2.13, +Python 3.12, Node 24 — plus sbt and docker, Linux needs: + +* **iproute2** (`ip`, `ss`). Essential on every mainstream distro, so normally + already installed. `ip` is required: without it the host LAN IP cannot be + detected and `up` refuses to start rather than publish an endpoint that only + works from one side. +* **docker with the compose v2 plugin.** On stock Ubuntu that is + `apt install docker.io docker-compose-v2`; `docker-compose-plugin` only + exists in Docker's own apt repository. +* **your user in the `docker` group** — `sudo usermod -aG docker "$USER"`, + then log in again. Group membership does not apply to shells that are + already running, so a `docker compose` permission error right after that + command is expected. + +`HOST_LAN_IP` is detected for you; export it only to override the choice, e.g. +to pin one interface on a multi-homed host. The address has to be reachable +from **both** the host JVMs and the containers, which is why the scan skips +container bridges (`docker0`, `br-*`, `veth*`) and overlay/VPN interfaces +(tailscale, zerotier, wireguard): `172.17.0.1` looks like a perfectly good +local IPv4 but is not reachable from inside another container's network +namespace. + ## Layout ``` diff --git a/bin/local-dev/main.sh b/bin/local-dev/main.sh index a1240208ac..58fa687f0d 100755 --- a/bin/local-dev/main.sh +++ b/bin/local-dev/main.sh @@ -462,22 +462,68 @@ fi # `localhost` only works for the host. `texera-minio` only works inside the # docker network. The host's LAN IP works from BOTH (host loopback for the # host, docker NAT'd out-and-back for the container). -_detect_host_lan_ip() { +# +# Both platform probes follow the same two steps: the interface backing the +# default route first (most reliable on a laptop that may have wifi + +# thunderbolt + tailscale all active), then a scan as a fallback. +_detect_host_lan_ip_darwin() { local iface="" ip="" - # 1. The interface backing the default route — most reliable on a - # laptop that may have wifi + thunderbolt + tailscale all active. iface=$(route get default 2>/dev/null | awk '/interface:/{print $2; exit}') if [[ -n "$iface" ]]; then ip=$(ipconfig getifaddr "$iface" 2>/dev/null) [[ -n "$ip" && "$ip" != 127.* ]] && { printf '%s\n' "$ip"; return 0; } fi - # 2. Fallback: linux `hostname -I`-equivalent walk over en*. for iface in en0 en1 en2 en3 en4 en5 en6 en7 en8 en9 en10; do ip=$(ipconfig getifaddr "$iface" 2>/dev/null) [[ -n "$ip" && "$ip" != 127.* ]] && { printf '%s\n' "$ip"; return 0; } done return 1 } + +_detect_host_lan_ip_linux() { + command -v ip >/dev/null 2>&1 || return 1 + local iface="" addr="" idx="" dev="" fam="" cidr="" + # 1. Interface backing the default route — take its first global IPv4, + # stripping the /prefix that `ip -o addr` appends. + iface=$(ip -4 route show default 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }') + # A default route over a container bridge or an overlay/VPN tunnel points at + # an address the containers can't reach back, which is the whole thing this + # variable must avoid — drop such an interface so the scan below wins. + case "$iface" in + docker*|br-*|bridge*|virbr*|veth*|tun*|tap*|cni*|flannel*|cali*|kube*|tailscale*|zt*|wg*) + iface="" ;; + esac + if [[ -n "$iface" ]]; then + addr=$(ip -4 -o addr show dev "$iface" scope global 2>/dev/null \ + | awk '{ split($4, a, "/"); print a[1]; exit }') + [[ -n "$addr" && "$addr" != 127.* ]] && { printf '%s\n' "$addr"; return 0; } + fi + # 2. Scan every global IPv4, skipping the interfaces that would defeat the + # purpose of this address. A container bridge (docker0, br-*, veth*) is + # reachable from the host but not from inside another container's + # network namespace the way MinIO needs; an overlay/VPN address + # (tailscale, zerotier) is not reachable from the docker bridge at all. + while read -r idx dev fam cidr _rest; do + [[ "$fam" == "inet" ]] || continue + case "$dev" in + lo|docker*|br-*|bridge*|virbr*|veth*|tun*|tap*|cni*|flannel*|cali*|kube*|tailscale*|zt*|wg*) + continue ;; + esac + addr="${cidr%%/*}" + [[ -n "$addr" && "$addr" != 127.* ]] && { printf '%s\n' "$addr"; return 0; } + done < <(ip -4 -o addr show scope global 2>/dev/null) + return 1 +} + +_detect_host_lan_ip() { + case "$(uname -s 2>/dev/null)" in + Darwin) _detect_host_lan_ip_darwin ;; + Linux) _detect_host_lan_ip_linux ;; + # Anything else (BSD, WSL oddities): try both rather than give up. + *) _detect_host_lan_ip_darwin || _detect_host_lan_ip_linux ;; + esac +} # Lazy resolver — called from subcommands that actually need to publish a # host-reachable S3 endpoint (cmd_up, cmd_auto). Subcommands like # `version`, `status`, `--help`, or `-i` don't talk to MinIO and shouldn't @@ -486,10 +532,18 @@ _require_host_lan_ip() { [[ -n "${HOST_LAN_IP:-}" ]] && return 0 HOST_LAN_IP="$(_detect_host_lan_ip)" || HOST_LAN_IP="" if [[ -z "$HOST_LAN_IP" ]]; then + local probes="" bridge_note="" + case "$(uname -s 2>/dev/null)" in + Darwin) probes="\`route get default\` / en0-en10" ;; + Linux) probes="\`ip route show default\` / \`ip -4 addr show scope global\`" + bridge_note=" outside the container bridges" ;; + *) probes="the macOS and Linux probes" + bridge_note=" outside the container bridges" ;; + esac echo "FATAL: could not detect a host LAN IP." >&2 echo " MinIO needs an address reachable from both docker (lakekeeper" >&2 echo " does S3 ops) and the host (JVMs read signed URLs back); none" >&2 - echo " of \`route get default\` / en0-en10 had a non-loopback IPv4." >&2 + echo " of $probes offered a non-loopback IPv4${bridge_note}." >&2 echo " Connect to a network or export HOST_LAN_IP=<your-IP> explicitly." >&2 exit 1 fi @@ -1133,20 +1187,23 @@ _install_hint() { node) printf " ${BOLD}install Node 20+ (needed for frontend & agent-service):${RESET}\n" printf " macOS: brew install node\n" + printf " Linux: use a version manager below — distro packages\n" + printf " (apt/dnf nodejs) are older than the frontend needs\n" printf " nvm: nvm install --lts && nvm use --lts\n" printf " fnm: fnm install --lts\n" printf " volta: volta install node\n" ;; yarn) printf " ${BOLD}install yarn (needed for the frontend):${RESET}\n" + printf " any OS: corepack enable ${DIM}# ships with Node; frontend pins yarn@4${RESET}\n" printf " macOS: brew install yarn\n" - printf " npm: npm install -g yarn\n" - printf " corepack: corepack enable\n" + printf " Linux: npm install -g yarn\n" ;; bun) printf " ${BOLD}install bun (needed for agent-service):${RESET}\n" printf " macOS: brew install oven-sh/bun/bun\n" - printf " curl: curl -fsSL https://bun.sh/install | bash\n" + printf " Linux: curl -fsSL https://bun.sh/install | bash\n" + printf " npm: npm install -g bun\n" ;; sbt) printf " ${BOLD}install sbt (needed to build the JVM services):${RESET}\n" @@ -1156,7 +1213,8 @@ _install_hint() { docker) printf " ${BOLD}install Docker (needed for postgres/minio/lakefs/lakekeeper/litellm):${RESET}\n" printf " macOS: download Docker Desktop from https://docker.com/products/docker-desktop\n" - printf " Linux: apt install docker.io docker-compose-plugin\n" + printf " Linux: apt install docker.io docker-compose-v2 ${DIM}# dnf: moby-engine docker-compose${RESET}\n" + printf " then sudo usermod -aG docker \"\$USER\" and log back in\n" ;; *) printf " ${DIM}no install hint for: %s${RESET}\n" "$tool" @@ -1194,9 +1252,53 @@ _diagnose_node() { } # --------- helpers --------- +# PID of whatever is listening on a TCP port, or nothing. Always exits 0 — +# "empty output" is the contract for "nothing is listening", and callers +# (svc_running_pid, wait_for_port, the status table) rely on that. +# +# lsof first: it ships with macOS and is what this has always used. But it is +# not a default package on Debian/Ubuntu/Fedora, and when it is missing every +# native service reads as stopped — `up` then relaunches services that are +# already running and `down` silently no-ops. So fall back to `ss` from +# iproute2, which is essential on any modern Linux. listen_pid_for_port() { - # || true so pipefail doesn't kill us when nothing is listening - lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -1 || true + local port="$1" pid="" + if command -v lsof >/dev/null 2>&1; then + # || true so pipefail doesn't kill us when nothing is listening + pid=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -1 || true) + [[ -n "$pid" ]] && { printf '%s\n' "$pid"; return 0; } + fi + if command -v ss >/dev/null 2>&1; then + # `ss -lntp` prints e.g. + # LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("java",pid=4827,fd=9)) + # Match on the local-address column ending in :<port> so :18080 doesn't + # answer for :8080. No -H: it postdates the iproute2 in older distros. + pid=$(ss -lntp 2>/dev/null \ + | awk -v suffix=":$port" '$4 ~ suffix"$" { print; exit }' \ + | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2 || true) + [[ -n "$pid" ]] && { printf '%s\n' "$pid"; return 0; } + fi + return 0 +} + +# Modification time of a file as "YYYY-MM-DD HH:MM", in whichever stat dialect +# is present. Non-zero and silent if the file is missing or unreadable, so +# callers can substitute their own placeholder. +# +# GNU coreutils and BSD disagree completely here: BSD's `stat -f FMT -t TIMEFMT` +# reads `-f` as "file system info" under GNU, which then treats the format +# strings as filenames — it writes a diagnostic to stderr, prints filesystem +# fields on stdout and exits 1. Probe rather than branch on `uname` so a +# GNU-coreutils macOS also works. +_file_mtime_str() { + local f="${1:-}" + [[ -n "$f" && -e "$f" ]] || return 1 + if stat -c '%y' "$f" >/dev/null 2>&1; then + # GNU: "2026-07-29 20:06:12.123456789 +0000" → keep date + HH:MM + stat -c '%y' "$f" 2>/dev/null | cut -c1-16 + else + stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$f" 2>/dev/null + fi } # Branch + short-sha of the deploy source ($REPO_ROOT), tab-separated, each @@ -1671,14 +1773,14 @@ svc_artifact_mtime() { done <<< "$globbed" fi if [[ ${#main_jars[@]} -gt 0 ]]; then - stat -f "%Sm" -t "%Y-%m-%d %H:%M" "${main_jars[0]}" + _file_mtime_str "${main_jars[0]}" || echo "—" return fi fi echo "—" ;; - bun) stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$(amap_get SVC_CWD "$svc")/bun.lock" 2>/dev/null || echo "—" ;; - yarn) stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$(amap_get SVC_CWD "$svc")/yarn.lock" 2>/dev/null || echo "—" ;; + bun) _file_mtime_str "$(amap_get SVC_CWD "$svc")/bun.lock" || echo "—" ;; + yarn) _file_mtime_str "$(amap_get SVC_CWD "$svc")/yarn.lock" || echo "—" ;; docker) echo "—" ;; esac } diff --git a/bin/local-dev/tests/test_local_dev_sh.sh b/bin/local-dev/tests/test_local_dev_sh.sh index 06a340b372..fbcd48a87f 100755 --- a/bin/local-dev/tests/test_local_dev_sh.sh +++ b/bin/local-dev/tests/test_local_dev_sh.sh @@ -20,10 +20,14 @@ # bash bin/local-dev/tests/test_local_dev_sh.sh # Exits 0 if every check passes, 1 otherwise. # -# Kept deliberately small: bringing up the actual stack needs Docker / -# sbt / a Mac and is out of scope for CI here. We cover the things that -# regress quietly — script syntax, version-detection, the subcommand -# dispatch, and graceful failure on garbage input. +# Kept deliberately small: bringing up the actual stack needs Docker, sbt and +# the rest of the toolchain, which is out of scope for CI here. We cover the +# things that regress quietly — script syntax, version-detection, the +# subcommand dispatch, and graceful failure on garbage input. +# +# Runs on macOS and Linux alike (CI's `infra` job covers both). Anything that +# can only hold on one of them is guarded by a `uname` check or by probing for +# the tool involved, and reports a skip rather than a pass. set -u @@ -593,7 +597,253 @@ else _fail "changelog references missing files:$missing_files" fi -# 29) Regression for #7075: `find -newer` is *strictly* newer, so a source whose +# 29) Host LAN IP detection on Linux. The macOS probes (`route get default`, +# `ipconfig getifaddr en0..en10`) do not exist there, so `up`/`auto` used to +# abort with "could not detect a host LAN IP" on every Linux box (#7065). +# Driven against a fake `ip` so the assertions don't depend on the test +# machine's interfaces. The docker-bridge case is the one that matters most: +# the whole point of HOST_LAN_IP is an address reachable from both the host +# and the lakekeeper container, and 172.17.0.1 is reachable from neither +# side the way we need. +lanip_fn=$(awk '/^_detect_host_lan_ip_linux\(\)/{f=1} f{print} f&&/^}/{exit}' "$MAIN_SH") +if [[ -z "$lanip_fn" ]]; then + _fail "_detect_host_lan_ip_linux helper missing" +else + _lan_dir=$(mktemp -d 2>/dev/null || mktemp -d -t ldlan) + # Fake `ip`: answers `route show default` from ip.route and + # `-o addr show [dev X] scope global` from ip.addr. + cat > "$_lan_dir/ip" <<'FAKE_IP' +#!/usr/bin/env bash +mode=""; want_dev="" +while [[ $# -gt 0 ]]; do + case "$1" in + route) mode=route ;; + addr) mode=addr ;; + dev) shift; want_dev="${1:-}" ;; + esac + shift +done +case "$mode" in + route) cat "$0.route" 2>/dev/null ;; + addr) + if [[ -n "$want_dev" ]]; then + awk -v d="$want_dev" '$2 == d' "$0.addr" 2>/dev/null + else + cat "$0.addr" 2>/dev/null + fi + ;; +esac +FAKE_IP + chmod +x "$_lan_dir/ip" + _lan_check() { # $1=label $2=expected stdout ("" = must fail) $3=route $4=addr + printf '%s\n' "$3" > "$_lan_dir/ip.route" + printf '%s\n' "$4" > "$_lan_dir/ip.addr" + local got="" rc=0 + got=$(PATH="$_lan_dir:$PATH"; eval "$lanip_fn"; _detect_host_lan_ip_linux) || rc=$? + if [[ -z "$2" ]]; then + if (( rc != 0 )) && [[ -z "$got" ]]; then + _pass "linux LAN IP: $1 (refuses)" + else + _fail "linux LAN IP: $1 should refuse" "rc=$rc got='$got'" + fi + elif [[ "$got" == "$2" ]]; then + _pass "linux LAN IP: $1 → $got" + else + _fail "linux LAN IP: $1" "expected '$2' got '$got' (rc=$rc)" + fi + } + _lan_check "default route interface wins" "10.10.10.30" \ + 'default via 10.10.10.1 dev eth0 proto static metric 100' \ + '2: eth0 inet 10.10.10.30/24 brd 10.10.10.255 scope global eth0 +3: docker0 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0' + _lan_check "no default route: skips the docker bridge" "10.10.10.30" \ + '' \ + '3: docker0 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0 +2: eth0 inet 10.10.10.30/24 brd 10.10.10.255 scope global eth0' + _lan_check "no default route: skips bridges, veth and tailscale" "192.168.1.42" \ + '' \ + '4: br-abc123 inet 172.18.0.1/16 scope global br-abc123 +5: veth9f2 inet 172.19.0.1/16 scope global veth9f2 +6: tailscale0 inet 100.101.102.103/32 scope global tailscale0 +2: enp5s0 inet 192.168.1.42/24 scope global enp5s0' + _lan_check "default route interface has no global v4: falls back to scan" "192.168.1.42" \ + 'default via 10.0.0.1 dev ppp0 proto static' \ + '2: enp5s0 inet 192.168.1.42/24 scope global enp5s0' + _lan_check "default route over a bridge/VPN is skipped, not trusted" "10.10.10.30" \ + 'default via 172.17.0.1 dev docker0 proto static' \ + '3: docker0 inet 172.17.0.1/16 scope global docker0 +2: eth0 inet 10.10.10.30/24 scope global eth0' + _lan_check "nothing but loopback" "" '' '' + _lan_check "only excluded interfaces" "" \ + '' \ + '3: docker0 inet 172.17.0.1/16 scope global docker0' + # Negative: no iproute2 at all must fail cleanly, not emit a bogus address. + _lan_no_ip=$(mktemp -d 2>/dev/null || mktemp -d -t ldlan2) + got=""; rc=0 + got=$(PATH="$_lan_no_ip"; eval "$lanip_fn"; _detect_host_lan_ip_linux) || rc=$? + if (( rc != 0 )) && [[ -z "$got" ]]; then + _pass "linux LAN IP: no \`ip\` on PATH (refuses)" + else + _fail "linux LAN IP: no \`ip\` should refuse" "rc=$rc got='$got'" + fi + rm -rf "$_lan_dir" "$_lan_no_ip" +fi + +# 30) The dispatcher must keep the macOS probes for Darwin and only use the +# Linux ones on Linux — the fix must not regress the platform that worked. +dispatch_fn=$(awk '/^_detect_host_lan_ip\(\)/{f=1} f{print} f&&/^}/{exit}' "$MAIN_SH") +if [[ "$dispatch_fn" == *"Darwin"* ]] \ + && [[ "$dispatch_fn" == *"_detect_host_lan_ip_darwin"* ]] \ + && [[ "$dispatch_fn" == *"_detect_host_lan_ip_linux"* ]]; then + _pass "_detect_host_lan_ip dispatches per platform" +else + _fail "_detect_host_lan_ip doesn't dispatch to both platform probes" +fi +# And the FATAL must stop naming only the macOS probes, since on Linux none of +# them ever ran. +if ! grep -q 'of \\`route get default\\` / en0-en10 had a non-loopback IPv4' "$MAIN_SH"; then + _pass "host-LAN-IP failure message is not macOS-only" +else + _fail "host-LAN-IP failure message still blames route get/en0-en10 on every platform" +fi + +# 31) Artifact mtime must be readable with either stat dialect. GNU coreutils +# rejects BSD's `stat -f "%Sm" -t FMT` outright (it reads -f as "file system +# info" and the format strings as filenames), so on Linux the ARTIFACT MTIME +# column rendered stat's error text instead of a timestamp. +mtime_fn=$(awk '/^_file_mtime_str\(\)/{f=1} f{print} f&&/^}/{exit}' "$MAIN_SH") +if [[ -z "$mtime_fn" ]]; then + _fail "_file_mtime_str helper missing" +else + _mt_dir=$(mktemp -d 2>/dev/null || mktemp -d -t ldmt) + : > "$_mt_dir/artifact.jar" + got=$(eval "$mtime_fn"; _file_mtime_str "$_mt_dir/artifact.jar") + if [[ "$got" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}$ ]]; then + _pass "_file_mtime_str formats YYYY-MM-DD HH:MM ($got)" + else + _fail "_file_mtime_str wrong format" "got '$got'" + fi + # Negative: a missing file must fail rather than print stat's diagnostics. + got=""; rc=0 + got=$(eval "$mtime_fn"; _file_mtime_str "$_mt_dir/nope.jar" 2>&1) || rc=$? + if (( rc != 0 )) && [[ -z "$got" ]]; then + _pass "_file_mtime_str refuses a missing file quietly" + else + _fail "_file_mtime_str should fail quietly on a missing file" "rc=$rc got='$got'" + fi + got=""; rc=0 + got=$(eval "$mtime_fn"; _file_mtime_str 2>&1) || rc=$? + if (( rc != 0 )) && [[ -z "$got" ]]; then + _pass "_file_mtime_str refuses a missing argument quietly" + else + _fail "_file_mtime_str should fail quietly with no argument" "rc=$rc got='$got'" + fi + rm -rf "$_mt_dir" +fi +# The BSD spelling is allowed to survive inside _file_mtime_str — that's the +# whole point of the helper — but nowhere else, or the next call site silently +# breaks on Linux again. +main_outside_helper=$(awk ' + /^_file_mtime_str\(\)/ { skip = 1 } + skip { if ($0 == "}") skip = 0; next } + { print } +' "$MAIN_SH") +if ! printf '%s\n' "$main_outside_helper" | grep -qE 'stat -f "%Sm" -t'; then + _pass "BSD stat is confined to _file_mtime_str" +else + _fail "main.sh still calls BSD-only \`stat -f \"%Sm\" -t\` outside _file_mtime_str" \ + "$(printf '%s\n' "$main_outside_helper" | grep -nE 'stat -f "%Sm" -t' | head -3 | tr '\n' '|')" +fi + +# 32) Port→PID lookup must not depend on lsof. It is standard on macOS but not +# installed by default on Debian/Ubuntu/Fedora, and without it every native +# service read as "stopped" — so `up` relaunched live services and `down` +# silently no-opped. Tested against a real listener, with lsof shimmed out. +listen_fn=$(awk '/^listen_pid_for_port\(\)/{f=1} f{print} f&&/^}/{exit}' "$MAIN_SH") +if [[ -z "$listen_fn" ]]; then + _fail "listen_pid_for_port helper missing" +elif ! command -v python3 >/dev/null 2>&1; then + _pass "skip: python3 not on PATH (port listener check)" +else + _lp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t ldlp) + # Bind an ephemeral port, print it, then hold it open until killed. + python3 -c ' +import socket, sys, time +s = socket.socket(); s.bind(("127.0.0.1", 0)); s.listen(1) +print(s.getsockname()[1], flush=True) +time.sleep(120) +' > "$_lp_dir/port" 2>/dev/null & + _lp_pid=$! + _lp_port="" + for _ in 1 2 3 4 5 6 7 8 9 10; do + _lp_port=$(head -1 "$_lp_dir/port" 2>/dev/null) + [[ -n "$_lp_port" ]] && break + sleep 0.3 + done + if [[ -z "$_lp_port" ]]; then + _fail "port listener check: could not start a listener" + else + got=$(eval "$listen_fn"; listen_pid_for_port "$_lp_port") + if [[ "$got" == "$_lp_pid" ]]; then + _pass "listen_pid_for_port finds the listener ($_lp_port → $got)" + else + _fail "listen_pid_for_port didn't find the listener" \ + "port=$_lp_port expected=$_lp_pid got='$got'" + fi + # Same answer with lsof unavailable — the Linux-without-lsof box. The + # fallback is `ss` from iproute2, which only exists on Linux; macOS has + # no equivalent and ships lsof in the base system, so there is nothing + # to fall back to (or to test) there. + if command -v ss >/dev/null 2>&1; then + printf '#!/bin/sh\nexit 127\n' > "$_lp_dir/lsof"; chmod +x "$_lp_dir/lsof" + got=$(PATH="$_lp_dir:$PATH"; eval "$listen_fn"; listen_pid_for_port "$_lp_port") + if [[ "$got" == "$_lp_pid" ]]; then + _pass "listen_pid_for_port works without lsof ($got)" + else + _fail "listen_pid_for_port needs lsof" \ + "port=$_lp_port expected=$_lp_pid got='$got'" + fi + else + _pass "skip: no \`ss\` on this platform (lsof fallback is Linux-only)" + fi + # Negative: a port nobody listens on yields empty output, not noise. + got=$(eval "$listen_fn"; listen_pid_for_port 1 2>&1) + if [[ -z "$got" ]]; then + _pass "listen_pid_for_port: unused port yields nothing" + else + _fail "listen_pid_for_port: unused port produced output" "got='$got'" + fi + fi + kill "$_lp_pid" 2>/dev/null + wait "$_lp_pid" 2>/dev/null + rm -rf "$_lp_dir" +fi + +# 33) Install hints have to be actionable on Linux: `docker-compose-plugin` +# only exists in Docker's own apt repo (stock Ubuntu ships +# `docker-compose-v2`), and node/yarn/bun had no Linux line at all. +hint_body=$(awk '/^_install_hint\(\)/{f=1} f{print} f&&/^}/{exit}' "$MAIN_SH") +if [[ "$hint_body" != *"docker-compose-plugin"* ]] \ + && [[ "$hint_body" == *"docker-compose-v2"* ]]; then + _pass "docker hint names the distro compose package" +else + _fail "docker hint still points at docker-compose-plugin" +fi +hint_missing="" +for tool in java python node yarn bun sbt docker; do + case_body=$(printf '%s\n' "$hint_body" | awk -v t="$tool" ' + $0 ~ "^[[:space:]]*"t"\\)" {f=1} + f {print} + f && /;;/ {exit}') + [[ "$case_body" == *"Linux"* ]] || hint_missing+=" $tool" +done +if [[ -z "$hint_missing" ]]; then + _pass "every install hint has a Linux line" +else + _fail "install hints with no Linux line:$hint_missing" +fi + +# 34) Regression for #7075: `find -newer` is *strictly* newer, so a source whose # mtime equals the build stamp's is invisible to the fast filter and `auto` # reports "everything up-to-date" without rebuilding it. The read side # therefore compares against a throwaway marker one second behind the stamp. @@ -649,7 +899,7 @@ else rm -rf "$_sd" fi -# 30) Wiring: the jvm dirty check must compare against the backdated marker +# 35) Wiring: the jvm dirty check must compare against the backdated marker # rather than the stamp, or #7075 is only half fixed (the shell path is the # one that gates the rebuild; tui.py only colours the SRC column). src_changed_body=$(awk 'index($0, "svc_src_changed()") == 1 {f=1} f{print} f && /^}/{exit}' "$MAIN_SH")
