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.
This commit is contained in:
Dushnilin
2026-08-08 21:51:51 +03:00
parent b9317a553b
commit feec8c6d35
16 changed files with 383 additions and 75 deletions
+6 -7
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env ucode
let fs = require("fs");
let common = require("core.common");
let helpers = require("core.helpers");
let uci_core = require("core.uci");
let connections = require("config.connections");
@@ -1259,9 +1260,8 @@ function launch_subscription_worker(args) {
for (let arg in args)
push(command_args, arg);
let command = command_env(subscription_worker_env()) + " " +
command_from_args(command_args) +
" >/dev/null 2>&1 1000>&- & echo $!";
let command = common.background_command_with_pid(
command_env(subscription_worker_env()) + " " + command_from_args(command_args));
return trim(command_output("sh -c " + shell_quote(command)));
}
@@ -1761,9 +1761,8 @@ function launch_component_worker(args) {
for (let arg in args)
push(command_args, arg);
let command = command_env(component_worker_env()) + " " +
command_from_args(command_args) +
" >/dev/null 2>&1 1000>&- & echo $!";
let command = common.background_command_with_pid(
command_env(component_worker_env()) + " " + command_from_args(command_args));
return trim(command_output("sh -c " + shell_quote(command)));
}
@@ -2345,7 +2344,7 @@ function import_builtin_subnets_from_rule(section, settings) {
write_file(cached_file, join("\n", combined_lines) + "\n");
ensure_dir("/etc/tachyon/rulesets");
write_file(persistent_file, join("\n", combined_lines) + "\n");
system("/etc/init.d/tachyon reload </dev/null >/dev/null 2>&1 &");
system(common.background_command("/etc/init.d/tachyon reload"));
}
}