diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index 8d31ff266f..eb1f0d9853 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -26,6 +26,7 @@ on: paths: - '**/Dockerfile*' - '.dockerignore' + - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/util.sh' jobs: docker-build: @@ -47,6 +48,28 @@ jobs: run: | IMAGE_ID=$(docker build -q -f ${{ matrix.dockerfile }} .) echo "Built: $IMAGE_ID" + echo "IMAGE_ID=$IMAGE_ID" >> "$GITHUB_ENV" HC=$(docker inspect --format='{{json .Config.Healthcheck}}' "$IMAGE_ID") echo "Healthcheck: $HC" [[ "$HC" != "null" ]] || { echo "ERROR: HEALTHCHECK missing in ${{ matrix.dockerfile }}"; exit 1; } + + # The startup preflight needs a socket-table tool, and the base image + # ships none of its own. Without one every start reports "unknown" and + # a duplicate start is no longer refused, so assert the image can + # actually answer. Only the server images run check_port. + # TODO(docker-ci): this pins the probe dependency, not the behaviour it + # protects. A full duplicate-start/stop check needs a booted server with + # a backend, which belongs with the e2e job rather than the image build. + - name: Port preflight can answer inside ${{ matrix.dockerfile }} + if: ${{ startsWith(matrix.dockerfile, 'hugegraph-server/') }} + run: | + STATE=$(docker run --rm "$IMAGE_ID" bash -c \ + 'source /hugegraph-server/bin/util.sh && port_listen_state 8080') + echo "port_listen_state 8080 -> $STATE" + # Assert a positive answer rather than "not unknown": an empty $STATE + # would otherwise satisfy the check. The step's default `bash -e` + # already aborts on a failed `docker run`, so this only states intent. + [[ "$STATE" == "free" || "$STATE" == "busy" ]] || { + echo "ERROR: no usable socket-table tool (ss/netstat) in ${{ matrix.dockerfile }}" + exit 1 + } diff --git a/.github/workflows/pd-store-ci.yml b/.github/workflows/pd-store-ci.yml index 62006794c7..6f670e1cb9 100644 --- a/.github/workflows/pd-store-ci.yml +++ b/.github/workflows/pd-store-ci.yml @@ -110,7 +110,9 @@ jobs: - name: Check startup test prerequisites (PD) id: pd-preflight run: | - for tool in lsof curl java; do + # These jobs run on Linux, where the suites clean up ports with fuser. + # lsof is no longer required: check_port now uses ss/netstat. + for tool in fuser curl java; do if ! command -v "$tool" >/dev/null 2>&1; then echo "can_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT" @@ -190,7 +192,9 @@ jobs: - name: Check startup test prerequisites (Store) id: store-preflight run: | - for tool in lsof curl java; do + # These jobs run on Linux, where the suites clean up ports with fuser. + # lsof is no longer required: check_port now uses ss/netstat. + for tool in fuser curl java; do if ! command -v "$tool" >/dev/null 2>&1; then echo "can_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 72f28b6692..7b9802174a 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -79,17 +79,33 @@ jobs: run: | mvn clean compile -U -Dmaven.javadoc.skip=true -ntp + - name: Run check_port unit tests + if: ${{ env.BACKEND == 'rocksdb' }} + run: | + # Validates the ss/netstat port preflight that replaced lsof + $TRAVIS_DIR/test-check-port.sh \ + hugegraph-server/hugegraph-dist/src/assembly/static \ + hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh + - name: Check startup test prerequisites id: server-preflight if: ${{ env.BACKEND == 'rocksdb' }} run: | - for tool in lsof crontab curl java; do + # Note: lsof removed from prerequisites; check_port now uses ss, falling back + # to netstat, and warns without blocking when neither can answer. + # This job always runs on Linux (ubuntu-22.04), which uses fuser for port cleanup. + for tool in crontab curl java; do if ! command -v "$tool" >/dev/null 2>&1; then echo "can_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT" exit 0 fi done + if ! command -v fuser >/dev/null 2>&1; then + echo "can_run=false" >> "$GITHUB_OUTPUT" + echo "skip_reason=missing tool: fuser (required for Linux port cleanup)" >> "$GITHUB_OUTPUT" + exit 0 + fi echo "can_run=true" >> "$GITHUB_OUTPUT" - name: Run start-hugegraph.sh foreground mode tests @@ -187,6 +203,13 @@ jobs: run: | mvn clean compile -pl hugegraph-server/hugegraph-test -am -U -Dmaven.javadoc.skip=true -ntp + - name: Run check_port unit tests + # Validates the ss/netstat port preflight that replaced lsof + run: | + $TRAVIS_DIR/test-check-port.sh \ + hugegraph-server/hugegraph-dist/src/assembly/static \ + hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh + - name: Run RocksDB core test run: | $TRAVIS_DIR/run-core-test.sh $BACKEND diff --git a/hugegraph-pd/Dockerfile b/hugegraph-pd/Dockerfile index d55ac180ae..68e6e2555b 100644 --- a/hugegraph-pd/Dockerfile +++ b/hugegraph-pd/Dockerfile @@ -45,12 +45,14 @@ ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport -XX:Max WORKDIR /hugegraph-pd/ # 1. Install runtime dependencies +# Note: lsof is gone because its only consumer was the dead check_port removed +# from hg-pd-dist bin/util.sh. PD runs no port preflight, so unlike the server +# images this needs no socket-table tool in its place. RUN apt-get -q update \ && apt-get -q install -y --no-install-recommends --no-install-suggests \ dumb-init \ procps \ curl \ - lsof \ vim \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/util.sh b/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/util.sh index 0b7ad0f0f0..b476d7935b 100644 --- a/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/util.sh +++ b/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/util.sh @@ -20,20 +20,21 @@ # TODO: consider reuse it with server-dist module (almost same as it) function command_available() { local cmd=$1 - if [ $(command -v $cmd >/dev/null 2>&1) ]; then - return 1 - else + if command -v "$cmd" >/dev/null 2>&1; then return 0 fi + return 1 } # read a property from .properties file function read_property() { # file path + local file_name + local property_name file_name=$1 # replace "." to "\." - property_name=$(echo $2 | sed 's/\./\\\./g') - cat $file_name | sed -n -e "s/^[ ]*//g;/^#/d;s/^$property_name=//p" | tail -1 + property_name=$(echo "$2" | sed 's/\./\\\./g') + cat "$file_name" | sed -n -e "s/^[ ]*//g;/^#/d;s/^$property_name=//p" | tail -1 } function write_property() { @@ -56,7 +57,7 @@ function parse_yaml() { local version=$2 local module=$3 - cat $file | tr -d '\n {}'| awk -F',+|:' '''{ + cat "$file" | tr -d '\n {}'| awk -F',+|:' '''{ pre=""; for(i=1; i<=NF; ) { if(match($i, /version/)) { @@ -72,27 +73,13 @@ function parse_yaml() { } function process_num() { - num=`ps -ef | grep $1 | grep -v grep | wc -l` - return $num + num=$(ps -ef | grep "$1" | grep -v grep | wc -l) + return "$num" } function process_id() { - pid=`ps -ef | grep $1 | grep -v grep | awk '{print $2}'` - return $pid -} - -# check the port of rest server is occupied -function check_port() { - local port=$(echo "$1" | sed 's|.*:||' | sed 's|/.*||') - if ! command_available "lsof"; then - echo "Required lsof but it is unavailable" - exit 1 - fi - lsof -i :$port >/dev/null - if [ $? -eq 0 ]; then - echo "The port $port has already been used" - exit 1 - fi + pid=$(ps -ef | grep "$1" | grep -v grep | awk '{print $2}') + return "$pid" } function crontab_append() { @@ -130,13 +117,14 @@ function wait_for_startup() { local server_url="$3" local timeout_s="$4" - local now_s=`date '+%s'` - local stop_s=$(( $now_s + $timeout_s )) + local now_s + now_s=$(date '+%s') + local stop_s=$(( now_s + timeout_s )) local status echo -n "Connecting to $server_name ($server_url)" - while [ $now_s -le $stop_s ]; do + while [ "$now_s" -le "$stop_s" ]; do echo -n . process_status "$server_name" "$pid" >/dev/null if [ $? -eq 1 ]; then @@ -144,14 +132,14 @@ function wait_for_startup() { return 1 fi - status=`curl -o /dev/null -s -k -w %{http_code} $server_url` - if [[ $status -eq 200 || $status -eq 401 ]]; then + status=$(curl -o /dev/null -s -k -w '%{http_code}' "$server_url") + if [[ "$status" -eq 200 || "$status" -eq 401 ]]; then echo "OK" echo "Started [pid $pid]" return 0 fi sleep 2 - now_s=`date '+%s'` + now_s=$(date '+%s') done echo "The operation timed out when attempting to connect to $server_url" >&2 @@ -160,22 +148,28 @@ function wait_for_startup() { function free_memory() { local free="" - local os=`uname` + local os=$(uname) if [ "$os" == "Linux" ]; then - local mem_free=`cat /proc/meminfo | grep -w "MemFree" | awk '{print $2}'` - local mem_buffer=`cat /proc/meminfo | grep -w "Buffers" | awk '{print $2}'` - local mem_cached=`cat /proc/meminfo | grep -w "Cached" | awk '{print $2}'` + local mem_free=$(cat /proc/meminfo | grep -w "MemFree" | awk '{print $2}') + local mem_buffer=$(cat /proc/meminfo | grep -w "Buffers" | awk '{print $2}') + local mem_cached=$(cat /proc/meminfo | grep -w "Cached" | awk '{print $2}') if [[ "$mem_free" == "" || "$mem_buffer" == "" || "$mem_cached" == "" ]]; then echo "Failed to get free memory" exit 1 fi - free=`expr $mem_free + $mem_buffer + $mem_cached` - free=`expr $free / 1024` + free=$(expr "$mem_free" + "$mem_buffer" + "$mem_cached") + free=$(expr "$free" / 1024) elif [ "$os" == "Darwin" ]; then - local pages_free=`vm_stat | awk '/Pages free/{print $0}' | awk -F'[:.]+' '{print $2}' | tr -d " "` - local pages_inactive=`vm_stat | awk '/Pages inactive/{print $0}' | awk -F'[:.]+' '{print $2}' | tr -d " "` - local pages_available=`expr $pages_free + $pages_inactive` - free=`expr $pages_available \* 4096 / 1024 / 1024` + local pages_free pages_inactive + pages_free=$(vm_stat | awk '/Pages free/{print $0}' | awk -F'[:.]+' '{print $2}' | tr -d " ") + pages_inactive=$(vm_stat | awk '/Pages inactive/{print $0}' | awk -F'[:.]+' '{print $2}' | tr -d " ") + if [[ -z "$pages_free" || -z "$pages_inactive" ]]; then + echo "Failed to get free memory" + exit 1 + fi + local pages_available + pages_available=$(expr "$pages_free" + "$pages_inactive") + free=$(expr "$pages_available" \* 4096 / 1024 / 1024) else echo "Unsupported operating system $os" exit 1 @@ -187,8 +181,8 @@ function calc_xmx() { local min_mem=$1 local max_mem=$2 # Get machine available memory - local free=`free_memory` - local half_free=$[free/2] + local free=$(free_memory) + local half_free=$((free/2)) local xmx=$min_mem if [[ "$free" -lt "$min_mem" ]]; then @@ -236,47 +230,78 @@ function ensure_path_writable() { } function get_ip() { - local os=`uname` + local os=$(uname) local loopback="127.0.0.1" local ip="" case $os in Linux) if command_available "ifconfig"; then - ip=`ifconfig | grep 'inet addr:' | grep -v "$loopback" | cut -d: -f2 | awk '{ print $1}'` + ip=$(ifconfig | grep 'inet addr:' | grep -v "$loopback" | cut -d: -f2 | awk '{ print $1}') elif command_available "ip"; then - ip=`ip addr | grep 'state UP' -A2 | tail -n1 | awk '{print $2}' | awk -F"/" '{print $1}'` + ip=$(ip addr | grep 'state UP' -A2 | tail -n1 | awk '{print $2}' | awk -F"/" '{print $1}') else ip=$loopback fi ;; FreeBSD|OpenBSD|Darwin) if command_available "ifconfig"; then - ip=`ifconfig | grep -E 'inet.[0-9]' | grep -v "$loopback" | awk '{ print $2}'` + ip=$(ifconfig | grep -E 'inet.[0-9]' | grep -v "$loopback" | awk '{ print $2}') else ip=$loopback fi ;; SunOS) if command_available "ifconfig"; then - ip=`ifconfig -a | grep inet | grep -v "$loopback" | awk '{ print $2} '` + ip=$(ifconfig -a | grep inet | grep -v "$loopback" | awk '{ print $2} ') else ip=$loopback fi ;; *) ip=$loopback;; esac - echo $ip + [[ -z "$ip" ]] && ip=$loopback + echo "$ip" } function download() { local path=$1 local link_url=$2 - if command_available "wget"; then - wget --help | grep -q '\--show-progress' && progress_opt="-q --show-progress" || progress_opt="" - wget ${link_url} -P ${path} $progress_opt - elif command_available "curl"; then - curl ${link_url} -o ${path}/${link_url} + if [ ! -d "$path" ]; then + mkdir -p "$path" || { + echo "Failed to create directory: $path" + exit 1 + } + fi + + local filename tmp + filename=$(basename "${link_url%%[?#]*}") + local dest="${path}/${filename}" + # mktemp, not $$: a PID is shared by concurrent background subshells. + tmp=$(mktemp -- "${path}/.${filename}.XXXXXX") || { + echo "Failed to create a temporary file in $path" + return 1 + } + + if command_available "curl"; then + # -o must appear before -- so it is parsed as an option, not an extra URL. + if curl -fL -o "$tmp" -- "${link_url}"; then + mv -f -- "$tmp" "$dest" || { rm -f -- "$tmp"; return 1; } + else + rm -f -- "$tmp" + return 1 + fi + elif command_available "wget"; then + local -a progress_opt=() + if wget --help 2>&1 | grep -q -- '--show-progress'; then + progress_opt=(-q --show-progress) + fi + if wget ${progress_opt[@]+"${progress_opt[@]}"} -O "$tmp" -- "${link_url}"; then + mv -f -- "$tmp" "$dest" || { rm -f -- "$tmp"; return 1; } + else + rm -f -- "$tmp" + return 1 + fi else echo "Required wget or curl but they are unavailable" exit 1 @@ -289,19 +314,17 @@ function ensure_package_exist() { local tar=$3 local link=$4 - if [ ! -d ${path}/${dir} ]; then - if [ ! -f ${path}/${tar} ]; then + if [ ! -d "${path}/${dir}" ]; then + if [ ! -f "${path}/${tar}" ]; then echo "Downloading the compressed package '${tar}'" - download ${path} ${link} - if [ $? -ne 0 ]; then + if ! download "${path}" "${link}"; then echo "Failed to download, please ensure the network is available and link is valid" exit 1 fi echo "[OK] Finished download" fi echo "Unzip the compressed package '$tar'" - tar -zxvf ${path}/${tar} -C ${path} >/dev/null 2>&1 - if [ $? -ne 0 ]; then + if ! tar -zxvf "${path}/${tar}" -C "${path}" >/dev/null 2>&1; then echo "Failed to unzip, please check the compressed package" exit 1 fi @@ -316,7 +339,7 @@ function wait_for_shutdown() { local pid="$2" local timeout_s="$3" - local now_s=`date '+%s'` + local now_s=$(date '+%s') local stop_s=$(( $now_s + $timeout_s )) echo -n "Killing $process_name(pid $pid)" >&2 @@ -328,7 +351,7 @@ function wait_for_shutdown() { return 0 fi sleep 2 - now_s=`date '+%s'` + now_s=$(date '+%s') done echo "$process_name shutdown timeout(exceeded $timeout_s seconds)" >&2 return 1 @@ -357,7 +380,7 @@ function kill_process() { return 0 fi - case "`uname`" in + case "$(uname)" in CYGWIN*) taskkill /F /PID "$pid" ;; *) kill "$pid" ;; esac diff --git a/hugegraph-server/Dockerfile b/hugegraph-server/Dockerfile index 2ca946e6ba..5caadd23cb 100644 --- a/hugegraph-server/Dockerfile +++ b/hugegraph-server/Dockerfile @@ -46,12 +46,16 @@ ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport -XX:Max WORKDIR /hugegraph-server/ # 1. Install runtime dependencies and configure server +# Note: iproute2 provides `ss`, which the bin/util.sh port preflight needs. The +# jammy base image ships neither ss nor netstat, so without it the preflight is +# permanently inconclusive and a duplicate start is no longer caught. It +# replaces lsof, which no shipped script calls any more. RUN apt-get -q update \ && apt-get -q install -y --no-install-recommends --no-install-suggests \ dumb-init \ procps \ curl \ - lsof \ + iproute2 \ vim \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ diff --git a/hugegraph-server/Dockerfile-hstore b/hugegraph-server/Dockerfile-hstore index 06d2d4b88c..7cd64e8f3b 100644 --- a/hugegraph-server/Dockerfile-hstore +++ b/hugegraph-server/Dockerfile-hstore @@ -48,12 +48,16 @@ ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport -XX:Max WORKDIR /hugegraph-server/ # 1. Install runtime dependencies and configure server +# Note: iproute2 provides `ss`, which the bin/util.sh port preflight needs. The +# jammy base image ships neither ss nor netstat, so without it the preflight is +# permanently inconclusive and a duplicate start is no longer caught. It +# replaces lsof, which no shipped script calls any more. RUN apt-get -q update \ && apt-get -q install -y --no-install-recommends --no-install-suggests \ dumb-init \ procps \ curl \ - lsof \ + iproute2 \ vim \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/util.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/util.sh index 91c0c3efbf..8ae46e88a4 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/util.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/util.sh @@ -18,11 +18,10 @@ function command_available() { local cmd=$1 - if [[ -x "$(command -v "$cmd")" ]]; then + if command -v "$cmd" >/dev/null 2>&1; then return 0 - else - return 1 fi + return 1 } function configure_riscv64_libatomic() { @@ -67,6 +66,8 @@ function configure_riscv64_libatomic() { # read a property from .properties file function read_property() { # file path + local file_name + local property_name file_name=$1 # replace "." to "\." property_name=$(echo "$2" | sed 's/\./\\\./g') @@ -118,18 +119,197 @@ function process_id() { return "$pid" } -# check the port of rest server is occupied -function check_port() { - local port=$(echo "$1" | sed 's|.*:||' | sed 's|/.*||') - if ! command_available "lsof"; then - echo "Required lsof but it is unavailable" - exit 1 +# Extract a validated TCP port from a configured server URL. +# Echoes the port on success. Returns 1 when the value carries no usable port +# or is ambiguous, in which case the caller skips the preflight. +function parse_port_from_url() { + local url="$1" + + # ServerOptions tolerates surrounding whitespace, so strip it first. + url="${url#"${url%%[![:space:]]*}"}" + url="${url%"${url##*[![:space:]]}"}" + [[ -z "$url" ]] && return 1 + + # The scheme is optional and case-insensitive. + local scheme="" rest="$url" + if [[ "$url" == *"://"* ]]; then + scheme=$(echo "${url%%://*}" | tr '[:upper:]' '[:lower:]') + rest="${url#*://}" fi - lsof -i :"$port" >/dev/null - if [ $? -eq 0 ]; then - echo "The port $port has already been used" - exit 1 + + # The authority ends at the first '/', '?' or '#'. + local authority="${rest%%[/?#]*}" + [[ -z "$authority" ]] && return 1 + + # Drop any userinfo prefix; its colon would otherwise look like an + # unbracketed IPv6 separator. + authority="${authority##*@}" + [[ -z "$authority" ]] && return 1 + + local port="" + if [[ "$authority" =~ ^\[[^]]*\](:([0-9]+))?$ ]]; then + # Bracketed IPv6, with or without a port: [::1] or [::1]:8080 + port="${BASH_REMATCH[2]}" + elif [[ "$authority" == *:*:* ]]; then + # Unbracketed IPv6 is ambiguous: in "::1:8080" the trailing group may + # be a port or another hextet. Refuse to guess. + # TODO(check_port): no preflight runs at all for this form. If + # ServerOptions ever guarantees a normalized bracketed value here, this + # branch can resolve the port instead of skipping the check. + echo "WARN: ambiguous IPv6 authority '$authority' in server URL;" \ + "use bracket notation such as [::1]:8080." >&2 + return 1 + elif [[ "$authority" == *:* ]]; then + port="${authority##*:}" + fi + + # Fall back to the scheme's default port. + # TODO(check_port): a scheme-less value with no explicit port (e.g. plain + # "127.0.0.1") has no derivable port, so it is skipped rather than guessed. + # Reading the configured default from ServerOptions would close this gap. + if [[ -z "$port" ]]; then + case "$scheme" in + http) port="80" ;; + https) port="443" ;; + *) return 1 ;; + esac fi + + [[ "$port" =~ ^[0-9]+$ ]] || return 1 + # Normalise leading-zero forms textually; Java reads 08080 as decimal 8080. + # Arithmetic conversion must not happen before the value is bounded: Bash + # evaluates in 64-bit and wraps silently, so 18446744073709559616 would + # otherwise pass the range check as port 8000. + port="${port#"${port%%[!0]*}"}" + [[ -z "$port" ]] && return 1 + (( ${#port} <= 5 )) || return 1 + (( port >= 1 && port <= 65535 )) || return 1 + + echo "$port" +} + +# Echo "busy", "free" or "unknown" for the given TCP port. +# +# The port is compared as text against the listener table, so the argument must +# already be normalised - parse_port_from_url() strips leading zeroes for this +# reason, and "08080" passed directly here would not match a listener on 8080. +# +# Detection is deliberately port-only, matching the conservative behaviour of +# the `lsof -i :PORT` call this replaces. Reproducing kernel socket semantics +# in Bash - dual-stack IPV6_V6ONLY, wildcard versus specific binds, address +# canonicalisation - produced more wrong answers than it prevented, so we only +# ask "is anything already listening on this port?". +# +# Only LISTEN rows and only the local-address column are inspected, so an +# unrelated outbound connection to the same port number is never mistaken for +# a local listener. A tool that is missing, fails, or yields no recognisable +# listener row reports "unknown" rather than "free", and the next probe is +# tried; the sole exception is `ss -H` succeeding with no output at all, which +# says positively that the host has no TCP listeners. +# +# TODO(check_port): port-only matching ignores the listener's address, so a +# listener bound to one local address (127.0.0.1:8080) reports the port busy +# even when the server would bind a different one (192.168.1.5:8080). This is +# deliberate - it is what `lsof -i :PORT` did, and it fails safe - but it can +# refuse a bind that would have succeeded. If that is reported in practice, +# revisit by comparing the local-address column instead of only its port. +# +# TODO(check_port): only the `ss` branch can tell "no listeners at all" from +# "table unreadable", because -H removes the header and leaves a zero exit with +# empty output as a positive signal. Both netstat branches print headers that +# the parser drops, so an empty result there stays "unknown" and warns. +function port_listen_state() { + local port="$1" + local out + local state + local os + os=$(uname) + + # $4 is the local address for both `ss -ltn` and BSD `netstat -an`. + # Splitting on the last separator keeps IPv6 hextets (for example + # [2001:db8::80]:443) from being misread as the port. + local parser=' + NF >= 4 && (!want_listen || $NF == "LISTEN") { + addr = $4 + cut = 0 + for (k = length(addr); k > 0; k--) { + if (substr(addr, k, 1) == sep) { cut = k; break } + } + if (cut == 0) next + rows++ + if (substr(addr, cut + 1) == port) { found = 1; exit } + } + END { + if (found) print "busy" + else if (rows > 0) print "free" + else print "unknown" + }' + + # Each probe answers only when it produced a usable listener table; an + # inconclusive one falls through to the next rather than ending the search. + if [[ "$os" == "Darwin" || "$os" == *BSD* ]]; then + if command_available "netstat" && out=$(netstat -an -p tcp 2>/dev/null) \ + && [[ -n "$out" ]]; then + state=$(echo "$out" | awk -v port="$port" -v sep="." -v want_listen=1 "$parser") + case "$state" in + busy|free) echo "$state"; return 0 ;; + esac + fi + else + # `ss -H -ltn` already restricts output to listening sockets, and -H + # drops the header, so a zero exit with no output means "nothing is + # listening" - the one case where empty is an answer, not a failure. + if command_available "ss" && out=$(ss -H -ltn 2>/dev/null); then + if [[ -z "$out" ]]; then + echo "free" + return 0 + fi + state=$(echo "$out" | awk -v port="$port" -v sep=":" -v want_listen=0 "$parser") + case "$state" in + busy|free) echo "$state"; return 0 ;; + esac + fi + if command_available "netstat" && out=$(netstat -ltn 2>/dev/null) \ + && [[ -n "$out" ]]; then + state=$(echo "$out" | awk -v port="$port" -v sep=":" -v want_listen=1 "$parser") + case "$state" in + busy|free) echo "$state"; return 0 ;; + esac + fi + fi + + # TODO(check_port): with neither ss nor netstat present there is no probe + # left, so the preflight is permanently "unknown" and never detects a busy + # port. The published server images carry iproute2 for this reason, but a + # hand-built minimal image can still ship neither. A dependency-free + # fallback would need a bounded connect, which was deliberately removed + # here; adding one back means re-solving the hang this PR fixes. + echo "unknown" +} + +# Best-effort startup preflight. Exits 1 when the configured port is already +# in use. The server's own bind stays authoritative, so an inconclusive +# result only warns and lets startup proceed. +function check_port() { + local url="$1" + local port + local state + + port=$(parse_port_from_url "$url") || return 0 + + state=$(port_listen_state "$port") + case "$state" in + busy) + echo "The port $port has already been used" + exit 1 + ;; + unknown) + echo "WARN: could not determine whether port $port is free;" \ + "continuing and letting the server bind decide." >&2 + ;; + esac + + return 0 } function crontab_append() { @@ -167,14 +347,15 @@ function wait_for_startup() { local server_url="$3" local timeout_s="$4" - local now_s=$(date '+%s') + local now_s + now_s=$(date '+%s') local stop_s=$((now_s + timeout_s)) local status local error_file_name="startup_error.txt" echo -n "Connecting to $server_name ($server_url)" - while [ "$now_s" -le $stop_s ]; do + while [ "$now_s" -lt "$stop_s" ]; do echo -n . process_status "$server_name" "$pid" >/dev/null if [ $? -eq 1 ]; then @@ -185,8 +366,18 @@ function wait_for_startup() { return 1 fi - status=$(curl -I -sS -k -w "%{http_code}" -o /dev/null "$server_url" 2> "$error_file_name") - if [[ $status -eq 200 || $status -eq 401 ]]; then + # Bound each probe by the time left in the overall deadline: without + # --max-time a single blackholed request blocks past ${timeout_s}s. + # Refresh the clock after the process check and immediately before the + # probe. A stale loop-entry time must not grant curl a new one-second + # budget when the overall deadline has already been reached. + now_s=$(date '+%s') + local remain_s=$((stop_s - now_s)) + [ "$remain_s" -le 0 ] && break + local connect_s=$((remain_s < 5 ? remain_s : 5)) + status=$(curl -I -sS -k --connect-timeout "$connect_s" --max-time "$remain_s" \ + -w "%{http_code}" -o /dev/null "$server_url" 2> "$error_file_name") + if [[ "$status" -eq 200 || "$status" -eq 401 ]]; then echo "OK" echo "Started [pid $pid]" if [ -e "$error_file_name" ]; then @@ -194,13 +385,24 @@ function wait_for_startup() { fi return 0 fi - sleep 2 + + # Pause for the retry interval, but never past the deadline: sleeping a + # fixed 2s and only then re-reading the clock made a 1s timeout take 2s. + # Nothing left to wait for means the deadline is spent, so stop here + # rather than spin until the clock ticks past it. + now_s=$(date '+%s') + local sleep_s=$((stop_s - now_s)) + [ "$sleep_s" -le 0 ] && break + [ "$sleep_s" -gt 2 ] && sleep_s=2 + sleep "$sleep_s" now_s=$(date '+%s') done echo "" - cat "$error_file_name" - rm "$error_file_name" + if [ -e "$error_file_name" ]; then + cat "$error_file_name" + rm "$error_file_name" + fi echo "The operation timed out(${timeout_s}s) when attempting to connect to $server_url" >&2 return 1 } @@ -318,17 +520,43 @@ function get_ip() { function download() { local path=$1 local download_url=$2 + + if [ ! -d "$path" ]; then + mkdir -p "$path" || { + echo "Failed to create directory: $path" + exit 1 + } + fi + + # Strip query/fragment so the on-disk name matches the server-side artifact. + local filename tmp + filename=$(basename "${download_url%%[?#]*}") + local dest="${path}/${filename}" + # mktemp, not $$: a PID is shared by concurrent background subshells. + tmp=$(mktemp -- "${path}/.${filename}.XXXXXX") || { + echo "Failed to create a temporary file in $path" + return 1 + } + if command_available "curl"; then - if [ ! -d "$path" ]; then - mkdir -p "$path" || { - echo "Failed to create directory: $path" - exit 1 - } + # -o must appear before -- so it is parsed as an option, not an extra URL. + if curl -fL -o "$tmp" -- "${download_url}"; then + mv -f -- "$tmp" "$dest" || { rm -f -- "$tmp"; return 1; } + else + rm -f -- "$tmp" + return 1 fi - curl -L "${download_url}" -o "${path}/$(basename "${download_url}")" elif command_available "wget"; then - wget --help | grep -q '\--show-progress' && progress_opt="-q --show-progress" || progress_opt="" - wget "${download_url}" -P "${path}" $progress_opt + local -a progress_opt=() + if wget --help 2>&1 | grep -q -- '--show-progress'; then + progress_opt=(-q --show-progress) + fi + if wget ${progress_opt[@]+"${progress_opt[@]}"} -O "$tmp" -- "${download_url}"; then + mv -f -- "$tmp" "$dest" || { rm -f -- "$tmp"; return 1; } + else + rm -f -- "$tmp" + return 1 + fi else echo "Required curl or wget but they are unavailable" exit 1 @@ -341,19 +569,17 @@ function ensure_package_exist() { local tar=$3 local link=$4 - if [ ! -d "${path}"/"${dir}" ]; then - if [ ! -f "${path}"/"${tar}" ]; then + if [ ! -d "${path}/${dir}" ]; then + if [ ! -f "${path}/${tar}" ]; then echo "Downloading the compressed package '${tar}'" - download "${path}" "${link}" - if [ $? -ne 0 ]; then + if ! download "${path}" "${link}"; then echo "Failed to download, please ensure the network is available and link is valid" exit 1 fi echo "[OK] Finished download" fi echo "Unzip the compressed package '$tar'" - tar zxvf "${path}"/"${tar}" -C "${path}" >/dev/null 2>&1 - if [ $? -ne 0 ]; then + if ! tar -zxvf "${path}/${tar}" -C "${path}" >/dev/null 2>&1; then echo "Failed to unzip, please check the compressed package" exit 1 fi @@ -363,6 +589,10 @@ function ensure_package_exist() { ########################################################################### +# TODO(wait_for_shutdown): this loop has the same fixed-2s-then-check-the-clock +# shape wait_for_startup had, so it can also overrun its timeout by roughly one +# sleep interval. Left alone here to keep this PR to the port preflight; the +# fix is the same remaining-deadline cap used above. function wait_for_shutdown() { local process_name="$1" local pid="$2" diff --git a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-check-port.sh b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-check-port.sh new file mode 100755 index 0000000000..e8950355c7 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-check-port.sh @@ -0,0 +1,677 @@ +#!/bin/bash +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Contract tests for the startup port preflight in bin/util.sh. +# +# The preflight is best effort: the server's own bind is authoritative. These +# tests pin the three-state contract (busy / free / unknown) rather than the +# internals of any one probe. +# +# TODO(test-check-port): the Linux and BSD detection branches are both driven +# by mocked tool output, so on any one runner only the host's own branch is +# ever exercised against a real kernel. The single real-listener case covers +# whichever OS the job runs on. Closing this needs the suite to run on both +# a Linux and a macOS runner, which CI already does for the server job. + +set -u + +STATIC_DIR="${1:-hugegraph-server/hugegraph-dist/src/assembly/static}" +UTIL_SH="$STATIC_DIR/bin/util.sh" +STORE_UTIL_SH="${2:-}" + +if [[ ! -f "$UTIL_SH" ]]; then + if [[ $# -gt 0 ]]; then + echo "ERROR: required util.sh not found at $UTIL_SH" >&2 + exit 1 + fi + # With no argument this script may be discovered outside the repository by + # an optional test runner. CI always supplies STATIC_DIR explicitly, so a + # bad workflow path takes the required failure branch above. + echo "SKIP: default util.sh not found at $UTIL_SH" + exit 0 +fi + +if [[ -n "$STORE_UTIL_SH" && ! -f "$STORE_UTIL_SH" ]]; then + echo "ERROR: required Store util.sh not found at $STORE_UTIL_SH" >&2 + exit 1 +fi + +# shellcheck source=/dev/null +source "$UTIL_SH" + +# Sections run in subshells so their command overrides stay isolated, which +# means results have to be tallied through a file rather than a variable. +RESULTS=$(mktemp) +# Clean the tally on any exit, not only the normal one. Section 5 runs outside +# a subshell and has to extend this trap, so it restores this handler rather +# than clearing it. +trap 'rm -f "$RESULTS"' EXIT + +pass() { + echo " PASS $1" + echo "P" >> "$RESULTS" +} + +fail() { + echo " FAIL $1" + echo " $2" + echo "F" >> "$RESULTS" +} + +expect() { + # expect + if [[ "$2" == "$3" ]]; then + pass "$1" + else + fail "$1" "expected '$2', got '$3'" + fi +} + +echo "" +echo "check_port contract tests ($UTIL_SH)" + +# -------------------------------------------------------------------------- +echo "" +echo "1. URL to port" +# "|", where SKIP means "no usable port, preflight is skipped". +url_cases=( + 'http://127.0.0.1:8080|8080' + 'HTTP://127.0.0.1:8080|8080' + 'http://127.0.0.1|80' + 'https://127.0.0.1|443' + '127.0.0.1:8080|8080' + 'http://127.0.0.1:8080/path:9090|8080' + 'http://127.0.0.1:8080?probe=x|8080' + 'http://127.0.0.1:8080#frag|8080' + 'http://user:pass@127.0.0.1:8080|8080' + 'http://user@127.0.0.1:8080|8080' + 'http://[::1]:8080|8080' + 'http://user:pass@[::1]:8080|8080' + '[::1]:8080|8080' + 'https://[::1]|443' + 'http://127.0.0.1:08080|8080' + '::1:8080|SKIP' + 'http://127.0.0.1:abc|SKIP' + 'http://127.0.0.1:0|SKIP' + 'http://127.0.0.1:70000|SKIP' + '127.0.0.1|SKIP' + # Oversized values must be rejected on their digits, not after a 64-bit + # arithmetic conversion that would wrap them back into range. + 'http://127.0.0.1:18446744073709551617|SKIP' + 'http://127.0.0.1:18446744073709559616|SKIP' + 'http://127.0.0.1:0000000000000008080|8080' + 'http://127.0.0.1:00000|SKIP' +) +for case in "${url_cases[@]}"; do + url="${case%|*}" + want="${case##*|}" + got=$(parse_port_from_url " $url " 2>/dev/null) || got="SKIP" + expect "$url" "$want" "$got" +done + +# -------------------------------------------------------------------------- +echo "" +echo "2. Linux: ss busy / free / failure, and the netstat fallback" +( + uname() { echo "Linux"; } + command_available() { [[ "$1" == "ss" ]]; } + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + SS_ARGS_FILE="$WORK/ss-args" + SS_OUT="" + SS_RC=0 + SS_FILTER_LISTEN=0 + ss() { + echo "$*" > "$SS_ARGS_FILE" + [[ $# -eq 2 && "$1" == "-H" && "$2" == "-ltn" ]] || return 64 + if [[ "$SS_FILTER_LISTEN" -eq 1 ]]; then + echo "$SS_OUT" | awk '$1 == "LISTEN"' + else + printf '%s' "$SS_OUT" + fi + return "$SS_RC" + } + + SS_OUT='LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:*' + expect "listener on 8080 is busy" "busy" "$(port_listen_state 8080)" + expect "ss receives the exact listener-table arguments" \ + "-H -ltn" "$(cat "$SS_ARGS_FILE")" + + # Model the filtering done by `ss -l`: an established connection may use + # the target as its local ephemeral port, but it is not a listener. The + # mock refuses changed argv, so dropping -l makes this unknown, not free. + SS_FILTER_LISTEN=1 + SS_OUT='ESTAB 0 0 127.0.0.1:8080 127.0.0.1:443' + expect "Linux non-LISTEN local port is free" "free" "$(port_listen_state 8080)" + SS_FILTER_LISTEN=0 + + SS_OUT='LISTEN 0 4096 0.0.0.0:22 0.0.0.0:*' + expect "no listener on 8080 is free" "free" "$(port_listen_state 8080)" + + # The port must come from the local-address field, not an IPv6 hextet. + SS_OUT='LISTEN 0 128 [2001:db8:8080::1]:9090 [::]:*' + expect "IPv6 hextet 8080 is not the port" "free" "$(port_listen_state 8080)" + + SS_OUT='LISTEN 0 128 [::]:8080 [::]:*' + expect "IPv6 wildcard listener is busy" "busy" "$(port_listen_state 8080)" + + # A tool that runs but fails proves nothing. + SS_OUT='' + SS_RC=1 + expect "ss failure is unknown" "unknown" "$(port_listen_state 8080)" + + # `-H` prints no header, so a zero exit with no output is a positive "this + # host has no TCP listeners" - the state a fresh container starts in. + SS_RC=0 + SS_OUT='' + expect "ss empty output is free" "free" "$(port_listen_state 8080)" + + # Success with an unusable table proves nothing. + SS_OUT='some diagnostic banner' + expect "ss unparseable output is unknown" "unknown" "$(port_listen_state 8080)" + + # ...and it must not end the search either: netstat may still have a + # readable table. Returning on the first probe left the port "unknown" + # even when the fallback could have answered. + command_available() { [[ "$1" == "ss" || "$1" == "netstat" ]]; } + netstat() { + echo 'Active Internet connections (only servers)' + echo 'Proto Recv-Q Send-Q Local Address Foreign Address State' + echo 'tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN' + } + + SS_OUT='some diagnostic banner' + expect "unparseable ss falls back to netstat" "busy" "$(port_listen_state 8080)" + expect "netstat fallback can also report free" "free" "$(port_listen_state 9999)" + + SS_RC=1 + SS_OUT='' + expect "failed ss falls back to netstat" "busy" "$(port_listen_state 8080)" + +) + +# -------------------------------------------------------------------------- +echo "" +echo "3. macOS/BSD: LISTEN versus ESTABLISHED" +( + uname() { echo "Darwin"; } + command_available() { [[ "$1" == "netstat" ]]; } + NS_OUT="" + netstat() { printf '%s' "$NS_OUT"; } + + NS_OUT='Active Internet connections (including servers) +Proto Recv-Q Send-Q Local Address Foreign Address (state) +tcp4 0 0 *.8080 *.* LISTEN' + expect "BSD LISTEN on 8080 is busy" "busy" "$(port_listen_state 8080)" + + # An outbound connection to :443 is not a local listener on 443. This is + # the false positive that blocked startup on macOS. + NS_OUT='Active Internet connections (including servers) +Proto Recv-Q Send-Q Local Address Foreign Address (state) +tcp4 0 0 192.168.1.3.60320 44.195.16.138.443 ESTABLISHED +tcp4 0 0 *.22 *.* LISTEN' + expect "BSD ESTABLISHED peer :443 is free" "free" "$(port_listen_state 443)" + + # A socket lingering in TIME_WAIT holds the port in its *local* address but + # is not a listener, so only the connection state can tell them apart. + NS_OUT='Proto Recv-Q Send-Q Local Address Foreign Address (state) +tcp4 0 0 127.0.0.1.8080 127.0.0.1.51000 TIME_WAIT +tcp4 0 0 *.22 *.* LISTEN' + expect "BSD TIME_WAIT on local 8080 is free" "free" "$(port_listen_state 8080)" + + NS_OUT='Proto Recv-Q Send-Q Local Address Foreign Address (state) +tcp6 0 0 ::1.8080 *.* LISTEN' + expect "BSD IPv6 LISTEN is busy" "busy" "$(port_listen_state 8080)" + + NS_OUT='Proto Recv-Q Send-Q Local Address Foreign Address (state)' + expect "BSD header only is unknown" "unknown" "$(port_listen_state 8080)" + +) + +# -------------------------------------------------------------------------- +echo "" +echo "4. Unknown never blocks startup" +( + uname() { echo "Linux"; } + command_available() { return 1; } + + expect "no probe tool is unknown" "unknown" "$(port_listen_state 8080)" + + # check_port must warn and let the server perform the authoritative bind. + err=$( (check_port "http://127.0.0.1:8080") 2>&1 >/dev/null ) + rc=$? + expect "unknown exits 0" "0" "$rc" + if [[ "$err" == *"could not determine"* ]]; then + pass "unknown warns on stderr" + else + fail "unknown warns on stderr" "stderr was: $err" + fi + +) + +# -------------------------------------------------------------------------- +echo "" +echo "5. Real listener on an ephemeral port" +if command -v python3 >/dev/null 2>&1; then + PORT_FILE=$(mktemp) + # Cover the temp file from the moment it exists, then widen the trap to the + # child as soon as there is a pid; registering only after the fork leaves a + # window where an interrupt orphans the listener for its full 30s sleep. + trap 'rm -f "$PORT_FILE" "$RESULTS"' EXIT + python3 -c ' +import socket, sys, time +s = socket.socket() +s.bind(("127.0.0.1", 0)) +s.listen(1) +print(s.getsockname()[1]) +sys.stdout.flush() +time.sleep(30) +' > "$PORT_FILE" & + PY_PID=$! + trap 'kill "$PY_PID" 2>/dev/null; rm -f "$PORT_FILE" "$RESULTS"' EXIT + + BOUND="" + for _ in 1 2 3 4 5 6 7 8 9 10; do + BOUND=$(head -1 "$PORT_FILE" 2>/dev/null) + [[ -n "$BOUND" ]] && break + # Whole seconds only: fractional sleep is not POSIX, and this suite runs + # with `set -u` but no `-e`, so a busybox sleep would fail silently and + # spin all ten iterations instantly. + sleep 1 + done + + if [[ -z "$BOUND" ]] || ! kill -0 "$PY_PID" 2>/dev/null; then + fail "listener bound an ephemeral port" "child did not report a port" + else + pass "listener bound an ephemeral port ($BOUND)" + expect "bound port is busy" "busy" "$(port_listen_state "$BOUND")" + (check_port "http://127.0.0.1:$BOUND" >/dev/null 2>&1) + expect "check_port exits 1 on the bound port" "1" "$?" + + # A port that was bound and released must not read as busy. + FREE=$(python3 -c 'import socket +s = socket.socket() +s.bind(("127.0.0.1", 0)) +p = s.getsockname()[1] +s.close() +print(p)') + expect "released port is not busy" "0" "$([[ "$(port_listen_state "$FREE")" == "busy" ]] && echo 1 || echo 0)" + fi + + kill "$PY_PID" 2>/dev/null + wait "$PY_PID" 2>/dev/null + rm -f "$PORT_FILE" + # Restore the script-level handler rather than clearing it, so the tally + # file stays covered for the remaining sections. + trap 'rm -f "$RESULTS"' EXIT +else + echo " SKIP real listener test: python3 not available" +fi + +# -------------------------------------------------------------------------- +echo "" +echo "6. download() rename failure is reported" +( + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + + command_available() { [[ "$1" == "curl" ]]; } + curl() { + # Write to the -o destination so a temp file exists to rename. + local out="" + while [[ $# -gt 0 ]]; do + [[ "$1" == "-o" ]] && { out="$2"; shift; } + shift + done + echo "payload" > "$out" + return 0 + } + # Simulate a rename that fails (read-only target, cross-device, ...). + mv() { return 1; } + + if download "$WORK" "https://example.com/pkg.tar.gz"; then + fail "download reports rename failure" "download returned 0 after mv failed" + else + pass "download reports rename failure" + fi + + if [[ -e "$WORK/pkg.tar.gz" ]]; then + fail "failed rename leaves no destination" "$WORK/pkg.tar.gz exists" + else + pass "failed rename leaves no destination" + fi + + leftover=$(find "$WORK" -name '.pkg.tar.gz.*' 2>/dev/null | wc -l | tr -d ' ') + expect "failed rename cleans its temp file" "0" "$leftover" + +) + +# -------------------------------------------------------------------------- +echo "" +echo "7. Startup probe is bounded" +( + WORK=$(mktemp -d) + cd "$WORK" || exit 1 + trap 'cd /; rm -rf "$WORK"' EXIT + + CLOCK_FILE="$WORK/clock" + ARGS_FILE="$WORK/curl-args" + SLEEP_FILE="$WORK/sleep-args" + echo 100 > "$CLOCK_FILE" + : > "$ARGS_FILE" + : > "$SLEEP_FILE" + curl() { echo "$*" >> "$ARGS_FILE"; echo "000"; return 28; } + # Keep the contract checks deterministic across whole-second boundaries. + # The retry pause consumes the remaining simulated second without making + # this half of the test depend on scheduler timing. + date() { cat "$CLOCK_FILE"; } + sleep() { + echo "$1" >> "$SLEEP_FILE" + echo 101 > "$CLOCK_FILE" + } + process_status() { return 0; } + ps() { return 0; } + + TIMEOUT_S=1 + if wait_for_startup "$$" "test-server" "http://127.0.0.1:1" "$TIMEOUT_S" \ + >/dev/null 2>&1; then + WAIT_STATUS=0 + else + WAIT_STATUS=$? + fi + + expect "failed startup probe returns failure" "1" "$WAIT_STATUS" + + # The deterministic half: bounding each curl does not bound the loop, which + # used to sleep a flat 2s and only then re-read the clock. A 1s timeout + # must never ask for more than 1s of sleeping. + TOTAL_SLEEP_S=$(awk '{ total += $1 } END { print total + 0 }' "$SLEEP_FILE") + if [[ "$TOTAL_SLEEP_S" -le "$TIMEOUT_S" ]]; then + pass "retry sleeps stay inside the ${TIMEOUT_S}s deadline" + else + fail "retry sleeps stay inside the ${TIMEOUT_S}s deadline" \ + "slept ${TOTAL_SLEEP_S}s in total: $(tr '\n' ' ' < "$SLEEP_FILE")" + fi + + # With a one-second overall deadline, every positive remaining budget is + # exactly one second. Parse each curl call so option presence, values, and + # the deadline bound are all part of the contract rather than string-grep + # smoke checks. + CURL_CALLS=0 + TIMEOUT_VALUES_VALID=1 + TIMEOUT_ERROR="" + while IFS= read -r curl_args; do + [[ -z "$curl_args" ]] && continue + CURL_CALLS=$((CURL_CALLS + 1)) + CONNECT_TIMEOUT="" + MAX_TIME="" + set -- $curl_args + while [[ $# -gt 0 ]]; do + case "$1" in + --connect-timeout) + shift + [[ $# -gt 0 ]] && CONNECT_TIMEOUT="$1" + ;; + --max-time) + shift + [[ $# -gt 0 ]] && MAX_TIME="$1" + ;; + esac + [[ $# -gt 0 ]] && shift + done + + if [[ ! "$CONNECT_TIMEOUT" =~ ^[0-9]+$ || + ! "$MAX_TIME" =~ ^[0-9]+$ || + "$CONNECT_TIMEOUT" -le 0 || "$CONNECT_TIMEOUT" -gt "$TIMEOUT_S" || + "$MAX_TIME" -le 0 || "$MAX_TIME" -gt "$TIMEOUT_S" ]]; then + TIMEOUT_VALUES_VALID=0 + TIMEOUT_ERROR="invalid timeouts connect='$CONNECT_TIMEOUT' max='$MAX_TIME' in: $curl_args" + break + fi + done < "$ARGS_FILE" + + if [[ "$CURL_CALLS" -gt 0 && "$TIMEOUT_VALUES_VALID" -eq 1 ]]; then + pass "startup probe timeouts are positive and within the remaining deadline" + else + [[ -n "$TIMEOUT_ERROR" ]] || TIMEOUT_ERROR="no curl calls recorded" + fail "startup probe timeouts are positive and within the remaining deadline" \ + "$TIMEOUT_ERROR" + fi + + # A probe may consume all of its assigned budget. Refreshing the clock + # after curl must then suppress the retry pause rather than sleep beyond + # the overall deadline. + EXHAUST_SLEEP_FILE="$WORK/exhausted-sleep-calls" + echo 100 > "$CLOCK_FILE" + : > "$EXHAUST_SLEEP_FILE" + curl() { + echo 101 > "$CLOCK_FILE" + echo "000" + return 28 + } + sleep() { echo "$1" >> "$EXHAUST_SLEEP_FILE"; } + if wait_for_startup "$$" "budget-server" "http://127.0.0.1:1" \ + "$TIMEOUT_S" >/dev/null 2>&1; then + EXHAUST_STATUS=0 + else + EXHAUST_STATUS=$? + fi + + expect "budget-exhausting probe returns failure" "1" "$EXHAUST_STATUS" + EXHAUST_SLEEP_CALLS=$(wc -l < "$EXHAUST_SLEEP_FILE" | tr -d ' ') + expect "budget-exhausting probe starts no retry sleep" \ + "0" "$EXHAUST_SLEEP_CALLS" + + # Keep a small real-clock smoke test for the end-to-end deadline. Crossing + # an integer-second boundary between the initial and pre-probe clock reads + # may legitimately produce zero curl calls, so this half checks only the + # return status and wall-clock bound and does not require an args file. + date() { command date "$@"; } + curl() { echo "000"; return 28; } + sleep() { command sleep "$1"; } + START_S=$(date '+%s') + if wait_for_startup "$$" "wall-clock-server" "http://127.0.0.1:1" \ + "$TIMEOUT_S" >/dev/null 2>&1; then + WALL_STATUS=0 + else + WALL_STATUS=$? + fi + ELAPSED_S=$(( $(date '+%s') - START_S )) + + expect "real-clock failed startup probe returns failure" "1" "$WALL_STATUS" + # One second of slack accounts for the granularity of date '+%s'. + if [[ "$ELAPSED_S" -le $((TIMEOUT_S + 1)) ]]; then + pass "wait_for_startup returns within the deadline (${ELAPSED_S}s)" + else + fail "wait_for_startup returns within the deadline" \ + "took ${ELAPSED_S}s for a ${TIMEOUT_S}s timeout" + fi + +) + +# -------------------------------------------------------------------------- +echo "" +echo "8. Deadline boundary starts no probe" +( + WORK=$(mktemp -d) + cd "$WORK" || exit 1 + trap 'cd /; rm -rf "$WORK"' EXIT + + CLOCK_FILE="$WORK/clock-calls" + CURL_FILE="$WORK/curl-calls" + echo 0 > "$CLOCK_FILE" + : > "$CURL_FILE" + + # The first read establishes [100, 101) as the allowed interval. The + # process check consumes that interval, so the mandatory pre-curl refresh + # observes the deadline exactly. File-backed state works through the + # command substitutions used by wait_for_startup, including Bash 3.2. + date() { + local calls + calls=$(cat "$CLOCK_FILE") + calls=$((calls + 1)) + echo "$calls" > "$CLOCK_FILE" + if [[ "$calls" -eq 1 ]]; then + echo 100 + else + echo 101 + fi + } + process_status() { return 0; } + curl() { echo called >> "$CURL_FILE"; echo "000"; return 28; } + sleep() { echo called >> "$WORK/sleep-calls"; } + + wait_for_startup "$$" "boundary-server" "http://127.0.0.1:1" 1 \ + >/dev/null 2>&1 + + CLOCK_CALLS=$(cat "$CLOCK_FILE") + if [[ "$CLOCK_CALLS" -ge 2 ]]; then + pass "startup refreshes the clock at the probe boundary" + else + fail "startup refreshes the clock at the probe boundary" \ + "clock was read only ${CLOCK_CALLS} time(s)" + fi + + PROBE_CALLS=$(wc -l < "$CURL_FILE" | tr -d ' ') + expect "no startup probe begins at the deadline" "0" "$PROBE_CALLS" + +) + +if [[ -n "$STORE_UTIL_SH" ]]; then + # ---------------------------------------------------------------------- + echo "" + echo "9. Store verified replacement survives a stale concurrent caller" + ( + # Keep the Store helper overrides isolated from the server helpers used + # by every other section in this suite. + # shellcheck source=/dev/null + source "$STORE_UTIL_SH" + + WORK=$(mktemp -d) + DEST="$WORK/lib.so" + HASHED="$WORK/stale-hashed" + RELEASE="$WORK/release-stale" + STALE_DOWNLOAD="$WORK/stale-download" + STALE_PID="" + trap ' + touch "$RELEASE" + if [[ -n "$STALE_PID" ]]; then + kill "$STALE_PID" 2>/dev/null + wait "$STALE_PID" 2>/dev/null + fi + rm -rf "$WORK" + ' EXIT + + echo "invalid" > "$DEST" + + wait_for_file() { + local path="$1" + local _ + for _ in 1 2 3 4 5 6 7 8 9 10; do + [[ -e "$path" ]] && return 0 + command sleep 1 + done + return 1 + } + + # Both callers first observe the invalid destination. The stale caller + # then holds that checksum until the installer has atomically published + # valid bytes. Reintroducing the old pre-download rm makes the stale + # caller delete that valid replacement before its own download fails. + md5sum() { + local target="${@: -1}" + if [[ "$target" == "$DEST" ]]; then + if [[ "$CALLER" == "stale" ]]; then + touch "$HASHED" + wait_for_file "$RELEASE" || return 1 + fi + echo "bad $target" + else + echo "good $target" + fi + } + + curl() { + [[ "$1" == "-fL" && "$2" == "-o" && "$4" == "--" ]] || return 64 + if [[ "$CALLER" == "installer" ]]; then + echo "valid" > "$3" + return 0 + fi + touch "$STALE_DOWNLOAD" + return 22 + } + + ( + CALLER="stale" + download_and_verify "https://example.com/lib.so" "$DEST" "good" \ + >/dev/null 2>&1 + ) & + STALE_PID=$! + + if ! wait_for_file "$HASHED"; then + fail "stale caller captured the original checksum" \ + "caller did not reach the checksum barrier" + exit 0 + fi + pass "stale caller captured the original checksum" + + CALLER="installer" + if download_and_verify "https://example.com/lib.so" "$DEST" "good" \ + >/dev/null 2>&1; then + pass "concurrent installer succeeds" + else + fail "concurrent installer succeeds" "installer returned nonzero" + fi + + touch "$RELEASE" + wait "$STALE_PID" 2>/dev/null + STALE_WAIT_RC=$? + STALE_PID="" + if [[ "$STALE_WAIT_RC" -ne 0 && -e "$STALE_DOWNLOAD" ]]; then + pass "stale caller reports its failed replacement download" + else + CURL_REACHED=$([[ -e "$STALE_DOWNLOAD" ]] && echo yes || echo no) + fail "stale caller reports its failed replacement download" \ + "rc=$STALE_WAIT_RC, curl reached=$CURL_REACHED" + fi + + if [[ -f "$DEST" && "$(cat "$DEST")" == "valid" ]]; then + pass "failed stale caller preserves the valid replacement" + else + fail "failed stale caller preserves the valid replacement" \ + "destination is missing or no longer contains valid bytes" + fi + + LEFTOVERS=$(find "$WORK" -name 'lib.so.*' -type f | wc -l | tr -d ' ') + expect "concurrent replacement cleans private temp files" "0" "$LEFTOVERS" + ) +fi + +# -------------------------------------------------------------------------- +echo "" +echo "----------------------------------------" +# grep -c prints 0 and exits 1 when there are no matches; the count is what +# matters here, so the exit status is deliberately ignored. +PASSED=$(grep -c '^P' "$RESULTS" 2>/dev/null) +FAILED=$(grep -c '^F' "$RESULTS" 2>/dev/null) +rm -f "$RESULTS" +echo "passed: $PASSED failed: $FAILED" +if [[ "$FAILED" -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh index 260e7f5711..ab73255b8c 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh @@ -30,6 +30,7 @@ set -uo pipefail PD_ROOT="${1:-$(pwd)}" BIN="$PD_ROOT/bin" START_SCRIPT="$BIN/start-hugegraph-pd.sh" +STOP_SCRIPT="$BIN/stop-hugegraph-pd.sh" PID_FILE="$BIN/pid" PD_URL="http://localhost:8620" STARTUP_WAIT=60 # seconds to wait for PD HTTP to respond @@ -67,14 +68,30 @@ section() { cleanup() { info "Cleaning up..." + "$STOP_SCRIPT" >/dev/null 2>&1 || true if [[ -s "$PID_FILE" ]]; then kill "$(cat "$PID_FILE")" 2>/dev/null || true fi rm -f "$PID_FILE" rm -rf "$PD_ROOT/logs/" - # kill anything still holding the PD port - lsof -ti :8620 | xargs kill -9 2>/dev/null || true - lsof -ti :8686 | xargs kill -9 2>/dev/null || true + # kill anything still holding the PD port: fuser on Linux, lsof on macOS, + # which ships no fuser. Only the Linux path had to lose its lsof dependency. + if [[ "$(uname)" == "Darwin" ]]; then + local port pids pid + for port in 8620 8686; do + pids=$(lsof -ti ":$port" 2>/dev/null) + if [[ -n "$pids" ]]; then + for pid in $pids; do + kill -9 "$pid" 2>/dev/null || true + done + fi + done + elif command -v fuser >/dev/null 2>&1; then + fuser -k 8620/tcp 2>/dev/null || true + fuser -k 8686/tcp 2>/dev/null || true + else + echo "Warning: Neither lsof nor fuser available for PD port cleanup" + fi sleep 3 } @@ -129,7 +146,12 @@ if [[ ! -f "$START_SCRIPT" ]]; then exit 1 fi -for tool in lsof curl java; do +if [[ "$(uname)" == "Darwin" ]]; then + _prereq_tools="lsof curl java" +else + _prereq_tools="fuser curl java" +fi +for tool in $_prereq_tools; do if ! command -v "$tool" >/dev/null 2>&1; then echo "SKIP: required tool '$tool' not found — skipping test suite" exit 77 diff --git a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-store.sh b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-store.sh index 7e11da28dd..9ff324ae29 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-store.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-store.sh @@ -51,15 +51,30 @@ section() { echo ""; echo "── $1 ──"; } cleanup() { info "Cleaning up..." + "$STOP_SCRIPT" >/dev/null 2>&1 || true if [[ -s "$PID_FILE" ]]; then kill "$(cat "$PID_FILE")" 2>/dev/null || true fi rm -f "$PID_FILE" rm -rf "$STORE_ROOT/logs/" # kill anything holding Store ports (8520 REST, 8510 raft, 8500 gRPC) - lsof -ti :8520 | xargs kill -9 2>/dev/null || true - lsof -ti :8510 | xargs kill -9 2>/dev/null || true - lsof -ti :8500 | xargs kill -9 2>/dev/null || true + if [[ "$(uname)" == "Darwin" ]]; then + local port pids pid + for port in 8520 8510 8500; do + pids=$(lsof -ti ":$port" 2>/dev/null) + if [[ -n "$pids" ]]; then + for pid in $pids; do + kill -9 "$pid" 2>/dev/null || true + done + fi + done + elif command -v fuser >/dev/null 2>&1; then + fuser -k 8520/tcp 2>/dev/null || true + fuser -k 8510/tcp 2>/dev/null || true + fuser -k 8500/tcp 2>/dev/null || true + else + echo "Warning: Neither lsof nor fuser available for Store port cleanup" + fi sleep 3 } @@ -110,7 +125,12 @@ if [[ ! -f "$START_SCRIPT" ]]; then exit 1 fi -for tool in lsof curl java; do +if [[ "$(uname)" == "Darwin" ]]; then + _prereq_tools="lsof curl java" +else + _prereq_tools="fuser curl java" +fi +for tool in $_prereq_tools; do if ! command -v "$tool" >/dev/null 2>&1; then echo "SKIP: required tool '$tool' not found — skipping test suite" exit 77 diff --git a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph.sh b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph.sh index 353cab6518..9f0bcfaa63 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph.sh @@ -76,10 +76,27 @@ cleanup() { kill "$(cat "$PID_FILE")" 2>/dev/null || true fi rm -f "$PID_FILE" - # kill anything still holding server ports so check_port doesn't fail next test - lsof -ti :8080 | xargs kill -9 2>/dev/null || true - lsof -ti :8182 | xargs kill -9 2>/dev/null || true - lsof -ti :8088 | xargs kill -9 2>/dev/null || true + # kill anything still holding server ports: fuser on Linux, lsof on macOS, + # which ships no fuser. Only the Linux path had to lose its lsof dependency. + if [[ "$(uname)" == "Darwin" ]]; then + local port pids pid + for port in 8080 8182 8088; do + pids=$(lsof -ti ":$port" 2>/dev/null) + if [[ -n "$pids" ]]; then + for pid in $pids; do + kill -9 "$pid" 2>/dev/null || true + done + fi + done + elif command -v fuser >/dev/null 2>&1; then + # fuser exists and should work on Linux; suppress failures but log if debug needed + fuser -k 8080/tcp 2>/dev/null || true + fuser -k 8182/tcp 2>/dev/null || true + fuser -k 8088/tcp 2>/dev/null || true + else + # Neither lsof nor fuser available; ports may remain occupied + echo "Warning: Neither lsof nor fuser available for port cleanup" + fi sleep 3 crontab -l 2>/dev/null | grep -v monitor-hugegraph | crontab - 2>/dev/null || true } @@ -145,7 +162,12 @@ if [[ ! -f "$STOP_SCRIPT" ]]; then exit 1 fi -for tool in lsof crontab curl java; do +if [[ "$(uname)" == "Darwin" ]]; then + _prereq_tools="lsof crontab curl java" +else + _prereq_tools="fuser crontab curl java" +fi +for tool in $_prereq_tools; do if ! command -v "$tool" >/dev/null 2>&1; then echo "SKIP: required tool '$tool' not found — skipping test suite" exit 77 @@ -155,7 +177,8 @@ done # start-monitor.sh requires JAVA_HOME if [[ -z "${JAVA_HOME:-}" ]]; then if command -v /usr/libexec/java_home >/dev/null 2>&1; then - export JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null)" + JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null)" + export JAVA_HOME fi fi diff --git a/hugegraph-store/Dockerfile b/hugegraph-store/Dockerfile index 49d41f6460..f1b9db5fd4 100644 --- a/hugegraph-store/Dockerfile +++ b/hugegraph-store/Dockerfile @@ -45,12 +45,14 @@ ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport -XX:Max WORKDIR /hugegraph-store/ # 1. Install runtime dependencies +# Note: lsof is gone because its only consumer was the dead check_port removed +# from hg-store-dist bin/util.sh. Store runs no port preflight, so unlike the +# server images this needs no socket-table tool in its place. RUN apt-get -q update \ && apt-get -q install -y --no-install-recommends --no-install-suggests \ dumb-init \ procps \ curl \ - lsof \ vim \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/hugegraph-store/hg-store-dist/src/assembly/static/bin/start-hugegraph-store.sh b/hugegraph-store/hg-store-dist/src/assembly/static/bin/start-hugegraph-store.sh index 5a11eeab39..88165e47b6 100755 --- a/hugegraph-store/hg-store-dist/src/assembly/static/bin/start-hugegraph-store.sh +++ b/hugegraph-store/hg-store-dist/src/assembly/static/bin/start-hugegraph-store.sh @@ -46,8 +46,8 @@ if [[ $arch == "aarch64" || $arch == "arm64" ]]; then lib_file="$TOP/bin/libjemalloc_aarch64.so" download_url="${GITHUB}/apache/hugegraph-doc/raw/binary-1.5/dist/server/libjemalloc_aarch64.so" expected_md5="2a631d2f81837f9d5864586761c5e380" - if download_and_verify $download_url $lib_file $expected_md5; then - export LD_PRELOAD=$lib_file + if download_and_verify "$download_url" "$lib_file" "$expected_md5"; then + export LD_PRELOAD="$lib_file" else echo "Failed to verify or download $lib_file, skip it" fi @@ -55,8 +55,8 @@ elif [[ $arch == "x86_64" ]]; then lib_file="$TOP/bin/libjemalloc.so" download_url="${GITHUB}/apache/hugegraph-doc/raw/binary-1.5/dist/server/libjemalloc.so" expected_md5="fd61765eec3bfea961b646c269f298df" - if download_and_verify $download_url $lib_file $expected_md5; then - export LD_PRELOAD=$lib_file + if download_and_verify "$download_url" "$lib_file" "$expected_md5"; then + export LD_PRELOAD="$lib_file" else echo "Failed to verify or download $lib_file, skip it" fi @@ -73,7 +73,8 @@ export FILE_LIMITN=1024 #export FILE_LIMITN=1024000 function check_evn_limit() { - local limit_check=$(ulimit -n) + local limit_check + limit_check=$(ulimit -n) if [[ ${limit_check} != "unlimited" && ${limit_check} -lt ${FILE_LIMITN} ]]; then echo -e "${BASH_SOURCE[0]##*/}:${LINENO}:\E[1;32m ulimit -n can open too few maximum file descriptors, need (${FILE_LIMITN})!! \E[0m" return 1 @@ -218,7 +219,7 @@ fi # JAVA_OPTIONS="${JAVA_OPTIONS} -javaagent:${LIB}/jmx_prometheus_javaagent-0.16.1.jar=${JMX_EXPORT_PORT}:${CONF}/jmx_exporter.yml" #fi -if [ $(ps -ef|grep -v grep| grep java|grep -cE ${CONF}) -ne 0 ]; then +if [ "$(ps -ef | grep -v grep | grep java | grep -cE "${CONF}")" -ne 0 ]; then echo "HugeGraphStoreServer is already running..." exit 0 fi diff --git a/hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh b/hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh index 3b3d660102..e45249a04b 100644 --- a/hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh +++ b/hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh @@ -19,20 +19,21 @@ function command_available() { local cmd=$1 - if [ `command -v $cmd >/dev/null 2>&1` ]; then - return 1 - else + if command -v "$cmd" >/dev/null 2>&1; then return 0 fi + return 1 } # read a property from .properties file function read_property() { # file path + local file_name + local property_name file_name=$1 # replace "." to "\." - property_name=`echo $2 | sed 's/\./\\\./g'` - cat $file_name | sed -n -e "s/^[ ]*//g;/^#/d;s/^$property_name=//p" | tail -1 + property_name=$(echo "$2" | sed 's/\./\\\./g') + cat "$file_name" | sed -n -e "s/^[ ]*//g;/^#/d;s/^$property_name=//p" | tail -1 } function write_property() { @@ -40,7 +41,7 @@ function write_property() { local key=$2 local value=$3 - local os=`uname` + local os=$(uname) case $os in # Note: in mac os should use sed -i '' "xxx" to replace string, # otherwise prompt 'command c expects \ followed by text'. @@ -55,7 +56,7 @@ function parse_yaml() { local version=$2 local module=$3 - cat $file | tr -d '\n {}'| awk -F',+|:' '''{ + cat "$file" | tr -d '\n {}'| awk -F',+|:' '''{ pre=""; for(i=1; i<=NF; ) { if(match($i, /version/)) { @@ -71,27 +72,13 @@ function parse_yaml() { } function process_num() { - num=`ps -ef | grep $1 | grep -v grep | wc -l` - return $num + num=$(ps -ef | grep "$1" | grep -v grep | wc -l) + return "$num" } function process_id() { - pid=`ps -ef | grep $1 | grep -v grep | awk '{print $2}'` - return $pid -} - -# check the port of rest server is occupied -function check_port() { - local port=$(echo "$1" | sed 's|.*:||' | sed 's|/.*||') - if ! command_available "lsof"; then - echo "Required lsof but it is unavailable" - exit 1 - fi - lsof -i :$port >/dev/null - if [ $? -eq 0 ]; then - echo "The port $port has already been used" - exit 1 - fi + pid=$(ps -ef | grep "$1" | grep -v grep | awk '{print $2}') + return "$pid" } function crontab_append() { @@ -129,13 +116,14 @@ function wait_for_startup() { local server_url="$3" local timeout_s="$4" - local now_s=`date '+%s'` - local stop_s=$(( $now_s + $timeout_s )) + local now_s + now_s=$(date '+%s') + local stop_s=$(( now_s + timeout_s )) local status echo -n "Connecting to $server_name ($server_url)" - while [ $now_s -le $stop_s ]; do + while [ "$now_s" -le "$stop_s" ]; do echo -n . process_status "$server_name" "$pid" >/dev/null if [ $? -eq 1 ]; then @@ -143,14 +131,14 @@ function wait_for_startup() { return 1 fi - status=`curl -o /dev/null -s -k -w %{http_code} $server_url` - if [[ $status -eq 200 || $status -eq 401 ]]; then + status=$(curl -o /dev/null -s -k -w '%{http_code}' "$server_url") + if [[ "$status" -eq 200 || "$status" -eq 401 ]]; then echo "OK" echo "Started [pid $pid]" return 0 fi sleep 2 - now_s=`date '+%s'` + now_s=$(date '+%s') done echo "The operation timed out when attempting to connect to $server_url" >&2 @@ -159,22 +147,28 @@ function wait_for_startup() { function free_memory() { local free="" - local os=`uname` + local os=$(uname) if [ "$os" == "Linux" ]; then - local mem_free=`cat /proc/meminfo | grep -w "MemFree" | awk '{print $2}'` - local mem_buffer=`cat /proc/meminfo | grep -w "Buffers" | awk '{print $2}'` - local mem_cached=`cat /proc/meminfo | grep -w "Cached" | awk '{print $2}'` + local mem_free=$(cat /proc/meminfo | grep -w "MemFree" | awk '{print $2}') + local mem_buffer=$(cat /proc/meminfo | grep -w "Buffers" | awk '{print $2}') + local mem_cached=$(cat /proc/meminfo | grep -w "Cached" | awk '{print $2}') if [[ "$mem_free" == "" || "$mem_buffer" == "" || "$mem_cached" == "" ]]; then echo "Failed to get free memory" exit 1 fi - free=`expr $mem_free + $mem_buffer + $mem_cached` - free=`expr $free / 1024` + free=$(expr "$mem_free" + "$mem_buffer" + "$mem_cached") + free=$(expr "$free" / 1024) elif [ "$os" == "Darwin" ]; then - local pages_free=`vm_stat | awk '/Pages free/{print $0}' | awk -F'[:.]+' '{print $2}' | tr -d " "` - local pages_inactive=`vm_stat | awk '/Pages inactive/{print $0}' | awk -F'[:.]+' '{print $2}' | tr -d " "` - local pages_available=`expr $pages_free + $pages_inactive` - free=`expr $pages_available \* 4096 / 1024 / 1024` + local pages_free pages_inactive + pages_free=$(vm_stat | awk '/Pages free/{print $0}' | awk -F'[:.]+' '{print $2}' | tr -d " ") + pages_inactive=$(vm_stat | awk '/Pages inactive/{print $0}' | awk -F'[:.]+' '{print $2}' | tr -d " ") + if [[ -z "$pages_free" || -z "$pages_inactive" ]]; then + echo "Failed to get free memory" + exit 1 + fi + local pages_available + pages_available=$(expr "$pages_free" + "$pages_inactive") + free=$(expr "$pages_available" \* 4096 / 1024 / 1024) else echo "Unsupported operating system $os" exit 1 @@ -186,8 +180,8 @@ function calc_xmx() { local min_mem=$1 local max_mem=$2 # Get machine available memory - local free=`free_memory` - local half_free=$[free/2] + local free=$(free_memory) + local half_free=$((free/2)) local xmx=$min_mem if [[ "$free" -lt "$min_mem" ]]; then @@ -235,47 +229,78 @@ function ensure_path_writable() { } function get_ip() { - local os=`uname` + local os=$(uname) local loopback="127.0.0.1" local ip="" case $os in Linux) if command_available "ifconfig"; then - ip=`ifconfig | grep 'inet addr:' | grep -v "$loopback" | cut -d: -f2 | awk '{ print $1}'` + ip=$(ifconfig | grep 'inet addr:' | grep -v "$loopback" | cut -d: -f2 | awk '{ print $1}') elif command_available "ip"; then - ip=`ip addr | grep 'state UP' -A2 | tail -n1 | awk '{print $2}' | awk -F"/" '{print $1}'` + ip=$(ip addr | grep 'state UP' -A2 | tail -n1 | awk '{print $2}' | awk -F"/" '{print $1}') else ip=$loopback fi ;; FreeBSD|OpenBSD|Darwin) if command_available "ifconfig"; then - ip=`ifconfig | grep -E 'inet.[0-9]' | grep -v "$loopback" | awk '{ print $2}'` + ip=$(ifconfig | grep -E 'inet.[0-9]' | grep -v "$loopback" | awk '{ print $2}') else ip=$loopback fi ;; SunOS) if command_available "ifconfig"; then - ip=`ifconfig -a | grep inet | grep -v "$loopback" | awk '{ print $2} '` + ip=$(ifconfig -a | grep inet | grep -v "$loopback" | awk '{ print $2} ') else ip=$loopback fi ;; *) ip=$loopback;; esac - echo $ip + [[ -z "$ip" ]] && ip=$loopback + echo "$ip" } function download() { local path=$1 local link_url=$2 - if command_available "wget"; then - wget --help | grep -q '\--show-progress' && progress_opt="-q --show-progress" || progress_opt="" - wget ${link_url} -P ${path} $progress_opt - elif command_available "curl"; then - curl ${link_url} -o ${path}/${link_url} + if [ ! -d "$path" ]; then + mkdir -p "$path" || { + echo "Failed to create directory: $path" + exit 1 + } + fi + + local filename tmp + filename=$(basename "${link_url%%[?#]*}") + local dest="${path}/${filename}" + # mktemp, not $$: a PID is shared by concurrent background subshells. + tmp=$(mktemp -- "${path}/.${filename}.XXXXXX") || { + echo "Failed to create a temporary file in $path" + return 1 + } + + if command_available "curl"; then + # -o must appear before -- so it is parsed as an option, not an extra URL. + if curl -fL -o "$tmp" -- "${link_url}"; then + mv -f -- "$tmp" "$dest" || { rm -f -- "$tmp"; return 1; } + else + rm -f -- "$tmp" + return 1 + fi + elif command_available "wget"; then + local -a progress_opt=() + if wget --help 2>&1 | grep -q -- '--show-progress'; then + progress_opt=(-q --show-progress) + fi + if wget ${progress_opt[@]+"${progress_opt[@]}"} -O "$tmp" -- "${link_url}"; then + mv -f -- "$tmp" "$dest" || { rm -f -- "$tmp"; return 1; } + else + rm -f -- "$tmp" + return 1 + fi else echo "Required wget or curl but they are unavailable" exit 1 @@ -286,14 +311,15 @@ download_and_verify() { local url=$1 local filepath=$2 local expected_md5=$3 + local actual_md5 - if [[ -f $filepath ]]; then + if [[ -f "$filepath" ]]; then echo "File $filepath exists. Verifying MD5 checksum..." - actual_md5=$(md5sum $filepath | awk '{ print $1 }') - if [[ $actual_md5 != $expected_md5 ]]; then + actual_md5=$(md5sum -- "$filepath" | awk '{ print $1 }') + if [[ "$actual_md5" != "$expected_md5" ]]; then echo "MD5 checksum verification failed for $filepath. Expected: $expected_md5, but got: $actual_md5" - echo "Deleting $filepath..." - rm -f $filepath + # Keep the shared destination until a verified temporary file is + # ready; a stale reader must not delete another caller's replacement. else echo "MD5 checksum verification succeeded for $filepath." return 0 @@ -301,11 +327,21 @@ download_and_verify() { fi echo "Downloading $filepath..." - curl -L -o $filepath $url - - actual_md5=$(md5sum $filepath | awk '{ print $1 }') - if [[ $actual_md5 != $expected_md5 ]]; then - echo "MD5 checksum verification failed for $filepath after download. Expected: $expected_md5, but got: $actual_md5" + local tmp + # mktemp, not $$: a PID is shared by concurrent background subshells. + tmp=$(mktemp -- "${filepath}.XXXXXX") || return 1 + if curl -fL -o "$tmp" -- "$url"; then + actual_md5=$(md5sum -- "$tmp" | awk '{ print $1 }') + if [[ "$actual_md5" != "$expected_md5" ]]; then + echo "MD5 checksum verification failed for $filepath after download. Expected: $expected_md5, but got: $actual_md5" + rm -f -- "$tmp" + return 1 + fi + # A failed rename must not report success: callers export LD_PRELOAD + # from $filepath and would otherwise use a library that never installed. + mv -f -- "$tmp" "$filepath" || { rm -f -- "$tmp"; return 1; } + else + rm -f -- "$tmp" return 1 fi @@ -318,19 +354,17 @@ function ensure_package_exist() { local tar=$3 local link=$4 - if [ ! -d ${path}/${dir} ]; then - if [ ! -f ${path}/${tar} ]; then + if [ ! -d "${path}/${dir}" ]; then + if [ ! -f "${path}/${tar}" ]; then echo "Downloading the compressed package '${tar}'" - download ${path} ${link} - if [ $? -ne 0 ]; then + if ! download "${path}" "${link}"; then echo "Failed to download, please ensure the network is available and link is valid" exit 1 fi echo "[OK] Finished download" fi echo "Unzip the compressed package '$tar'" - tar -zxvf ${path}/${tar} -C ${path} >/dev/null 2>&1 - if [ $? -ne 0 ]; then + if ! tar -zxvf "${path}/${tar}" -C "${path}" >/dev/null 2>&1; then echo "Failed to unzip, please check the compressed package" exit 1 fi @@ -345,7 +379,7 @@ function wait_for_shutdown() { local pid="$2" local timeout_s="$3" - local now_s=`date '+%s'` + local now_s=$(date '+%s') local stop_s=$(( $now_s + $timeout_s )) echo -n "Killing $process_name(pid $pid)" >&2 @@ -357,7 +391,7 @@ function wait_for_shutdown() { return 0 fi sleep 2 - now_s=`date '+%s'` + now_s=$(date '+%s') done echo "$process_name shutdown timeout(exceeded $timeout_s seconds)" >&2 return 1 @@ -386,7 +420,7 @@ function kill_process() { return 0 fi - case "`uname`" in + case "$(uname)" in CYGWIN*) taskkill /F /PID "$pid" ;; *) kill "$pid" ;; esac