#!/usr/bin/env bash # ============================================================================= # Cloudflare Speed Test — CLI # ----------------------------------------------------------------------------- # A dependency-free (bash + curl + coreutils only) internet speed test that # talks exclusively to https://speed-pages.pages.dev, which proxies every # measurement through to a Cloudflare Worker. Designed to run identically # whether it's executed as a file or piped straight into bash: # # curl https://speed-pages.pages.dev | bash # # Compatible with Bash 3.2+ (macOS) through Bash 5+ (Linux). No jq, no # Python, no Perl, no Node — only bash builtins, curl, awk, sed, printf, # tput, mktemp, head and dd. # ============================================================================= # ----------------------------------------------------------------------------- # 0. Bash guard # ----------------------------------------------------------------------------- # If this script is somehow invoked under a non-bash shell (e.g. BusyBox's # `ash` via `sh speedtest.sh`), re-exec it under bash if one is installed, # otherwise fail with a clear, actionable message instead of a wall of # syntax errors. if [ -z "${BASH_VERSION:-}" ]; then if command -v bash >/dev/null 2>&1; then exec bash "$0" "$@" else echo "Error: this speed test requires bash, but bash was not found." >&2 echo "Install bash (e.g. 'apk add bash' on Alpine) and try again." >&2 exit 1 fi fi # ----------------------------------------------------------------------------- # 1. Configuration # ----------------------------------------------------------------------------- API_BASE="${SPEEDTEST_API_BASE:-https://speed-pages.pages.dev}" PING_COUNT=10 # number of /ping round trips to sample PING_TIMEOUT=5 # seconds, per ping request DOWNLOAD_STREAMS=4 # parallel connections for the download test UPLOAD_STREAMS=4 # parallel connections for the upload test POLL_INTERVAL="0.2" # seconds between progress-bar redraws PROBE_SIZE_BYTES=$((2*1024*1024)) # 2 MiB per-stream probe download # curl connect/max time safety nets so a dead network can't hang forever. CURL_CONNECT_TIMEOUT=10 CURL_MAX_TIME=30 VERSION="1.0.0" # ----------------------------------------------------------------------------- # 2. Temp workspace + cleanup # ----------------------------------------------------------------------------- WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/cf-speedtest.XXXXXX" 2>/dev/null)" if [ -z "$WORKDIR" ] || [ ! -d "$WORKDIR" ]; then echo "Error: could not create a temporary working directory." >&2 exit 1 fi BG_PIDS="" cleanup() { # Kill any background transfer jobs we may have left running (e.g. if the # user hits Ctrl-C mid-test) and always restore the cursor + terminal. if [ -n "$BG_PIDS" ]; then kill $BG_PIDS >/dev/null 2>&1 fi [ -n "${TPUT_OK:-}" ] && tput cnorm 2>/dev/null rm -rf "$WORKDIR" 2>/dev/null } trap cleanup EXIT trap 'echo; echo "Interrupted."; exit 130' INT TERM # ----------------------------------------------------------------------------- # 3. Terminal capability detection # ----------------------------------------------------------------------------- IS_TTY=0 [ -t 1 ] && IS_TTY=1 TPUT_OK="" if command -v tput >/dev/null 2>&1 && [ "$IS_TTY" -eq 1 ]; then if tput cols >/dev/null 2>&1; then TPUT_OK=1 fi fi term_width() { if [ -n "$TPUT_OK" ]; then tput cols 2>/dev/null || echo 80 else echo "${COLUMNS:-80}" fi } # Unicode support: assume yes unless LANG/LC_ALL clearly say otherwise. UNICODE_OK=1 case "${LC_ALL:-${LANG:-}}" in *UTF-8*|*utf8*|*UTF8*) UNICODE_OK=1 ;; "") UNICODE_OK=0 ;; *) UNICODE_OK=0 ;; esac # Color support: only enable when attached to a real, capable terminal. COLOR_OK=0 if [ "$IS_TTY" -eq 1 ]; then if [ -n "$TPUT_OK" ]; then ncolors="$(tput colors 2>/dev/null || echo 0)" [ "$ncolors" -ge 8 ] 2>/dev/null && COLOR_OK=1 else # No tput available — fall back to raw ANSI, which almost every # terminal emulator (including BusyBox/Alpine consoles) understands. COLOR_OK=1 fi fi if [ "$COLOR_OK" -eq 1 ]; then ESC="$(printf '\033')" C_RESET="${ESC}[0m" C_BOLD="${ESC}[1m" C_DIM="${ESC}[2m" C_CYAN="${ESC}[36m" C_GRAY="${ESC}[90m" C_WHITE="${ESC}[97m" C_BRIGHT_GREEN="${ESC}[92m" C_GREEN="${ESC}[32m" C_YELLOW="${ESC}[33m" C_ORANGE="${ESC}[38;5;208m" C_RED="${ESC}[31m" else ESC=""; C_RESET=""; C_BOLD=""; C_DIM=""; C_CYAN=""; C_GRAY=""; C_WHITE="" C_BRIGHT_GREEN=""; C_GREEN=""; C_YELLOW=""; C_ORANGE=""; C_RED="" fi # Unicode glyphs with plain-ASCII fallbacks. if [ "$UNICODE_OK" -eq 1 ]; then GLYPH_CHECK="✓"; GLYPH_CROSS="✗"; GLYPH_GLOBE="🌍"; GLYPH_NET="🌐" GLYPH_BUILDING="🏢"; GLYPH_SAT="📡"; GLYPH_PING="🏓"; GLYPH_DOWN="⬇" GLYPH_UP="⬆"; GLYPH_BAR_FULL="█"; GLYPH_BAR_EMPTY="░"; GLYPH_RULE="━" SPINNER_FRAMES="⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏" else GLYPH_CHECK="[OK]"; GLYPH_CROSS="[X]"; GLYPH_GLOBE="*"; GLYPH_NET="*" GLYPH_BUILDING="*"; GLYPH_SAT="*"; GLYPH_PING="*"; GLYPH_DOWN="v" GLYPH_UP="^"; GLYPH_BAR_FULL="#"; GLYPH_BAR_EMPTY="-"; GLYPH_RULE="-" SPINNER_FRAMES="| / - \\" fi hide_cursor() { [ -n "$TPUT_OK" ] && tput civis 2>/dev/null; } show_cursor() { [ -n "$TPUT_OK" ] && tput cnorm 2>/dev/null; } # Clears the screen and resets the cursor to the top-left, the way most # well-behaved terminal tools (htop, less, etc.) start a fresh session. # Only runs on a real TTY — piping into a file or another program never # gets a clear, so redirected output stays clean and greppable. clear_screen() { [ "$IS_TTY" -eq 1 ] || return if [ -n "$TPUT_OK" ]; then tput clear 2>/dev/null else printf '\033[2J\033[H' fi } # ----------------------------------------------------------------------------- # 4. Low-level drawing helpers # ----------------------------------------------------------------------------- # Prints `$1` centered within the terminal width using spaces. center_text() { text="$1" width="$(term_width)" # Strip ANSI escapes before measuring length so colored strings still # center correctly. plain="$(printf '%s' "$text" | sed -E "s/${ESC}\[[0-9;]*m//g" 2>/dev/null)" [ -z "$plain" ] && plain="$text" len=${#plain} if [ "$len" -ge "$width" ]; then printf '%s\n' "$text" return fi pad=$(( (width - len) / 2 )) printf '%*s%s\n' "$pad" "" "$text" } # Draws a full-width horizontal rule using GLYPH_RULE. draw_rule() { width="$(term_width)" line="" i=0 while [ "$i" -lt "$width" ]; do line="${line}${GLYPH_RULE}" i=$((i+1)) done printf '%s%s%s\n' "$C_GRAY" "$line" "$C_RESET" } print_header() { draw_rule center_text "${C_BOLD}${C_WHITE}Cloudflare Speed Test${C_RESET}" draw_rule echo } # clear_line: wipes the current terminal line so the next \r-prefixed print # doesn't leave stray trailing characters from a longer previous line. # Deliberately does NOT depend on `tput el` — that capability is read from # each platform's terminfo database and behaves inconsistently between # macOS's bundled ncurses and Linux's, which is what made the progress bar # render as a single flat line on macOS instead of animating in place. # Padding to the full terminal width with spaces works identically # everywhere because it only relies on printf. clear_line() { printf '\r%*s\r' "$(term_width)" "" } # step_start "label" — prints a label with a placeholder, to be completed # later via step_done/step_fail on the same line. step_start() { printf '%s ' "$1" } step_done() { # step_done " result text" clear_line printf '%s %s\n' "${C_BRIGHT_GREEN}${GLYPH_CHECK}${C_RESET}" "$1" } step_fail() { clear_line printf '%s %s\n' "${C_RED}${GLYPH_CROSS}${C_RESET}" "$1" } # ----------------------------------------------------------------------------- # 5. Spinner # ----------------------------------------------------------------------------- # run_with_spinner "label" "resultfile" background_pid # Polls a background job's PID while animating a spinner on the same line, # then hands off to step_done/step_fail once the job exits. The caller is # responsible for starting the background job and writing its outcome # ("0" or "1" followed by a result line) into $resultfile. run_with_spinner() { label="$1" resultfile="$2" pid="$3" if [ "$IS_TTY" -eq 0 ]; then # Non-interactive output (e.g. redirected to a file): skip animation, # just wait quietly. wait "$pid" 2>/dev/null return fi frames="$SPINNER_FRAMES" # shellcheck disable=SC2086 set -- $frames frame_count=$# i=1 while kill -0 "$pid" 2>/dev/null; do eval "frame=\${$i}" clear_line printf '%s %s%s%s' "$label" "$C_CYAN" "$frame" "$C_RESET" i=$((i % frame_count + 1)) sleep 0.1 done wait "$pid" 2>/dev/null } # ----------------------------------------------------------------------------- # 6. HTTP helpers # ----------------------------------------------------------------------------- curl_base() { curl -s --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" "$@" } # Extracts a bare JSON string value: json_field '"key"' '' json_field() { key="$1" json="$2" printf '%s' "$json" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n1 } # ----------------------------------------------------------------------------- # 7. Detection steps (location / IP / ISP / nearest edge) # ----------------------------------------------------------------------------- INFO_JSON="" PUBLIC_IP="" LOCATION_STR="" ISP_STR="" COLO_STR="" detect_info() { step_start "${GLYPH_GLOBE} Detecting location..." (curl_base "$API_BASE/info" > "$WORKDIR/info.json" 2>/dev/null; echo $? > "$WORKDIR/info.rc") & pid=$! run_with_spinner "${GLYPH_GLOBE} Detecting location..." "$WORKDIR/info.rc" "$pid" rc="$(cat "$WORKDIR/info.rc" 2>/dev/null)" INFO_JSON="$(cat "$WORKDIR/info.json" 2>/dev/null)" if [ "$rc" != "0" ] || [ -z "$INFO_JSON" ]; then step_fail "Detecting location... could not reach server" return 1 fi city="$(json_field "city" "$INFO_JSON")" country="$(json_field "country" "$INFO_JSON")" ISP_STR="$(json_field "asOrganization" "$INFO_JSON")" COLO_STR="$(json_field "colo" "$INFO_JSON")" PUBLIC_IP="$(json_field "ip" "$INFO_JSON")" if [ -n "$city" ] && [ -n "$country" ]; then LOCATION_STR="$city, $country" elif [ -n "$country" ]; then LOCATION_STR="$country" else LOCATION_STR="Unknown" fi step_done "Detecting location... ${C_GREEN}${LOCATION_STR}${C_RESET}" step_start "${GLYPH_NET} Detecting public IP..." clear_line if [ -n "$PUBLIC_IP" ] && [ "$PUBLIC_IP" != "unknown" ]; then step_done "Detecting public IP... ${C_GREEN}${PUBLIC_IP}${C_RESET}" else step_fail "Detecting public IP... unavailable" fi step_start "${GLYPH_BUILDING} Detecting ISP..." clear_line if [ -n "$ISP_STR" ]; then step_done "Detecting ISP... ${C_GREEN}${ISP_STR}${C_RESET}" else step_fail "Detecting ISP... unavailable" fi step_start "${GLYPH_SAT} Finding nearest edge..." clear_line if [ -n "$COLO_STR" ]; then step_done "Finding nearest edge... ${C_GREEN}${COLO_STR}${C_RESET}" else step_fail "Finding nearest edge... unavailable" fi echo draw_rule echo return 0 } # ----------------------------------------------------------------------------- # 8. Ping / jitter / loss test # ----------------------------------------------------------------------------- PING_MIN="" PING_AVG="" PING_MAX="" PING_JITTER="" PING_LOSS_PCT=0 measure_ping() { echo "${GLYPH_PING} Measuring latency..." echo -n " " : > "$WORKDIR/ping_times.txt" fail_count=0 frames="$SPINNER_FRAMES" set -- $frames frame_count=$# fi=1 n=1 while [ "$n" -le "$PING_COUNT" ]; do if [ "$IS_TTY" -eq 1 ]; then eval "frame=\${$fi}" clear_line printf ' %s%s%s Measuring latency... (%d/%d)' "$C_CYAN" "$frame" "$C_RESET" "$n" "$PING_COUNT" fi=$((fi % frame_count + 1)) fi t="$(curl_base -o /dev/null -w '%{time_total}' "$API_BASE/ping" 2>/dev/null)" rc=$? if [ "$rc" -eq 0 ] && [ -n "$t" ]; then # Convert seconds (e.g. 0.034512) to whole milliseconds. ms="$(awk -v t="$t" 'BEGIN{printf "%.3f", t*1000}')" echo "$ms" >> "$WORKDIR/ping_times.txt" else fail_count=$((fail_count+1)) fi n=$((n+1)) done clear_line total_attempts=$PING_COUNT ok_count=$((total_attempts - fail_count)) if [ "$ok_count" -eq 0 ]; then step_fail "Ping unreachable" PING_LOSS_PCT=100 return 1 fi # min / max / avg / jitter, computed with a single awk pass. read PING_MIN PING_AVG PING_MAX PING_JITTER <max) max=$1 } END { avg = sum / NR jsum = 0 for (i=2; i<=NR; i++) { d = v[i]-v[i-1] if (d<0) d=-d jsum += d } jitter = (NR>1) ? jsum/(NR-1) : 0 printf "%.0f %.0f %.0f %.0f", min, avg, max, jitter } ' "$WORKDIR/ping_times.txt") EOF_STATS PING_LOSS_PCT="$(awk -v f="$fail_count" -v t="$total_attempts" 'BEGIN{printf "%.0f", (f/t)*100}')" step_done "Ping ${C_GREEN}${PING_AVG} ms${C_RESET}" step_done "Jitter ${C_GREEN}${PING_JITTER} ms${C_RESET}" if [ "$PING_LOSS_PCT" != "0" ]; then printf '%s %s\n' "${C_YELLOW}!${C_RESET}" "Loss ${C_YELLOW}${PING_LOSS_PCT}%${C_RESET}" fi echo draw_rule echo return 0 } # ----------------------------------------------------------------------------- # 9. Shared progress-bar renderer # ----------------------------------------------------------------------------- # draw_progress "label" done_bytes total_bytes speed_mbps draw_progress() { label="$1"; done_b="$2"; total_b="$3"; mbps="$4" width="$(term_width)" bar_width=$((width - 30)) [ "$bar_width" -lt 10 ] && bar_width=10 pct=0 if [ "$total_b" -gt 0 ] 2>/dev/null; then pct="$(awk -v d="$done_b" -v t="$total_b" 'BEGIN{p=(d/t)*100; if(p>100)p=100; printf "%.0f", p}')" fi filled=$(( bar_width * pct / 100 )) empty=$(( bar_width - filled )) bar="" i=0; while [ "$i" -lt "$filled" ]; do bar="${bar}${GLYPH_BAR_FULL}"; i=$((i+1)); done i=0; while [ "$i" -lt "$empty" ]; do bar="${bar}${GLYPH_BAR_EMPTY}"; i=$((i+1)); done color="$(speed_color "$mbps")" clear_line printf '%s %s%s%s %3s%% %s%7s Mbps%s' \ "$label" "$C_CYAN" "$bar" "$C_RESET" "$pct" "$color" "$mbps" "$C_RESET" } # speed_color mbps -> ANSI color code for the given throughput. speed_color() { mbps="$1" awk -v m="$mbps" -v excellent="$C_BRIGHT_GREEN" -v good="$C_GREEN" \ -v avg="$C_YELLOW" -v poor="$C_ORANGE" -v bad="$C_RED" \ 'BEGIN{ if (m+0 >= 500) print excellent; else if (m+0 >= 100) print good; else if (m+0 >= 25) print avg; else if (m+0 >= 5) print poor; else print bad; }' } # Sums the byte-count contents of all files matching a glob (used to poll # in-flight transfer progress). Missing files count as 0. sum_progress_files() { pattern="$1" total=0 for f in $pattern; do [ -f "$f" ] || continue sz="$(wc -c < "$f" 2>/dev/null | tr -d ' ')" [ -n "$sz" ] && total=$((total + sz)) done echo "$total" } # ----------------------------------------------------------------------------- # 10. Download test # ----------------------------------------------------------------------------- DOWNLOAD_MBPS="" run_download_test() { echo "${GLYPH_DOWN} Download test" # --- Phase 1: quick probe to size the real test ----------------------- probe_total=$((PROBE_SIZE_BYTES * DOWNLOAD_STREAMS)) s=1 probe_pids="" while [ "$s" -le "$DOWNLOAD_STREAMS" ]; do ( curl_base -o "$WORKDIR/probe_$s.bin" "$API_BASE/download?size=$PROBE_SIZE_BYTES" >/dev/null 2>&1 ) & probe_pids="$probe_pids $!" s=$((s+1)) done BG_PIDS="$probe_pids" probe_start_iters=0 while :; do still=0 for p in $probe_pids; do kill -0 "$p" 2>/dev/null && still=1; done [ "$still" -eq 0 ] && break probe_start_iters=$((probe_start_iters+1)) sleep "$POLL_INTERVAL" done wait $probe_pids 2>/dev/null BG_PIDS="" probe_elapsed="$(awk -v n="$probe_start_iters" -v i="$POLL_INTERVAL" 'BEGIN{e=n*i; if(e<=0)e=0.2; print e}')" probe_bytes="$(sum_progress_files "$WORKDIR/probe_*.bin")" probe_mbps="$(awk -v b="$probe_bytes" -v t="$probe_elapsed" 'BEGIN{printf "%.1f", (b*8)/t/1000000}')" rm -f "$WORKDIR"/probe_*.bin # --- Adaptive sizing based on the probe -------------------------------- size_per_stream="$(awk -v m="$probe_mbps" 'BEGIN{ if (m+0 < 5) print 2*1024*1024; else if (m+0 < 25) print 8*1024*1024; else if (m+0 < 100) print 25*1024*1024; else if (m+0 < 300) print 50*1024*1024; else print 100*1024*1024; }')" total_bytes=$((size_per_stream * DOWNLOAD_STREAMS)) # --- Phase 2: real, measured download test ----------------------------- s=1 dl_pids="" while [ "$s" -le "$DOWNLOAD_STREAMS" ]; do ( curl_base -o "$WORKDIR/dl_$s.bin" "$API_BASE/download?size=$size_per_stream" >/dev/null 2>&1 ) & dl_pids="$dl_pids $!" s=$((s+1)) done BG_PIDS="$dl_pids" iters=0 prev_bytes=0 hide_cursor while :; do still=0 for p in $dl_pids; do kill -0 "$p" 2>/dev/null && still=1; done cur_bytes="$(sum_progress_files "$WORKDIR/dl_*.bin")" inst_mbps="$(awk -v c="$cur_bytes" -v p="$prev_bytes" -v i="$POLL_INTERVAL" \ 'BEGIN{printf "%.1f", ((c-p)*8)/i/1000000}')" draw_progress " " "$cur_bytes" "$total_bytes" "$inst_mbps" prev_bytes="$cur_bytes" [ "$still" -eq 0 ] && break iters=$((iters+1)) sleep "$POLL_INTERVAL" done wait $dl_pids 2>/dev/null BG_PIDS="" show_cursor elapsed="$(awk -v n="$iters" -v i="$POLL_INTERVAL" 'BEGIN{e=n*i; if(e<=0)e=0.2; print e}')" final_bytes="$(sum_progress_files "$WORKDIR/dl_*.bin")" DOWNLOAD_MBPS="$(awk -v b="$final_bytes" -v t="$elapsed" 'BEGIN{printf "%.0f", (b*8)/t/1000000}')" rm -f "$WORKDIR"/dl_*.bin clear_line color="$(speed_color "$DOWNLOAD_MBPS")" printf ' %s%s Mbps%s%*s\n' "$color" "$DOWNLOAD_MBPS" "$C_RESET" 0 "" echo } # ----------------------------------------------------------------------------- # 11. Upload test # ----------------------------------------------------------------------------- UPLOAD_MBPS="" # Uploads one stream's entire payload in a single POST over a single # connection, and asks curl itself to report exactly how many bytes went # out and how long it took. Earlier versions of this test re-sent the # payload in many small sequential chunks, each one a brand-new curl # process — i.e. a brand-new TCP+TLS handshake. On any connection with # non-trivial latency that handshake overhead dominates the transfer, # which is what produced the "fast, then stalled for seconds" pattern. # One connection per stream removes that entirely. upload_stream_worker() { stream_id="$1" src_file="$2" result_file="$WORKDIR/ul_result_${stream_id}.txt" progress_log="$WORKDIR/ul_progress_${stream_id}.log" # Deliberately omit -s here (unlike curl_base) so curl prints its # built-in transfer meter. That meter writes to curl's own stderr # stream, which C's stdio never fully buffers even when redirected to # a regular file — so polling this log file gives genuine, near-live # byte counts instead of only the value curl reports at the very end. curl --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" \ -X POST --data-binary "@$src_file" \ -H "Content-Type: application/octet-stream" \ -o /dev/null \ -w '%{size_upload} %{time_total}\n' \ "$API_BASE/upload" > "$result_file" 2> "$progress_log" } # Reads a stream's progress log and returns the most recent "bytes # transferred so far" figure curl reported. curl's meter overwrites its # own line with \r, so a raw `tail` would see one giant line; converting # CRs to newlines first lets us grab the last real snapshot. upload_progress_bytes() { log="$1" [ -f "$log" ] || { echo 0; return; } last_line="$(tr '\r' '\n' < "$log" 2>/dev/null | sed -n '/[0-9]/p' | tail -n1)" [ -z "$last_line" ] && { echo 0; return; } raw="$(printf '%s' "$last_line" | awk '{print $6}')" [ -z "$raw" ] && { echo 0; return; } awk -v s="$raw" 'BEGIN{ n = s+0; if (s ~ /[kK]$/) n = (s+0)*1024; else if (s ~ /[mM]$/) n = (s+0)*1024*1024; else if (s ~ /[gG]$/) n = (s+0)*1024*1024*1024; printf "%d", n; }' } run_upload_test() { echo "${GLYPH_UP} Upload test" # Reuse the download result (if available) to size the upload payload; # otherwise fall back to a conservative default. base_mbps="${DOWNLOAD_MBPS:-25}" stream_bytes="$(awk -v m="$base_mbps" 'BEGIN{ if (m+0 < 5) print 1*1024*1024; else if (m+0 < 25) print 4*1024*1024; else if (m+0 < 100) print 12*1024*1024; else print 24*1024*1024; }')" total_bytes=$((stream_bytes * UPLOAD_STREAMS)) # One source file per stream, filled from /dev/urandom (falls back to # /dev/zero on minimal systems that lack it). Built once, up front, so # the timed portion of the test is pure network transfer. rand_src="/dev/urandom" [ -r "$rand_src" ] || rand_src="/dev/zero" s=1 while [ "$s" -le "$UPLOAD_STREAMS" ]; do dd if="$rand_src" of="$WORKDIR/ulsrc_$s.bin" bs="$stream_bytes" count=1 >/dev/null 2>&1 rm -f "$WORKDIR/ul_result_$s.txt" "$WORKDIR/ul_progress_$s.log" s=$((s+1)) done s=1 ul_pids="" while [ "$s" -le "$UPLOAD_STREAMS" ]; do upload_stream_worker "$s" "$WORKDIR/ulsrc_$s.bin" & ul_pids="$ul_pids $!" s=$((s+1)) done BG_PIDS="$ul_pids" iters=0 prev_bytes=0 hide_cursor while :; do still=0 for p in $ul_pids; do kill -0 "$p" 2>/dev/null && still=1; done cur_bytes=0 s=1 while [ "$s" -le "$UPLOAD_STREAMS" ]; do b="$(upload_progress_bytes "$WORKDIR/ul_progress_$s.log")" cur_bytes=$((cur_bytes + b)) s=$((s+1)) done inst_mbps="$(awk -v c="$cur_bytes" -v p="$prev_bytes" -v i="$POLL_INTERVAL" \ 'BEGIN{v=((c-p)*8)/i/1000000; if(v<0)v=0; printf "%.1f", v}')" draw_progress " " "$cur_bytes" "$total_bytes" "$inst_mbps" prev_bytes="$cur_bytes" [ "$still" -eq 0 ] && break iters=$((iters+1)) sleep "$POLL_INTERVAL" done wait $ul_pids 2>/dev/null BG_PIDS="" show_cursor # Final figure comes from curl's own -w instrumentation (exact bytes # sent, exact time), not from the live progress-meter parsing above — # the live numbers are for the animation, this is the number that gets # reported. total_sent=0 max_time="0.2" for f in "$WORKDIR"/ul_result_*.txt; do [ -f "$f" ] || continue line="$(cat "$f" 2>/dev/null)" b="$(printf '%s' "$line" | awk '{print $1}')" t="$(printf '%s' "$line" | awk '{print $2}')" [ -n "$b" ] && total_sent=$((total_sent + b)) if [ -n "$t" ]; then max_time="$(awk -v a="$t" -v b="$max_time" 'BEGIN{print (a>b)?a:b}')" fi done rm -f "$WORKDIR"/ul_result_*.txt "$WORKDIR"/ul_progress_*.log "$WORKDIR"/ulsrc_*.bin UPLOAD_MBPS="$(awk -v b="$total_sent" -v t="$max_time" 'BEGIN{printf "%.0f", (b*8)/t/1000000}')" clear_line color="$(speed_color "$UPLOAD_MBPS")" printf ' %s%s Mbps%s%*s\n' "$color" "$UPLOAD_MBPS" "$C_RESET" 0 "" echo } # ----------------------------------------------------------------------------- # 12. Summary # ----------------------------------------------------------------------------- print_summary() { draw_rule echo dl_color="$(speed_color "${DOWNLOAD_MBPS:-0}")" ul_color="$(speed_color "${UPLOAD_MBPS:-0}")" printf ' %-14s %s%s Mbps%s\n' "Download" "$dl_color" "${DOWNLOAD_MBPS:-N/A}" "$C_RESET" printf ' %-14s %s%s Mbps%s\n' "Upload" "$ul_color" "${UPLOAD_MBPS:-N/A}" "$C_RESET" printf ' %-14s %s ms\n' "Ping" "${PING_AVG:-N/A}" printf ' %-14s %s ms\n' "Jitter" "${PING_JITTER:-N/A}" printf ' %-14s %s\n' "Cloudflare" "${COLO_STR:-N/A}" printf ' %-14s %s\n' "Public IP" "${PUBLIC_IP:-N/A}" printf ' %-14s %s\n' "ISP" "${ISP_STR:-N/A}" echo draw_rule } # ----------------------------------------------------------------------------- # 13. Preflight checks # ----------------------------------------------------------------------------- preflight() { if ! command -v curl >/dev/null 2>&1; then echo "Error: curl is required but was not found on this system." >&2 exit 1 fi if ! command -v awk >/dev/null 2>&1; then echo "Error: awk is required but was not found on this system." >&2 exit 1 fi if ! command -v mktemp >/dev/null 2>&1; then echo "Error: mktemp is required but was not found on this system." >&2 exit 1 fi } # ----------------------------------------------------------------------------- # 14. Main # ----------------------------------------------------------------------------- main() { preflight clear_screen print_header detect_info || echo " (continuing with limited info)" measure_ping || true run_download_test run_upload_test print_summary } main "$@"