From 5801f735bc6e70dbb63cb2cb655c9a0680ae6628 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 15 Oct 2026 23:20:15 +0800 Subject: [PATCH] fix: improve shell compatibility and process cleanup Preserve literal paths and arguments, use portable command options, validate startup configuration, and retain scripts when downloads fail. Ensure task watchdogs and process groups are cleaned up across Bash versions, with compatibility and process lifecycle regression coverage. --- docker/docker-entrypoint.sh | 4 +- shell/api.sh | 6 +- shell/otask.sh | 58 +-- shell/pub.sh | 10 +- shell/rmlog.sh | 37 +- shell/share.sh | 86 ++-- shell/start-simplify.sh | 4 +- shell/start.sh | 41 +- shell/task-timeout.sh | 26 +- shell/task.sh | 14 +- shell/update.sh | 40 +- test/back/qlServiceLogCommand.test.cjs | 2 +- test/back/shell-compatibility.test.cjs | 558 ++++++++++++++++++++++ test/back/task-timeout-processes.test.cjs | 153 ++++++ 14 files changed, 900 insertions(+), 139 deletions(-) create mode 100644 test/back/shell-compatibility.test.cjs create mode 100644 test/back/task-timeout-processes.test.cjs diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 978a92cd..4976e11a 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -119,12 +119,12 @@ reload_pm2 if [[ $AutoStartBot == true ]]; then log_with_style "INFO" "🤖 3. 启动 bot..." - nohup ql bot >$dir_log/bot.log 2>&1 & + nohup ql bot >"$dir_log/bot.log" 2>&1 & fi if [[ $EnableExtraShell == true ]]; then log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." - nohup ql extra >$dir_log/extra.log 2>&1 & + nohup ql extra >"$dir_log/extra.log" 2>&1 & fi log_with_style "SUCCESS" "🎉 容器启动成功!" diff --git a/shell/api.sh b/shell/api.sh index ecacf56a..03926e99 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash create_token() { - local token_command="ts-node-transpile-only ${dir_root}/back/token.ts" local token_file="${dir_root}/static/build/token.js" if [[ -f $token_file ]]; then - token_command="node ${token_file}" + __ql_token__=$(node "$token_file") + else + __ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts") fi - __ql_token__=$(eval "$token_command") } get_token() { diff --git a/shell/otask.sh b/shell/otask.sh index f826bfa5..20e1b70f 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -39,16 +39,16 @@ random_delay() { gen_array_scripts() { local dir_current=$(pwd) local i="-1" - cd $dir_scripts - for file in $(ls); do + cd "$dir_scripts" || return $? + for file in *; do if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then let i++ - array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g") - array_scripts_name[i]=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) + array_scripts[i]=$file + array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) [[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>" fi done - cd $dir_current + cd "$dir_current" || return $? } ## 使用说明 @@ -72,11 +72,11 @@ usage() { ## run nohup,$1:文件名,不含路径,带后缀 run_nohup() { local file_name=$1 - nohup node $file_name &>$log_path & + nohup node "$file_name" &>"$log_path" & } env_str_to_array() { - . $file_env + . "$file_env" local IFS="&" read -ra array <<<"${!env_param}" array_length=${#array[@]} @@ -122,7 +122,7 @@ enter_script_workdir() { _target_dir="${dir_scripts}/${work_dir}" fi if [[ -d "${_target_dir}" ]]; then - cd "${_target_dir}" + cd "${_target_dir}" || return $? if [[ ${file_param} =~ "/" ]]; then local script_name="${file_param##*/}" if [[ "${use_dot_prefix}" == "true" ]]; then @@ -136,13 +136,13 @@ enter_script_workdir() { t '警告:工作目录不存在 %s' "${_target_dir}" fi - cd $dir_scripts + cd "$dir_scripts" || return $? if [[ ${file_param} =~ "/" ]]; then local script_dir="${file_param%/*}" local script_name="${file_param##*/}" if [[ -d ${script_dir} ]]; then - cd ${script_dir} + cd "$script_dir" || return $? if [[ "${use_dot_prefix}" == "true" ]]; then file_param="./${script_name}" else @@ -159,12 +159,12 @@ run_normal() { random_delay "$file_param" fi - enter_script_workdir + enter_script_workdir || return $? if [[ $isJsOrPythonFile == 'false' ]]; then clear_non_sh_env fi - run_task_command $which_program $file_param "${script_params[@]}" + run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" } handle_env_split() { @@ -175,7 +175,7 @@ handle_env_split() { env_str_to_array local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g") local runArr=($(eval echo $tempArr)) - array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]})) + array_run=($(awk -v RS=' ' '!a[$1]++' <<<"${runArr[*]}")) } ## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行 @@ -192,10 +192,10 @@ run_concurrent() { time=$(date "+$mtime_format") single_log_time=$(format_log_time "$mtime_format" "$time") - enter_script_workdir + enter_script_workdir || return $? local j=0 - for i in ${array_run[@]}; do + for i in "${array_run[@]}"; do single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log" let j++ @@ -203,16 +203,16 @@ run_concurrent() { export "${env_param}=${array[$i - 1]}" clear_non_sh_env fi - eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path & + envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" & done wait local k=0 - for i in ${array_run[@]}; do + for i in "${array_run[@]}"; do single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log" let k++ - cat $single_log_path - [[ -f $single_log_path ]] && rm -f $single_log_path + cat "$single_log_path" + [[ -f $single_log_path ]] && rm -f "$single_log_path" done } @@ -229,7 +229,7 @@ run_designated() { if [[ $isJsOrPythonFile == 'false' ]]; then local n=0 - for i in ${array_run[@]}; do + for i in "${array_run[@]}"; do array_str[n]=${array[$i - 1]} let n++ done @@ -241,9 +241,9 @@ run_designated() { clear_non_sh_env fi - enter_script_workdir + enter_script_workdir || return $? - envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}" + envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" } ## 运行其他命令 @@ -273,7 +273,7 @@ run_else() { if [[ -n "$script_for_dir" ]]; then local saved_file_param="$file_param" file_param="$script_for_dir" - enter_script_workdir true + enter_script_workdir true || return $? local adjusted_script="$file_param" file_param="$saved_file_param" @@ -289,17 +289,17 @@ run_else() { set -- "${new_args[@]}" else # 没有找到脚本参数,只 cd 到 scripts 目录 - enter_script_workdir true + enter_script_workdir true || return $? shift fi else # file_param 本身就是脚本,直接用 enter_script_workdir 处理 - enter_script_workdir true + enter_script_workdir true || return $? shift fi clear_non_sh_env - run_task_command $which_program $file_param "$@" + run_task_command ${which_program:+"$which_program"} "$file_param" "$@" } check_file() { @@ -313,12 +313,14 @@ check_file() { export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}" export PREV_PYTHONPATH="${PYTHONPATH:=}" if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then - export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}" + local preload_path=${file_preload_js//\\/\\\\} + preload_path=${preload_path//\"/\\\"} + export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}" else export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}" fi else - . $file_env + . "$file_env" fi fi } diff --git a/shell/pub.sh b/shell/pub.sh index 1d126bc6..3b045282 100755 --- a/shell/pub.sh +++ b/shell/pub.sh @@ -1,4 +1,12 @@ #!/usr/bin/env bash + +# BSD grep accepts \d as a digit; GNU grep treats it as a literal d. +string=$(awk '$1 == "version:" { print $2; exit }' version.yaml) || exit 1 +if [[ ! $string =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?$ ]]; then + printf 'Invalid release version: %s\n' "$string" >&2 + exit 1 +fi +version="v$string" echo -e "开始发布" echo -e "切换master分支" @@ -9,8 +17,6 @@ git push --set-upstream origin master -f echo -e "更新cdn文件" ts-node-transpile-only sample/tool.ts -string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*") -version="v$string" echo -e "当前版本$version" echo -e "删除已经存在的本地tag" diff --git a/shell/rmlog.sh b/shell/rmlog.sh index cc67ff24..30059d6b 100755 --- a/shell/rmlog.sh +++ b/shell/rmlog.sh @@ -3,42 +3,47 @@ days=$1 remove_js_log() { - local log_full_path_list=$(find $dir_log -name "*.log") - local diff_time - for log in $log_full_path_list; do - local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10) + local log log_date log_timestamp diff_time log_path encoded_path result + local now + now=$(date +%s) || return $? + while IFS= read -r -d '' log; do + log_date=${log##*/} + log_date=${log_date:0:10} if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then if [[ $is_macos -eq 1 ]]; then - log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") + log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue else log_date=$(stat -c %y "$log" | cut -d ' ' -f 1) fi fi if [[ $is_macos -eq 1 ]]; then - diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s))) + # BSD date otherwise fills the omitted time from the current clock. + log_timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "$log_date 00:00:00" +%s) || continue else - diff_time=$(($(date +%s) - $(date +%s -d "$log_date"))) + log_timestamp=$(date +%s -d "$log_date") || continue fi + diff_time=$((now - log_timestamp)) if [[ $diff_time -gt $((${days} * 86400)) ]]; then - local log_path=$(echo "$log" | sed "s,${dir_log}/,,g") - local result=$(find_cron_api "log_path=$log_path") + log_path=${log#"$dir_log"/} + encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue + result=$(find_cron_api "log_path=$encoded_path") t '查询文件 %s' "$log_path" if [[ -z $result ]]; then t '删除中~' - rm -vf $log + rm -vf -- "$log" else t '正在被 %s 使用,跳过~' "$result" fi fi - done + done < <(find "$dir_log" -type f -name "*.log" -print0) } remove_empty_dir() { - cd $dir_log - for dir in $(ls); do - if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then - rm -rf $dir - fi + local dir + for dir in "$dir_log"/*/ "$dir_log"/.[!.]*/ "$dir_log"/..?*/; do + [[ -d "$dir" ]] || continue + # rmdir also protects a directory populated between discovery and removal. + rmdir -- "$dir" 2>/dev/null || true done } diff --git a/shell/share.sh b/shell/share.sh index 575e8288..eb49502f 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -77,16 +77,19 @@ load_ql_envs() { } import_config() { - [[ -f $file_config_user ]] && . $file_config_user - [[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh + [[ -f $file_config_user ]] && . "$file_config_user" + [[ -f $dir_preload/lang_env.sh ]] && . "$dir_preload/lang_env.sh" - # 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key) + # Language packs need global associative arrays (Bash 4.2+). Probe the + # capability so Bash 3.2 and 4.0/4.1 retain the Chinese-key fallback. local lang=${QL_LANG:-zh} local lang_file="$dir_shell/lang/${lang}.sh" - if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then - . $lang_file - elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then - . "$dir_shell/lang/zh.sh" + if (declare -gA __ql_lang_probe) 2>/dev/null; then + if [[ -f $lang_file ]]; then + . "$lang_file" + elif [[ -f "$dir_shell/lang/zh.sh" ]]; then + . "$dir_shell/lang/zh.sh" + fi fi load_ql_envs @@ -132,7 +135,7 @@ unset_proxy() { make_dir() { local dir=$1 if [[ ! -d $dir ]]; then - mkdir -p $dir + mkdir -p -- "$dir" fi } @@ -176,57 +179,57 @@ define_cmd() { } fix_config() { - make_dir $dir_tmp - make_dir $dir_static - make_dir $dir_data - make_dir $dir_config - make_dir $dir_log - make_dir $dir_db - make_dir $dir_scripts - make_dir $dir_list_tmp - make_dir $dir_repo - make_dir $dir_raw - make_dir $dir_update_log - make_dir $dir_dep + make_dir "$dir_tmp" + make_dir "$dir_static" + make_dir "$dir_data" + make_dir "$dir_config" + make_dir "$dir_log" + make_dir "$dir_db" + make_dir "$dir_scripts" + make_dir "$dir_list_tmp" + make_dir "$dir_repo" + make_dir "$dir_raw" + make_dir "$dir_update_log" + make_dir "$dir_dep" if [[ ! -s $file_config_user ]]; then - cp -f $file_config_sample $file_config_user + cp -f "$file_config_sample" "$file_config_user" fi if [[ ! -f $file_task_before ]]; then - cp -f $file_task_sample $file_task_before + cp -f "$file_task_sample" "$file_task_before" fi if [[ ! -f $file_task_after ]]; then - cp -f $file_task_sample $file_task_after + cp -f "$file_task_sample" "$file_task_after" fi if [[ ! -f $file_extra_shell ]]; then - cp -f $file_extra_sample $file_extra_shell + cp -f "$file_extra_sample" "$file_extra_shell" fi if [[ ! -s $file_notify_py ]]; then - cp -f $file_notify_py_sample $file_notify_py + cp -f "$file_notify_py_sample" "$file_notify_py" fi if [[ ! -s $file_notify_js ]]; then - cp -f $file_notify_js_sample $file_notify_js + cp -f "$file_notify_js_sample" "$file_notify_js" fi if [[ ! -s $file_test_js ]]; then - cp -f $file_test_js_sample $file_test_js + cp -f "$file_test_js_sample" "$file_test_js" fi if [[ ! -s $file_test_py ]]; then - cp -f $file_test_py_sample $file_test_py + cp -f "$file_test_py_sample" "$file_test_py" fi if [[ ! -s $dep_notify_js ]]; then - cp -f $file_notify_js_sample $dep_notify_js + cp -f "$file_notify_js_sample" "$dep_notify_js" fi if [[ ! -s $dep_notify_py ]]; then - cp -f $file_notify_py_sample $dep_notify_py + cp -f "$file_notify_py_sample" "$dep_notify_py" fi } @@ -246,17 +249,17 @@ npm_install_2() { local dir_current=$(pwd) local dir_work=$1 - cd $dir_work + cd "$dir_work" || return $? t '安装 %s 依赖包...\n' "$dir_work" npm_install_sub - cd $dir_current + cd "$dir_current" || return $? } diff_and_copy() { local copy_source=$1 local copy_to=$2 - if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then - cp -f $copy_source $copy_to + if [[ ! -s $copy_to ]] || [[ $(diff "$copy_source" "$copy_to") ]]; then + cp -f "$copy_source" "$copy_to" fi } @@ -265,12 +268,13 @@ git_clone_scripts() { local dir="$2" local branch="$3" local proxy="$4" - [[ $branch ]] && local part_cmd="-b $branch " + local branch_args=() + [[ $branch ]] && branch_args=(-b "$branch") t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir" set_proxy "$proxy" - git clone -q --depth=1 $part_cmd $url $dir + git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir" exit_status=$? unset_proxy @@ -283,7 +287,7 @@ random_range() { } delete_pm2() { - cd $dir_root + cd "$dir_root" || return $? # Try to delete PM2 processes, but don't fail if PM2 is not available pm2 delete ecosystem.config.js 2>/dev/null || true # Also try to kill any directly spawned node processes @@ -291,7 +295,7 @@ delete_pm2() { } reload_pm2() { - cd $dir_root + cd "$dir_root" || return $? restore_env_vars # Try to start PM2, but handle failures gracefully @@ -310,7 +314,7 @@ reload_pm2() { if [[ "$QL_CONTAINER" == "true" ]]; then nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 & else - nohup node static/build/app.js >$dir_log/qinglong.log 2>&1 & + nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 & fi local node_pid=$! @@ -398,7 +402,7 @@ handle_task_start() { } run_task_before() { - . $file_task_before "$@" + . "$file_task_before" "$@" if [[ ${task_before:=} ]]; then t '执行前置命令\n' @@ -408,7 +412,7 @@ run_task_before() { } run_task_after() { - . $file_task_after "$@" + . "$file_task_after" "$@" if [[ ${task_after:=} ]]; then t '\n执行后置命令\n' diff --git a/shell/start-simplify.sh b/shell/start-simplify.sh index f016ada5..36e4ceaf 100644 --- a/shell/start-simplify.sh +++ b/shell/start-simplify.sh @@ -63,12 +63,12 @@ reload_pm2 if [[ $command != "reload" ]]; then if [[ $AutoStartBot == true ]]; then log_with_style "INFO" "🤖 4. 启动 bot..." - nohup ql bot >$dir_log/bot.log 2>&1 & + nohup ql bot >"$dir_log/bot.log" 2>&1 & fi if [[ $EnableExtraShell == true ]]; then log_with_style "INFO" "🛠️ 5. 执行自定义脚本..." - nohup ql extra >$dir_log/extra.log 2>&1 & + nohup ql extra >"$dir_log/extra.log" 2>&1 & fi pm2 startup diff --git a/shell/start.sh b/shell/start.sh index 5553c359..3da0734d 100644 --- a/shell/start.sh +++ b/shell/start.sh @@ -4,31 +4,46 @@ set -e set -x +# Configuration and language helpers are not loaded during bootstrap errors. +startup_message() { + local format="$1" + shift + printf -- "$format\n" "$@" >&2 +} + if [[ ! $QL_DIR ]]; then - npm_dir=$(npm root -g) - pnpm_dir=$(pnpm root -g) - if [[ -d "$npm_dir/@whyour/qinglong" ]]; then + npm_dir=$(npm root -g 2>/dev/null) || npm_dir="" + pnpm_dir="" + if command -v pnpm >/dev/null 2>&1; then + pnpm_dir=$(pnpm root -g 2>/dev/null) || pnpm_dir="" + fi + if [[ -n "$npm_dir" && -d "$npm_dir/@whyour/qinglong" ]]; then QL_DIR="$npm_dir/@whyour/qinglong" - elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then + elif [[ -n "$pnpm_dir" && -d "$pnpm_dir/@whyour/qinglong" ]]; then QL_DIR="$pnpm_dir/@whyour/qinglong" else - t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装' + startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装' fi if [[ $QL_DIR ]]; then - t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR" + startup_message '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR" fi exit 1 fi +if [[ $QL_DIR != /* ]]; then + startup_message 'QL_DIR 必须为绝对路径,例如 /ql' + exit 1 +fi + if [[ ! $QL_DATA_DIR ]]; then - t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量' + startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量' exit 1 fi -if [[ $QL_DATA_DIR != */data ]]; then - t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中' +if [[ $QL_DATA_DIR != /*/data ]]; then + startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中' exit 1 fi @@ -38,7 +53,11 @@ if [[ $command != "reload" ]]; then # 安装依赖 os_name="${QL_OS_TYPE:-}" if [ -z "$os_name" ]; then - os_name=$(source /etc/os-release && echo "$ID") + if [[ -r /etc/os-release ]]; then + os_name=$(source /etc/os-release && echo "$ID") + else + os_name=$(uname -s) + fi fi # 非 root 用户使用 sudo @@ -69,7 +88,7 @@ if [[ $command != "reload" ]]; then $SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client ;; *) - t '暂不支持此系统部署 %s' "$os_name" + startup_message '暂不支持此系统部署 %s' "$os_name" exit 1 ;; esac diff --git a/shell/task-timeout.sh b/shell/task-timeout.sh index 4710a9a4..a230d599 100644 --- a/shell/task-timeout.sh +++ b/shell/task-timeout.sh @@ -36,7 +36,10 @@ run_task_command() { ) & timeout_watcher=$! set +m - trap '{ + finish_task_timeout() { + local code="$1" + trap - EXIT + { # Bash can report a killed job between any two cleanup commands. # Keep redirection active until the watchdog is fully reaped. kill -KILL -- "-$timeout_watcher" || : @@ -45,14 +48,19 @@ run_task_command() { if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi kill -KILL -- "-$timeout_worker" 2>/dev/null || : wait "$timeout_worker" 2>/dev/null || : - rm -rf -- "$timeout_dir"' EXIT - trap 'exit 130' INT - trap 'exit 143' TERM - trap 'exit 129' HUP - trap 'exit 131' QUIT + rm -rf -- "$timeout_dir" + exit "$code" + } + # Older Bash can omit a subshell's EXIT trap in sourced pipelines. Call + # cleanup explicitly on completion/signals, retaining EXIT as a fallback. + trap 'finish_task_timeout "$?"' EXIT + trap 'finish_task_timeout 130' INT + trap 'finish_task_timeout 143' TERM + trap 'finish_task_timeout 129' HUP + trap 'finish_task_timeout 131' QUIT # Do not clean up a timer whose process group has not initialized yet. while [[ ! -f "$timeout_dir/ready" ]]; do - kill -0 "$timeout_watcher" 2>/dev/null || exit 125 + kill -0 "$timeout_watcher" 2>/dev/null || finish_task_timeout 125 sleep 0.001 done wait "$timeout_worker" @@ -60,8 +68,8 @@ run_task_command() { if [[ -f "$timeout_dir/expired" ]]; then # Let the timer finish escalation even if the task leader exited first. wait "$timeout_watcher" 2>/dev/null || : - exit 124 + finish_task_timeout 124 fi - exit "$timeout_code" + finish_task_timeout "$timeout_code" ) } diff --git a/shell/task.sh b/shell/task.sh index 92bd620f..d5ab6e7d 100755 --- a/shell/task.sh +++ b/shell/task.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash dir_shell=$QL_DIR/shell -. $dir_shell/share.sh -. $dir_shell/api.sh -. $dir_shell/task-timeout.sh +. "$dir_shell/share.sh" +. "$dir_shell/api.sh" +. "$dir_shell/task-timeout.sh" trap 'single_hanle SIGINT' INT trap 'single_hanle SIGTERM' TERM @@ -48,7 +48,7 @@ handle_log_path() { fi if [[ -z ${ID:=} ]]; then - ID=$(cat $list_crontab_user | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}') + ID=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}') fi local suffix="" if [[ ! -z $ID ]]; then @@ -89,10 +89,10 @@ handle_log_path() { log_path="$real_log_path" fi - cmd="2>&1 | tee -a $dir_log/$log_path" + printf -v cmd '2>&1 | tee -a %q' "$dir_log/$log_path" make_dir "$dir_log/$log_dir" if [[ "${no_tee:=}" == "true" ]]; then - cmd=">> $dir_log/$log_path 2>&1" + printf -v cmd '>> %q 2>&1' "$dir_log/$log_path" fi if [[ "${real_time:=}" == "true" ]]; then @@ -162,5 +162,5 @@ define_program "${task_shell_params[@]}" handle_log_path "${task_shell_params[@]}" init_begin_time -eval . $dir_shell/otask.sh "$cmd" +eval '. "$dir_shell/otask.sh"' "$cmd" exit 0 diff --git a/shell/update.sh b/shell/update.sh index 2008cc9a..0324d1df 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash dir_shell=$QL_DIR/shell -. $dir_shell/share.sh -. $dir_shell/api.sh +. "$dir_shell/share.sh" +. "$dir_shell/api.sh" load_ql_envs -. $dir_shell/env.sh +. "$dir_shell/env.sh" send_mark=$dir_shell/send_mark @@ -165,17 +165,20 @@ update_raw() { t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}" set_proxy "$proxy" - wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url} - exit_status=$? + if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then + exit_status=0 + else + exit_status=$? + fi unset_proxy - if [[ $? -eq 0 ]]; then - mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" + if [[ $exit_status -eq 0 ]]; then + mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $? t '下载 %s 成功...\n' "${raw_file_name}" - cd $dir_raw + cd "$dir_raw" || return $? local filename="raw_${raw_file_name}" - local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}') - cp -f $raw_file_name $dir_scripts/${filename} + local cron_id=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}') + cp -f "$raw_file_name" "$dir_scripts/${filename}" || return $? if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then cron_line=$( perl -ne "{ @@ -202,6 +205,7 @@ update_raw() { else t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}" [[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new" + return "$exit_status" fi } @@ -209,7 +213,7 @@ update_raw() { ## 调用用户自定义的extra.sh run_extra_shell() { if [[ -f $file_extra_shell ]]; then - . $file_extra_shell + . "$file_extra_shell" else t '%s文件不存在,跳过执行...\n' "$file_extra_shell" fi @@ -266,7 +270,8 @@ show_service_logs() { # Winston writes the active runtime log by date. Follow the filename so # size-based rotations are handled without involving the PM2 log files. local current_system_log="$system_log_dir/$(date +%F).log" - tail -n 0 --retry -F "$current_system_log" + # GNU and BSD tail both support -F; --retry is GNU-only and implied by -F. + tail -n 0 -F "$current_system_log" } ## 脚本用法 @@ -484,9 +489,9 @@ main() { local log_path="${log_dir}/${log_time}.log" local file_path="$dir_log/$log_path" - cmd="2>&1 | tee -a $file_path" + printf -v cmd '2>&1 | tee -a %q' "$file_path" if [[ "$no_tee" == "true" ]]; then - cmd=">> $file_path 2>&1" + printf -v cmd '>> %q 2>&1' "$file_path" fi if [[ "$real_time" == "true" ]]; then cmd="" @@ -532,19 +537,20 @@ main() { get_uniq_path "$p2" if [[ -n $p2 ]]; then update_raw "$p2" "$p3" "$p4" "$p5" + command_status=$? else t '命令输入错误...\n' eval usage $cmd fi ;; rmlog) - eval . $dir_shell/rmlog.sh "$p2" $cmd + eval '. "$dir_shell/rmlog.sh" "$p2"' "$cmd" ;; bot) - eval . $dir_shell/bot.sh $cmd + eval '. "$dir_shell/bot.sh"' "$cmd" ;; check) - eval . $dir_shell/check.sh $cmd + eval '. "$dir_shell/check.sh"' "$cmd" ;; resetlet) eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd diff --git a/test/back/qlServiceLogCommand.test.cjs b/test/back/qlServiceLogCommand.test.cjs index 74fab549..fa28585d 100644 --- a/test/back/qlServiceLogCommand.test.cjs +++ b/test/back/qlServiceLogCommand.test.cjs @@ -101,7 +101,7 @@ test('ql log follows the current Winston system log without PM2', () => { ); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`); + assert.equal(result.stdout, `tail <-n> <0> <-F> <${currentLog}>\n`); assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false); }); diff --git a/test/back/shell-compatibility.test.cjs b/test/back/shell-compatibility.test.cjs new file mode 100644 index 00000000..0af5b9fa --- /dev/null +++ b/test/back/shell-compatibility.test.cjs @@ -0,0 +1,558 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const repo = path.resolve(__dirname, '../..'); +const bash = process.env.QL_TEST_BASH || '/bin/bash'; +const source = (file) => fs.readFileSync(path.join(repo, file), 'utf8'); +function extract(file, name) { + const text = source(file); + const start = text.indexOf(`${name}() {`); + assert.ok(start >= 0, name); + return text.slice(start, text.indexOf('\n}', start) + 2); +} +function fixture(t, prefix = `ql compatibility '" $literal `) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const write = (name, content, executable = false) => { + const file = path.join(root, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content, { mode: executable ? 0o755 : 0o644 }); + return file; + }; + const run = (code, env = {}, args = [], cwd = root) => + spawnSync(bash, ['-c', code, 'compatibility-test', ...args], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf8', + timeout: 15000, + }); + return { root, write, run }; +} + +for (const [label, args] of [ + ['normal', ['nested folder/probe [1] $name.sh', '--']], + ['explicit bash', ['bash', 'nested folder/probe [1] $name.sh']], + [ + 'designated', + ['nested folder/probe [1] $name.sh', 'desi', 'QA_PANEL', '1-2', '--'], + ], + [ + 'concurrent', + ['nested folder/probe [1] $name.sh', 'conc', 'QA_PANEL', '1-2', '--'], + ], + ['automatic Node', ['nested folder/probe [1] $name.js', '--']], + ['explicit Node', ['node', 'nested folder/probe [1] $name.js']], +]) { + for (const logging of ['tee', 'file']) { + test(`${label} task keeps literal paths and arguments with ${logging} logging`, (t) => { + const f = fixture(t); + for (const name of ['share.sh', 'task.sh', 'otask.sh', 'task-timeout.sh']) + f.write(`shell/${name}`, source(`shell/${name}`)); + f.write( + 'shell/api.sh', + 'update_cron(){ :; }; record_cron_stat(){ :; }\n', + ); + f.write('shell/preload/env.sh', 'export QA_PANEL="one&two"\n'); + f.write('shell/preload/sitecustomize.js', '// fixture preloader\n'); + f.write( + 'data/config/config.sh', + 'DefaultCronRule="0 0 * * *"\nCommandTimeoutTime="2s"\n', + ); + f.write('data/config/task_before.sh', 'printf "BEFORE\\n"\n'); + f.write('data/config/task_after.sh', 'printf "AFTER\\n"\n'); + f.write('bin/pnpm', '#!/bin/bash\nprintf /fixture/global\n', true); + const literal = `argument with 'quotes' $literal ; echo NOT_EXECUTED`; + f.write( + 'data/scripts/nested folder/probe [1] $name.sh', + 'printf "ARG=%s\\nACCOUNT=%s\\n" "$1" "$QA_PANEL"\n', + ); + f.write( + 'data/scripts/nested folder/probe [1] $name.js', + 'console.log("ARG=" + process.argv[2]);\n', + ); + const started = Date.now(); + const result = f.run( + 'exec "$QA_BASH" "$QL_DIR/shell/task.sh" "$@"', + { + QA_BASH: bash, + QL_DIR: f.root, + QL_DATA_DIR: path.join(f.root, 'data'), + ID: '42', + no_delay: 'true', + real_time: 'false', + no_tee: logging === 'file' ? 'true' : 'false', + real_log_path: 'literal [log] $name.log', + PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH, + }, + [...args, literal], + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.ok( + Date.now() - started < 1800, + 'completion must not wait for the two-second watchdog', + ); + const log = fs.readFileSync( + path.join(f.root, 'data/log/literal [log] $name.log'), + 'utf8', + ); + assert.ok(log.includes(`ARG=${literal}\n`), log); + assert.match(log, /AFTER\n/); + assert.match(log, /完成 ✅/); + if (label === 'concurrent') { + assert.match(log, /ACCOUNT=one\n/); + assert.match(log, /ACCOUNT=two\n/); + } + }); + } +} + +test('shared initialization copies configuration and hooks under a directory containing spaces', (t) => { + const f = fixture(t); + f.write('shell/share.sh', source('shell/share.sh')); + for (const name of [ + 'config.sample.sh', + 'task.sample.sh', + 'extra.sample.sh', + 'notify.py', + 'notify.js', + 'ql_sample.js', + 'ql_sample.py', + ]) + f.write( + `sample/${name}`, + name === 'config.sample.sh' + ? 'DefaultCronRule="0 0 * * *"\n' + : '# fixture\n', + ); + const result = f.run( + '. "$QL_DIR/shell/share.sh"; fix_config; import_config; printf "%s" "$default_cron"', + { + QL_DIR: f.root, + QL_DATA_DIR: path.join(f.root, 'custom data'), + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, '0 0 * * *'); + assert.ok( + fs.existsSync(path.join(f.root, 'custom data/config/task_after.sh')), + ); +}); + +test('token generation passes a compiled token filename literally', (t) => { + const f = fixture(t); + f.write('static/build/token.js', 'console.log("fixture-token");\n'); + const result = f.run( + extract('shell/api.sh', 'create_token') + + '\ncreate_token; printf "%s" "$__ql_token__"', + { dir_root: f.root }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'fixture-token'); +}); + +test('script discovery keeps filenames with spaces, glob characters and leading dashes', (t) => { + const f = fixture(t, 'ql-script-list | '); + for (const name of ['-dash name.js', 'probe [1].js', 'sendNotify.js']) + f.write(`scripts/${name}`, "new Env('fixture');\n"); + const result = f.run( + extract('shell/otask.sh', 'gen_array_scripts') + + '\ngen_array_scripts; printf "%s\\n" "${array_scripts[@]}"', + { + dir_scripts: path.join(f.root, 'scripts'), + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.deepEqual(result.stdout.trim().split('\n').sort(), [ + '-dash name.js', + 'probe [1].js', + ]); +}); + +test('token generation passes the TypeScript fallback filename literally', (t) => { + const f = fixture(t); + const result = f.run( + extract('shell/api.sh', 'create_token') + + ` + ts-node-transpile-only(){ [[ "$1" == "$dir_root/back/token.ts" ]] || return 99; printf fallback-token; } + create_token || exit $? + printf '%s' "$__ql_token__" + `, + { dir_root: f.root }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'fallback-token'); +}); + +test('repository cloning preserves a literal destination with and without a branch', (t) => { + const f = fixture(t); + const origin = path.join(f.root, 'origin repo'); + for (const args of [ + ['init', '-b', 'develop', origin], + ['-C', origin, 'config', 'user.name', 'Fixture'], + ['-C', origin, 'config', 'user.email', 'fixture@example.invalid'], + ]) { + const result = spawnSync('git', args, { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + } + fs.writeFileSync(path.join(origin, 'probe.txt'), 'fixture'); + for (const args of [ + ['-C', origin, 'add', '.'], + ['-C', origin, 'commit', '-m', 'fixture'], + ]) { + const result = spawnSync('git', args, { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + } + for (const branch of ['', 'develop']) { + const target = path.join(f.root, branch || 'default clone'); + const result = f.run( + [ + extract('shell/share.sh', 'set_proxy'), + extract('shell/share.sh', 'unset_proxy'), + extract('shell/share.sh', 'git_clone_scripts'), + 't(){ :; }; git_clone_scripts "$QA_ORIGIN" "$QA_TARGET" "$QA_BRANCH"; exit "$exit_status"', + ].join('\n'), + { QA_ORIGIN: origin, QA_TARGET: target, QA_BRANCH: branch }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(path.join(target, 'probe.txt'), 'utf8'), + 'fixture', + ); + } +}); + +for (const condition of [ + 'missing data', + 'invalid data', + 'invalid root', + 'npm only', + 'missing module', + 'unsupported OS', +]) { + test(`native startup reports ${condition} without depending on translation initialization`, (t) => { + const f = fixture(t); + const npmRoot = path.join(f.root, 'npm global'); + fs.mkdirSync(path.join(npmRoot, '@whyour/qinglong'), { recursive: true }); + f.write('bin/npm', '#!/bin/bash\nprintf "%s" "$QA_NPM_ROOT"\n', true); + f.write('bin/id', '#!/bin/bash\nprintf 1000\n', true); + const env = { + PATH: path.join(f.root, 'bin'), + QL_DIR: f.root, + QL_DATA_DIR: path.join(f.root, 'data'), + QA_NPM_ROOT: npmRoot, + QL_OS_TYPE: 'unsupported-fixture', + }; + if (condition === 'missing data') env.QL_DATA_DIR = ''; + if (condition === 'invalid data') env.QL_DATA_DIR = 'relative/data'; + if (condition === 'invalid root') env.QL_DIR = 'relative'; + if (condition === 'npm only' || condition === 'missing module') + env.QL_DIR = ''; + if (condition === 'missing module') + fs.rmdirSync(path.join(npmRoot, '@whyour/qinglong')); + const result = f.run(source('shell/start.sh'), env); + assert.equal(result.status, 1, result.stderr); + assert.doesNotMatch( + result.stderr, + /command not found|No such file|integer expression/, + ); + assert.match( + result.stderr, + condition === 'npm only' + ? /export QL_DIR=/ + : condition === 'missing module' + ? /npm i -g @whyour\/qinglong/ + : condition === 'unsupported OS' + ? /暂不支持此系统/ + : condition === 'invalid root' + ? /QL_DIR/ + : /QL_DATA_DIR/, + ); + }); +} + +test('language loading falls back when global associative arrays are unavailable', (t) => { + const f = fixture(t, 'ql-locale-compat-'); + f.write( + 'shell/lang/en.sh', + 'printf loaded > "$QA_MARKER"\ndeclare -gA LANG_MESSAGES=([probe]=translated)\n', + ); + const result = f.run( + source('shell/share.sh') + + ` + declare(){ if [[ "$1" == -gA ]]; then return 2; fi; builtin declare "$@"; } + import_config + t probe + `, + { + QL_DIR: f.root, + QL_LANG: 'en', + QA_MARKER: path.join(f.root, 'loaded'), + DefaultCronRule: '0 0 * * *', + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'probe\n'); + assert.equal(fs.existsSync(path.join(f.root, 'loaded')), false); +}); + +test( + 'supported Bash keeps translated messages global after configuration loading', + { + skip: + spawnSync(bash, ['-c', 'declare -gA probe'], { stdio: 'ignore' }) + .status !== 0, + }, + (t) => { + const f = fixture(t); + f.write( + 'shell/lang/en.sh', + 'declare -gA LANG_MESSAGES=([probe]=translated)\n', + ); + const result = f.run( + source('shell/share.sh') + '\nimport_config; t probe', + { + QL_DIR: f.root, + QL_LANG: 'en', + DefaultCronRule: '0 0 * * *', + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'translated\n'); + }, +); + +for (const fail of [false, true]) { + test(`raw download ${ + fail + ? 'failure preserves the installed script' + : 'success installs the new script' + } and clears its proxy`, (t) => { + const f = fixture(t); + f.write('raw/probe.js', 'previous-good-version'); + f.write('scripts/raw_probe.js', 'previous-good-version'); + f.write('crontab.list', ''); + const result = f.run( + [ + extract('shell/share.sh', 'set_proxy'), + extract('shell/share.sh', 'unset_proxy'), + extract('shell/update.sh', 'update_raw'), + `t(){ :; } + wget(){ printf '%s' "$QA_CONTENT" > "$4"; return "$QA_STATUS"; } + update_raw 'https://fixture.invalid/probe.js' 'http://fixture.invalid:8080' false false + result=$? + if env | grep -qE '^https?_proxy='; then exit 99; fi + exit "$result"`, + ].join('\n'), + { + dir_raw: path.join(f.root, 'raw'), + dir_scripts: path.join(f.root, 'scripts'), + list_crontab_user: path.join(f.root, 'crontab.list'), + cmd_task: 'task', + uniq_path: 'probe', + QA_CONTENT: fail ? 'partial-download' : 'new-version', + QA_STATUS: fail ? '8' : '0', + }, + ); + assert.equal(result.status, fail ? 8 : 0, result.stderr); + const expected = fail ? 'previous-good-version' : 'new-version'; + assert.equal( + fs.readFileSync(path.join(f.root, 'raw/probe.js'), 'utf8'), + expected, + ); + assert.equal( + fs.readFileSync(path.join(f.root, 'scripts/raw_probe.js'), 'utf8'), + expected, + ); + assert.equal(fs.existsSync(path.join(f.root, 'raw/probe.js.new')), false); + }); +} + +test('ql raw propagates download failure without replacing either installed copy', (t) => { + const f = fixture(t); + for (const name of ['share.sh', 'update.sh', 'env.sh']) + f.write(`shell/${name}`, source(`shell/${name}`)); + f.write('shell/api.sh', ''); + f.write('data/config/config.sh', 'DefaultCronRule="0 0 * * *"\n'); + f.write('data/config/crontab.list', ''); + f.write('data/raw/invalid_probe.js', 'old-version'); + f.write('data/scripts/raw_invalid_probe.js', 'old-version'); + f.write('bin/wget', '#!/bin/bash\nprintf partial > "$4"\nexit 8\n', true); + const result = f.run( + 'exec "$QA_BASH" "$QL_DIR/shell/update.sh" raw https://fixture.invalid/probe.js "" false false', + { + QA_BASH: bash, + QL_DIR: f.root, + QL_DATA_DIR: path.join(f.root, 'data'), + ID: '', + PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH, + }, + ); + assert.equal(result.status, 8, result.stderr); + assert.equal( + fs.readFileSync(path.join(f.root, 'data/raw/invalid_probe.js'), 'utf8'), + 'old-version', + ); + assert.equal( + fs.readFileSync( + path.join(f.root, 'data/scripts/raw_invalid_probe.js'), + 'utf8', + ), + 'old-version', + ); +}); + +const dateTools = [ + { + name: 'host', + command: 'date', + macos: process.platform === 'darwin' ? '1' : '0', + }, +]; +if ( + process.platform === 'darwin' && + spawnSync('gdate', ['--version']).status === 0 +) + dateTools.push({ name: 'GNU', command: 'gdate', macos: '0' }); +for (const dateTool of dateTools) { + test(`${dateTool.name} retention handles literal filenames, active references and empty directories`, (t) => { + const f = fixture(t); + const old = f.write('logs/2020-01-01 two [words].log', 'old'); + const active = f.write('logs/2020-01-01 active & protected.log', 'active'); + f.write('logs/keep/important.txt', 'keep'); + fs.mkdirSync(path.join(f.root, 'logs/empty directory')); + const result = f.run( + ` + t(){ :; } + date(){ command "$QA_DATE" "$@"; } + find_cron_api(){ printf '%s\n' "$1" >> "$QA_QUERIES"; [[ "$1" == *active* ]] && printf active; } + . "$QA_SCRIPT" 7 + `, + { + dir_log: path.join(f.root, 'logs'), + is_macos: dateTool.macos, + QA_DATE: dateTool.command, + QA_QUERIES: path.join(f.root, 'queries'), + QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'), + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(fs.existsSync(old), false); + assert.equal(fs.existsSync(active), true); + assert.equal( + fs.existsSync(path.join(f.root, 'logs/empty directory')), + false, + ); + assert.ok(fs.existsSync(path.join(f.root, 'logs/keep/important.txt'))); + const queries = fs + .readFileSync(path.join(f.root, 'queries'), 'utf8') + .trim() + .split('\n'); + for (const query of queries) + assert.equal( + new URLSearchParams(query).get('log_path').includes('.log'), + true, + ); + assert.ok( + queries.some( + (query) => + new URLSearchParams(query).get('log_path') === + '2020-01-01 active & protected.log', + ), + ); + }); + + test(`${dateTool.name} retention measures a dated log from local midnight`, (t) => { + const f = fixture(t, 'ql-retention-boundary-'); + const file = f.write('logs/2026-01-01.log', 'boundary'); + const result = f.run( + ` + t(){ :; }; find_cron_api(){ :; } + date(){ if [[ "$*" == +%s ]]; then printf 1767830401; else command "$QA_DATE" "$@"; fi; } + . "$QA_SCRIPT" 7 + `, + { + dir_log: path.join(f.root, 'logs'), + is_macos: dateTool.macos, + QA_DATE: dateTool.command, + QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'), + TZ: 'UTC', + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.existsSync(file), + false, + 'seven days plus one second is past retention', + ); + }); +} + +test('log streaming options are accepted by the actual platform tail', (t) => { + const f = fixture(t); + const today = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim(); + f.write(`syslog/${today}.log`, 'ready\n'); + const result = f.run( + extract('shell/update.sh', 'show_service_logs') + + ` + tail(){ printf '%s\\0' "$@"; } + show_service_logs --lines 0 + `, + { dir_data: f.root }, + ); + assert.equal(result.status, 0, result.stderr); + const args = result.stdout.split('\0').slice(0, -1); + const native = spawnSync('tail', args, { + encoding: 'utf8', + timeout: 250, + killSignal: 'SIGKILL', + }); + assert.equal(native.error?.code, 'ETIMEDOUT', native.stderr); + assert.equal(native.stderr, ''); +}); + +for (const version of ['2.23.0', '2.23.0-beta.1+build.2', 'invalid']) { + test(`legacy publishing ${ + version === 'invalid' + ? 'rejects an invalid version before Git changes' + : 'uses the complete version with GNU-compatible tools' + }`, (t) => { + const f = fixture(t); + f.write('version.yaml', `version: ${version}\n`); + f.write( + 'bin/git', + '#!/bin/bash\nprintf "%s\\n" "$*" >> "$QA_GIT_CALLS"\n', + true, + ); + f.write('bin/ts-node-transpile-only', '#!/bin/bash\nexit 0\n', true); + const result = f.run(source('shell/pub.sh'), { + PATH: + path.join(f.root, 'bin') + + path.delimiter + + (process.platform === 'darwin' && + fs.existsSync('/opt/homebrew/opt/grep/libexec/gnubin') + ? '/opt/homebrew/opt/grep/libexec/gnubin:' + : '') + + process.env.PATH, + QA_GIT_CALLS: path.join(f.root, 'git-calls'), + }); + if (version === 'invalid') { + assert.notEqual(result.status, 0); + assert.equal(fs.existsSync(path.join(f.root, 'git-calls')), false); + } else { + assert.equal(result.status, 0, result.stderr); + assert.ok( + fs + .readFileSync(path.join(f.root, 'git-calls'), 'utf8') + .includes(`tag -a v${version} -m release v${version}`), + ); + } + }); +} diff --git a/test/back/task-timeout-processes.test.cjs b/test/back/task-timeout-processes.test.cjs new file mode 100644 index 00000000..3d918063 --- /dev/null +++ b/test/back/task-timeout-processes.test.cjs @@ -0,0 +1,153 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const psTree = require('ps-tree'); +const test = require('node:test'); + +const bash = process.env.QL_TEST_BASH || '/bin/bash'; +const helper = path.resolve(__dirname, '../../shell/task-timeout.sh'); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const tree = (pid) => + new Promise((resolve, reject) => + psTree(pid, (error, children) => + error ? reject(error) : resolve(children), + ), + ); +async function until(predicate, message) { + const deadline = Date.now() + 5000; + while (!(await predicate())) { + assert.ok(Date.now() < deadline, message); + await delay(20); + } +} +function alive(pid) { + try { + process.kill(pid, 0); + if (process.platform === 'linux') { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + if ( + ['Z', 'X', 'x'].includes( + stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0], + ) + ) + return false; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } + } + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw error; + } +} + +for (const mode of ['timeout', 'manual']) { + test( + `${mode} cleans up TERM-resistant children, grandchildren and the watchdog`, + { timeout: 12000, skip: process.platform === 'win32' }, + async (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql-timeout-processes-'), + ); + const known = new Set(); + let child; + t.after(async () => { + if (child) { + for (const p of await tree(child.pid).catch(() => [])) + known.add(Number(p.PID)); + } + for (const pid of known) { + if (pid > 1) { + try { + process.kill(pid, 'SIGKILL'); + } catch {} + } + } + fs.rmSync(root, { recursive: true, force: true }); + }); + fs.writeFileSync( + path.join(root, 'child.sh'), + ` + trap '' TERM + printf '%s' "$$" > "$QA_ROOT/child" + sleep 30 & + printf '%s' "$!" > "$QA_ROOT/grandchild" + wait + `, + ); + child = spawn( + bash, + [ + '-c', + ` + . "$QA_HELPER" + task() { + trap '' TERM + /bin/sh -c 'printf "%s" "$PPID"' > "$QA_ROOT/worker" + "$QA_BASH" "$QA_ROOT/child.sh" & + wait + } + run_task_command task + exit $? + `, + ], + { + detached: true, + env: { + ...process.env, + QA_ROOT: root, + QA_HELPER: helper, + QA_BASH: bash, + command_timeout_time: mode === 'timeout' ? '1s' : '1h', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + known.add(child.pid); + let result, + stderr = ''; + child.stdout.resume(); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.on('exit', (code, signal) => { + result = { code, signal }; + }); + await until( + () => + ['worker', 'child', 'grandchild'].every((name) => { + const file = path.join(root, name); + return ( + fs.existsSync(file) && Number(fs.readFileSync(file, 'utf8')) > 1 + ); + }), + 'task process tree did not initialize', + ); + const descendants = await tree(child.pid); + for (const p of descendants) known.add(Number(p.PID)); + for (const name of ['worker', 'child', 'grandchild']) + known.add(Number(fs.readFileSync(path.join(root, name), 'utf8'))); + assert.ok( + known.size >= 6, + 'must observe the wrapper, watchdog and task descendants', + ); + if (mode === 'manual') { + const worker = Number( + fs.readFileSync(path.join(root, 'worker'), 'utf8'), + ); + const wrapper = descendants.find((p) => Number(p.PID) === worker); + assert.ok(wrapper, 'worker must belong to this test process tree'); + process.kill(Number(wrapper.PPID), 'SIGTERM'); + } + await until(() => result, 'task wrapper did not exit'); + assert.equal(result.code, mode === 'timeout' ? 124 : 143, stderr); + await until( + () => [...known].every((pid) => !alive(pid)), + 'task or watchdog process survived cleanup', + ); + }, + ); +}