From 9c3e14acc040e7d0a18bc428abfef833713f520b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 08:35:20 +0000 Subject: [PATCH 1/4] Accept wget in studio/setup.sh, as install.sh already does install.sh takes either transport everywhere: download() and _http_get both try curl then wget, and the transport gate only fires when both are missing. A wget-only box therefore installs fine, reaches studio/setup.sh, and finds curl as the only way to fetch anything there. Two sites: the uv bootstrap, where the fallback is silent (USE_UV stays false and fast_install degrades to python -m pip), and the PyPI version check. Both now go through a helper with install.sh's preference order. Verified in a PATH containing wget but no curl: both helpers return 0 and the uv installer is fetched, while the bare `curl -LsSf` this replaces exits 127. --- studio/setup.sh | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/studio/setup.sh b/studio/setup.sh index f623c9ab0b..8b81c215ec 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -972,14 +972,39 @@ install_python_stack() { python "$SCRIPT_DIR/install_python_stack.py" } +# ── HTTP GET to stdout (supports curl and wget) ── +# install.sh accepts either transport everywhere (its download() and _http_get), +# so a wget-only box gets that far and then stalled here, where curl was the only +# way to fetch anything. Same preference order: curl, else wget. +_setup_http_get() { + if command -v curl >/dev/null 2>&1; then + curl -LsSf "$1" + elif command -v wget >/dev/null 2>&1; then + wget -qO- "$1" + else + return 1 + fi +} + +# Same, with a deadline, for the checks that must not hang the install. +_setup_http_get_timed() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --max-time 5 "$1" + elif command -v wget >/dev/null 2>&1; then + wget -qO- --timeout=5 "$1" + else + return 1 + fi +} + USE_UV=false if command -v uv &>/dev/null; then USE_UV=true elif { if _is_verbose; then - curl -LsSf https://astral.sh/uv/install.sh | sh + _setup_http_get https://astral.sh/uv/install.sh | sh else - curl -LsSf https://astral.sh/uv/install.sh | sh > /dev/null 2>&1 + _setup_http_get https://astral.sh/uv/install.sh | sh > /dev/null 2>&1 fi }; then export PATH="$HOME/.local/bin:$PATH" @@ -1020,7 +1045,7 @@ import sys; from importlib.metadata import version print(version(sys.argv[1])) " "$_PKG_NAME" 2>/dev/null || echo "") - LATEST_VER=$(curl -fsSL --max-time 5 "https://pypi.org/pypi/$_PKG_NAME/json" 2>/dev/null \ + LATEST_VER=$(_setup_http_get_timed "https://pypi.org/pypi/$_PKG_NAME/json" 2>/dev/null \ | "$VENV_DIR/bin/python" -c "import sys,json; print(json.load(sys.stdin)['info']['version'])" 2>/dev/null \ || echo "") From 3d314ff7a7f7af8f7100e8bc5c7e40df9507e6ca Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 08:45:45 +0000 Subject: [PATCH 2/4] Cap the wget version check at one attempt wget's --timeout is per operation and it retries 20 times by default, so a server that accepts the request and then stalls stretched the bounded PyPI version check to 245s against a local stalling server; --tries=1 brings it back to the 5s curl's --max-time gives. Adds tests/sh/test_setup_http_get.sh covering both helpers: curl preferred, wget accepted, neither is a non-zero return, and the wget flags. --- studio/setup.sh | 5 +- tests/sh/test_setup_http_get.sh | 100 ++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/sh/test_setup_http_get.sh diff --git a/studio/setup.sh b/studio/setup.sh index 8b81c215ec..172b292512 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -987,11 +987,14 @@ _setup_http_get() { } # Same, with a deadline, for the checks that must not hang the install. +# wget's --timeout is per operation and it retries 20 times by default, so a +# server that accepts the request and then stalls drags a "5 second" check out +# to minutes; --tries=1 keeps it to the single attempt curl's --max-time gives. _setup_http_get_timed() { if command -v curl >/dev/null 2>&1; then curl -fsSL --max-time 5 "$1" elif command -v wget >/dev/null 2>&1; then - wget -qO- --timeout=5 "$1" + wget -qO- --timeout=5 --tries=1 "$1" else return 1 fi diff --git a/tests/sh/test_setup_http_get.sh b/tests/sh/test_setup_http_get.sh new file mode 100644 index 0000000000..ac81af836d --- /dev/null +++ b/tests/sh/test_setup_http_get.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# studio/setup.sh fetch helpers: curl preferred, wget accepted, neither is an error. +# install.sh takes either transport everywhere, so a wget-only box installs fine +# and used to stall in setup.sh, where curl was the only way to fetch anything. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SETUP_SH="$SCRIPT_DIR/../../studio/setup.sh" +PASS=0 +FAIL=0 + +assert_eq() { + _label="$1"; _expected="$2"; _actual="$3" + if [ "$_actual" = "$_expected" ]; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected '$_expected', got '$_actual')" + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + _label="$1"; _haystack="$2"; _needle="$3" + if echo "$_haystack" | grep -qF -- "$_needle"; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected to find '$_needle' in '$_haystack')" + FAIL=$((FAIL + 1)) + fi +} + +# ── Extract the two helpers from setup.sh ── +_FN_FILE=$(mktemp) +sed -n '/^_setup_http_get()/,/^}/p' "$SETUP_SH" > "$_FN_FILE" +sed -n '/^_setup_http_get_timed()/,/^}/p' "$SETUP_SH" >> "$_FN_FILE" + +for _fn in _setup_http_get _setup_http_get_timed; do + grep -q "^$_fn()" "$_FN_FILE" || { echo " FAIL: $_fn not found in setup.sh"; exit 1; } +done + +_MOCK=$(mktemp -d) +_LOG="$_MOCK/argv.log" + +_make_shim() { + cat > "$_MOCK/$1" <> "$_LOG" +echo "$1-body" +EOF + chmod +x "$_MOCK/$1" +} + +# PATH holds only the shims, so an absent shim is genuinely absent (not a stub +# that command -v still finds). +_run() { + _have="$1"; _call="$2" + rm -f "$_MOCK"/curl "$_MOCK"/wget "$_LOG" + for _t in $_have; do _make_shim "$_t"; done + ( PATH="$_MOCK"; export PATH; . "$_FN_FILE"; $_call "https://example.invalid/x" ) 2>/dev/null +} + +_argv() { cat "$_LOG" 2>/dev/null | tr '\n' ' '; } + +echo "=== _setup_http_get ===" + +assert_eq "curl preferred when both exist" "curl-body" "$(_run 'curl wget' _setup_http_get)" +assert_contains "curl call keeps -LsSf" "$(_argv)" "curl -LsSf" + +assert_eq "wget used when curl is missing" "wget-body" "$(_run 'wget' _setup_http_get)" +assert_contains "wget call writes to stdout" "$(_argv)" "wget -qO-" + +_out=$(_run '' _setup_http_get || true) +assert_eq "no transport: empty output" "" "$_out" +_rc=0; _run '' _setup_http_get >/dev/null 2>&1 || _rc=$? +assert_eq "no transport: non-zero exit" "1" "$_rc" + +echo "" +echo "=== _setup_http_get_timed ===" + +assert_eq "curl preferred when both exist" "curl-body" "$(_run 'curl wget' _setup_http_get_timed)" +assert_contains "curl bounds the whole transfer" "$(_argv)" "--max-time 5" + +assert_eq "wget used when curl is missing" "wget-body" "$(_run 'wget' _setup_http_get_timed)" +assert_contains "wget sets the timeout" "$(_argv)" "--timeout=5" +# wget's --timeout is per operation and it retries 20 times by default, so +# without --tries=1 a stalling server turns this bounded check into minutes. +assert_contains "wget limited to one attempt" "$(_argv)" "--tries=1" + +_rc=0; _run '' _setup_http_get_timed >/dev/null 2>&1 || _rc=$? +assert_eq "no transport: non-zero exit" "1" "$_rc" + +rm -rf "$_MOCK" "$_FN_FILE" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 From a979cee564315e031223609880778435287b81f4 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 08:55:13 +0000 Subject: [PATCH 3/4] Bound the wget version check by wall clock, not per operation wget's --timeout is per network operation, so a response that dribbles a byte inside every interval never ends: against a local drip server the check was still running after 45s, where curl's --max-time 5 stops at 5s. Wrapping wget in coreutils timeout gives the same ceiling (5s, rc 124 in the same repro). timeout is not in a base macOS install, so its absence falls back to the per-operation bound instead of dropping the check; that path only matters without curl, which macOS ships. --- studio/setup.sh | 15 +++++++++++---- tests/sh/test_setup_http_get.sh | 8 +++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/studio/setup.sh b/studio/setup.sh index 172b292512..be5b4e99b0 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -987,14 +987,21 @@ _setup_http_get() { } # Same, with a deadline, for the checks that must not hang the install. -# wget's --timeout is per operation and it retries 20 times by default, so a -# server that accepts the request and then stalls drags a "5 second" check out -# to minutes; --tries=1 keeps it to the single attempt curl's --max-time gives. +# wget has no equivalent of curl's total-transfer --max-time: --timeout is per +# operation and it retries 20 times by default, so a stalled server dragged this +# check out to minutes and a slow drip never ended it at all. --tries=1 plus an +# outer `timeout` gives the same 5 second ceiling; without coreutils timeout +# (curl-less macOS would be the case, and macOS ships curl) we keep the +# per-operation bound rather than skipping the check. _setup_http_get_timed() { if command -v curl >/dev/null 2>&1; then curl -fsSL --max-time 5 "$1" elif command -v wget >/dev/null 2>&1; then - wget -qO- --timeout=5 --tries=1 "$1" + if command -v timeout >/dev/null 2>&1; then + timeout 5 wget -qO- --timeout=5 --tries=1 "$1" + else + wget -qO- --timeout=5 --tries=1 "$1" + fi else return 1 fi diff --git a/tests/sh/test_setup_http_get.sh b/tests/sh/test_setup_http_get.sh index ac81af836d..5f35f84de4 100644 --- a/tests/sh/test_setup_http_get.sh +++ b/tests/sh/test_setup_http_get.sh @@ -58,7 +58,7 @@ EOF # that command -v still finds). _run() { _have="$1"; _call="$2" - rm -f "$_MOCK"/curl "$_MOCK"/wget "$_LOG" + rm -f "$_MOCK"/curl "$_MOCK"/wget "$_MOCK"/timeout "$_LOG" for _t in $_have; do _make_shim "$_t"; done ( PATH="$_MOCK"; export PATH; . "$_FN_FILE"; $_call "https://example.invalid/x" ) 2>/dev/null } @@ -90,6 +90,12 @@ assert_contains "wget sets the timeout" "$(_argv)" "--timeout=5" # without --tries=1 a stalling server turns this bounded check into minutes. assert_contains "wget limited to one attempt" "$(_argv)" "--tries=1" +# --timeout is per operation, so a drip response never ends the transfer; the +# outer timeout is what actually matches curl's --max-time. +assert_eq "timeout wraps wget when available" "timeout-body" "$(_run 'wget timeout' _setup_http_get_timed)" +assert_contains "wall clock deadline on wget" "$(_argv)" "timeout 5 wget -qO- --timeout=5 --tries=1" +assert_eq "no timeout binary: wget still runs" "wget-body" "$(_run 'wget' _setup_http_get_timed)" + _rc=0; _run '' _setup_http_get_timed >/dev/null 2>&1 || _rc=$? assert_eq "no transport: non-zero exit" "1" "$_rc" From d901b70c0f03ab1ad122de8f71a5bf690672714a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 09:06:47 +0000 Subject: [PATCH 4/4] Tighten the setup.sh fetch helper comments --- studio/setup.sh | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/studio/setup.sh b/studio/setup.sh index be5b4e99b0..4916f9b0c0 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -973,9 +973,8 @@ install_python_stack() { } # ── HTTP GET to stdout (supports curl and wget) ── -# install.sh accepts either transport everywhere (its download() and _http_get), -# so a wget-only box gets that far and then stalled here, where curl was the only -# way to fetch anything. Same preference order: curl, else wget. +# install.sh takes either transport everywhere, so a wget-only box installs fine +# and then stalled here, where curl was the only way to fetch anything. _setup_http_get() { if command -v curl >/dev/null 2>&1; then curl -LsSf "$1" @@ -987,12 +986,11 @@ _setup_http_get() { } # Same, with a deadline, for the checks that must not hang the install. -# wget has no equivalent of curl's total-transfer --max-time: --timeout is per -# operation and it retries 20 times by default, so a stalled server dragged this -# check out to minutes and a slow drip never ended it at all. --tries=1 plus an -# outer `timeout` gives the same 5 second ceiling; without coreutils timeout -# (curl-less macOS would be the case, and macOS ships curl) we keep the -# per-operation bound rather than skipping the check. +# wget has nothing like curl's total-transfer --max-time: --timeout is per +# operation and it retries 20 times, so a stalled server took minutes and a slow +# drip never ended. --tries=1 plus an outer `timeout` restores the 5s ceiling; +# without timeout (base macOS, which ships curl anyway) the per-operation bound +# stands rather than the check being dropped. _setup_http_get_timed() { if command -v curl >/dev/null 2>&1; then curl -fsSL --max-time 5 "$1"