Files
NeoTachyon/tests/service_start_trap.sh
T
Dushnilin feec8c6d35 fix(spawn): закрывать дескрипторы фоновых процессов вместо фиктивного 1000<&-
ucode держит по одному открытому дескриптору на каждый require()-модуль всё
время жизни интерпретатора и не даёт ни закрыть их, ни пометить close-on-exec,
поэтому каждый фоновый процесс наследует весь набор. На роутере watchdog несёт
около 90 таких дескрипторов и передаёт их детям: logread, sh и nfqws2 найдены
держащими дескрипторы на собственных .uc-файлах Tachyon, часть уже удалена
обновлением пакета — старый инод прибит к диску процессом, которому он не нужен.

Против этого код писал литерал 1000<&- в 22 местах 13 модулей. Он не закрывал
ничего: дескриптор 1000 никогда не открывается, а все настоящие лежат в 6..89.
Хуже того, этого литерала требовал tests/service_start_trap.sh — именно поэтому
он и разошёлся по кодовой базе. Проверка пинила видимость защиты и продолжала бы
проходить вечно при живой утечке.

Замена оказалась не такой прямой, как выглядит. Счёт от 3 до потолка не годится:
в dash закрытие дескриптора 10 и выше фатально даже для номера, который никогда
не открывался — dash перемещает туда собственную служебную бухгалтерию, и
перенаправление убивает шелл целиком, а 2>/dev/null прячет сообщение, но не
смерть. Перечисление /proc/self/fd трогает только реально открытое, но сами
высокие дескрипторы никуда не деваются: набор модулей любого размера уводит их
за 10. Поэтому пролог сначала спрашивает шелл в подоболочке, которая поглощает
смерть, и опускает собственный потолок, если ответ отрицательный. busybox ash,
на котором работает OpenWrt, отвечает утвердительно и закрывает весь набор.

Три хелпера в core/common.uc задают форму фонового запуска — раньше каждое место
собирало перенаправления вручную, и они разошлись. Форма с пидом ставит exec
внутрь подоболочки, чтобы $! называл сам демон, а не обёртку, которая тут же
завершается; ведущие присваивания VAR=x при этом выносятся перед exec, иначе
шелл ищет программу с именем VAR=x. Для конвейеров, которые exec заменить не
может, есть отдельный хелпер. initd.uc и state.uc лежат на пути раннего старта и
несут собственные копии, как уже несут shell_quote и command_from_args.

Попутно вскрылись два настоящих дефекта. В лестнице эскалации watchdog строка
читалась как restart & rm -f lock &, то есть лок снимался параллельно с
рестартом, который он охраняет, и вторая эскалация могла начаться посреди
первой. В пакетном прогреве Zero-RTT знак & стоял внутри строки, поэтому
перенаправления после него цеплялись к пустой команде в родительском шелле,
а не к фоновому циклу.

Ещё четыре места запускали фон вообще мимо механизма; одно из них аудит
пропустил, его нашёл новый тест.

tests/background_spawn_fds.sh считает дескрипторы у ребёнка при обоих способах
запуска: структурная проверка такое показать не может, строку надо выполнить.
На роутере старый способ даёт ребёнку 7 дескрипторов, новый — 3.
2026-08-08 21:51:51 +03:00

156 lines
8.1 KiB
Bash

#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon"
TACHYON_INIT="$ROOT_DIR/tachyon/files/etc/init.d/tachyon"
CLI_UC="$TACHYON_BIN"
LIFECYCLE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/lifecycle.uc"
INITD_UC="$ROOT_DIR/tachyon/files/usr/lib/service/initd.uc"
STATE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/state.uc"
UI_UC="$ROOT_DIR/tachyon/files/usr/lib/service/ui.uc"
UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc"
SUBSCRIPTION_CACHE_UC="$ROOT_DIR/tachyon/files/usr/lib/subscription/cache.uc"
NFQUEUE_RUNTIME_UC="$ROOT_DIR/tachyon/files/usr/lib/providers/nfqueue/runtime.uc"
BYEDPI_RUNTIME_UC="$ROOT_DIR/tachyon/files/usr/lib/providers/byedpi/runtime.uc"
PRIORITY_UC="$ROOT_DIR/tachyon/files/usr/lib/singbox/priority.uc"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
require_pattern() {
local pattern="$1"
local label="$2"
grep -Fq "$pattern" "$LIFECYCLE_UC" || fail "$label"
}
reject_pattern() {
local pattern="$1"
local label="$2"
! grep -Fq "$pattern" "$TACHYON_BIN" || fail "$label"
}
require_file_pattern() {
local file="$1"
local pattern="$2"
local label="$3"
grep -Fq "$pattern" "$file" || fail "$label"
}
[ -r "$TACHYON_BIN" ] || fail "tachyon binary source is missing"
[ -r "$TACHYON_BIN" ] || fail "tachyon entrypoint is missing"
[ -r "$LIFECYCLE_UC" ] || fail "service/lifecycle.uc is missing"
grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" ||
fail "tachyon entrypoint must be a direct ucode executable"
grep -Fq 'service/lifecycle.uc' "$CLI_UC" ||
fail "service/cli.uc must dispatch startup through service/lifecycle.uc"
reject_pattern "trap " \
"tachyon shell entrypoint must not own startup fail-safe traps"
reject_pattern "clear_startup_failsafe_trap" \
"tachyon shell entrypoint must not keep old trap helper"
require_pattern 'module_background(UPDATES_UC, [ "list-update" ])' \
"startup list_update background job must be owned by service/lifecycle.uc"
require_pattern 'module_background(DIAGNOSTICS_UC, [ "get-system-info" ])' \
"startup system-info background job must be owned by service/lifecycle.uc"
require_pattern 'startup_config_fingerprint = external_config_fingerprint();' \
"startup must snapshot runtime-relevant config after validation"
require_pattern 'mark_pending_reload_if_config_changed(startup_config_fingerprint, "config_changed_during_start")' \
"startup must queue reload when config changes while service is starting"
awk '
/function restart_runtime_for_reload\(\)/ { in_restart = 1 }
in_restart && /status = start_impl\(\);/ { saw_start_impl = 1 }
in_restart && /dnsmasq_configure\(true\)/ { duplicated_dns = 1 }
in_restart && /write-current-reload-state-clean/ { duplicated_state = 1 }
in_restart && /DNS_FAILOVER_UC.*start-runtime/ { duplicated_failover = 1 }
in_restart && /^}/ { done = 1; exit }
END { exit done && saw_start_impl && !duplicated_dns && !duplicated_state && !duplicated_failover ? 0 : 1 }
' "$LIFECYCLE_UC" ||
fail "reload fallback restart must reuse the complete start_impl path without duplicating finalization"
require_pattern 'function abort_reload(status, runtime_changed)' \
"reload failures must share one cleanup decision owner"
require_pattern 'return abort_reload(status, true);' \
"reload failures after runtime mutation must clean the partial runtime"
cleanup_function_line="$(awk '/function cleanup_failed_runtime\(\)/ { print NR; exit }' "$LIFECYCLE_UC")"
abort_function_line="$(awk '/function abort_reload\(status, runtime_changed\)/ { print NR; exit }' "$LIFECYCLE_UC")"
[ -n "$cleanup_function_line" ] && [ -n "$abort_function_line" ] &&
[ "$cleanup_function_line" -lt "$abort_function_line" ] ||
fail "cleanup_failed_runtime must be declared before abort_reload for ucode runtime calls"
require_pattern 'pending_reload_log_context(reason)' \
"startup queued reload must be visible in logs"
require_pattern 'return "current reload";' \
"reload-time queued reload must be distinguishable in logs"
# Background spawns must route through the shared helpers, which close the
# descriptors the child would otherwise inherit — the procd lock among them.
#
# These assertions used to require a literal `1000>&-` in each of these files,
# which is why that string spread to 22 sites in 13 modules. It closed nothing:
# no descriptor 1000 is ever opened, and every real one falls in 6..89. The test
# pinned the appearance of the fix and would have gone on passing forever with
# the leak intact. What matters is that each site delegates to a helper that
# closes what is actually open; tests/background_spawn_fds.sh proves the helper
# does that at runtime.
require_file_pattern "$LIFECYCLE_UC" 'common.background_command(module_command(module_path, args))' \
"service/lifecycle.uc background modules must close inherited descriptors via the shared helper"
require_file_pattern "$INITD_UC" 'background_command(shell_quote(init_script) + " reload pending")' \
"service/initd.uc pending reload worker must close inherited descriptors"
require_file_pattern "$STATE_UC" 'background_command(shell_quote(init_script) + " reload pending")' \
"service/state.uc pending reload worker must close inherited descriptors"
for background_owner in "$UI_UC" "$UPDATES_UC" "$SUBSCRIPTION_CACHE_UC" \
"$NFQUEUE_RUNTIME_UC" "$BYEDPI_RUNTIME_UC" "$PRIORITY_UC"; do
require_file_pattern "$background_owner" 'common.background_command_with_pid(' \
"$(basename "$background_owner") workers must spawn through background_command_with_pid"
done
# initd.uc and state.uc sit on the early-boot path and carry their own copies of
# the helper rather than pulling in core.common, so their prologue is checked
# separately. Both copies and the shared one must ask the shell what it can close
# before closing anything — see core/common.uc for why a fixed range is fatal.
for prologue_owner in "$ROOT_DIR/tachyon/files/usr/lib/core/common.uc" "$INITD_UC" "$STATE_UC"; do
require_file_pattern "$prologue_owner" 'eval \"exec 10<&-\"' \
"$(basename "$prologue_owner") descriptor prologue must probe the shell before closing high descriptors"
done
require_file_pattern "$TACHYON_INIT" 'TACHYON_LAST_START_STATUS="$?"' \
"init.d start_service must preserve backend start status for rc.common"
require_file_pattern "$TACHYON_INIT" 'service_started()' \
"init.d must return preserved start status through rc.common service_started hook"
awk '
/function cleanup_failed_runtime\(\)/ { in_cleanup = 1 }
in_cleanup && /stop_main\(\);/ { saw_stop = 1 }
in_cleanup && /dnsmasq_restore_fail_safe\(\);/ && saw_stop { saw_dns = 1 }
in_cleanup && /mark_runtime_stopped_clean\(\);/ && saw_dns { ok = 1; exit }
in_cleanup && /^}/ { exit }
END { exit ok ? 0 : 1 }
' "$LIFECYCLE_UC" ||
fail "failed runtime cleanup must stop partial runtime, roll back DNS, and mark clean shutdown"
grep -Fq 'cleanup_failed_runtime();' "$LIFECYCLE_UC" ||
fail "service failure paths must use failed runtime cleanup"
require_pattern "tproxy-marking-rule4-present" \
"service stop must use direct ucode IPv4 tproxy marking rule check"
require_pattern "tproxy-marking-rule6-present" \
"service stop must use direct ucode IPv6 tproxy marking rule check"
require_pattern "tproxy-route4-present" \
"service stop must use direct ucode IPv4 tproxy route check"
require_pattern "tproxy-route6-present" \
"service stop must use direct ucode IPv6 tproxy route check"
grep -Fq 'require("core.uci")' "$LIFECYCLE_UC" ||
fail "service/lifecycle.uc must use core.uci for lifecycle UCI state"
if grep -n -E 'uci -q|require\("uci"\)\.cursor|function uci_|uci_set\(' "$LIFECYCLE_UC" >/dev/null 2>&1; then
fail "service/lifecycle.uc must not own direct UCI CLI/cursor helpers"
fi
reject_pattern "tproxy_marking_rule_present" \
"service stop must not call the removed shell tproxy marking helper"
reject_pattern "tproxy_route_present" \
"service stop must not call the removed shell tproxy route helper"
reject_pattern "has_tproxy_marking_rule" \
"service stop must not call the removed shell tproxy marking helper"
reject_pattern "has_tproxy_route" \
"service stop must not call the removed shell tproxy route helper"
printf 'service start trap checks passed\n'