fix(updates): не выдавать успешное обновление за ошибку "Failed to execute"

Воркер компонентного действия перенаправлял stderr в тот же файл, что и
stdout (2>&1). Действие печатает результат в stdout одним JSON-объектом,
а stderr — предупреждения apk/opkg, вывод init-скрипта при рестарте —
попадал в тот же файл после JSON. Разбор всего файла падал, построчный
фолбэк не находил объекта, и finish_component_job писал синтетический
success: false с последней строкой лога в качестве сообщения.

Особенно надёжно это ломало самообновление Tachyon: install_tachyon
рестартует сервис, будучи предком воркера, поэтому вывод рестарта
гарантированно ложился поверх канала результата.

Теперь stderr пишется в отдельный <job>.out.stderr, он же используется
как источник сообщения, когда воркер действительно умер, не напечатав
результат. Файл убирается вместе с заданием и подметается orphan-очисткой.

Заодно валидация sing-box перестала судить о бинарнике по коду возврата:
popen().close() отдаёт сырой wait-статус, а command_output отбрасывал
stdout при любом ненулевом статусе. `sing-box version` печатает версию и
может завершиться ненулевым кодом или получить сигнал от остановки
сервиса перед установкой — валидация получала "", объявляла рабочий
пакет битым и откатывала установку с exit_code 129 (128+SIGHUP). Добавлен
command_output_lenient, он же чинит показ версии в дашборде.
This commit is contained in:
Dushnilin
2026-08-10 05:21:19 +03:00
parent dce098ff7c
commit 6cf90e3e84
4 changed files with 193 additions and 5 deletions
+19 -1
View File
@@ -86,6 +86,19 @@ function command_output(command) {
return as_string(data);
}
// Like command_output but keeps stdout regardless of the exit status. popen()'s
// close() yields a raw wait status, so a command that writes a perfectly good
// answer and then exits non-zero - or is signalled - loses all of it above.
function command_output_lenient(command) {
let pipe = fs.popen(command, "r");
if (!pipe)
return "";
let data = pipe.read("all");
pipe.close();
return data != null ? as_string(data) : "";
}
function command_output_from_args(args) {
return command_output(command_from_args(args));
}
@@ -1328,11 +1341,16 @@ function read_sing_box_binary_version(binary, library_dir) {
if (binary == "" || !file_exists(binary))
return "";
// stderr is dropped but stdout is kept whatever the exit status: sing-box
// prints "sing-box version X" and can still exit non-zero (or be signalled by
// the service stop that precedes the install). Judging the binary by its exit
// code made every install report "failed validation" and roll a working
// sing-box back - the version string itself is the only reliable signal.
let command = command_from_args([ binary, "version" ]);
if (as_string(library_dir || "") != "")
command = command_env({ LD_LIBRARY_PATH: as_string(library_dir) }) + " " + command;
return trim(helper_output_input(command_output(command), "stdin-first-line-last-field", []));
return trim(helper_output_input(command_output_lenient("(" + command + ") 2>/dev/null"), "stdin-first-line-last-field", []));
}
function validate_sing_box_extended_binary(binary, library_dir) {
+23 -3
View File
@@ -1641,6 +1641,7 @@ function component_cleanup_jobs() {
let state_file = component_job_state_path_value(path_basename_without_suffix(output_path, ".out"));
if (state_file == "" || fs.stat(state_file) == null || !component_job_running_is(state_file, true)) {
remove_file(output_path);
remove_file(output_path + ".stderr");
remove_file(output_path + ".json");
}
}
@@ -1653,6 +1654,14 @@ function component_cleanup_jobs() {
"-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES),
"-delete"
]);
command_success_from_args([
"find",
COMPONENT_JOB_DIR,
"-type", "f",
"-name", "*.out.stderr",
"-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES),
"-delete"
]);
command_success_from_args([
"find",
COMPONENT_JOB_DIR,
@@ -1764,11 +1773,17 @@ function finish_component_job(path, component, action, exit_code, output_file) {
ok = write_state_file(path, value);
}
else {
let raw_output = file_last_nonblank_line_value(output_file, "Failed to execute", 240);
// No JSON at all means the worker really did die before printing its
// result. Its stderr, kept out of the result channel above, holds the
// reason; fall back to the result file only if stderr is empty.
let raw_output = file_last_nonblank_line_value(output_file + ".stderr", "", 240);
if (raw_output == "")
raw_output = file_last_nonblank_line_value(output_file, "Failed to execute", 240);
ok = write_state_file(path, component_fallback_job_state(component, action, raw_output, exit_code, updated_at));
}
remove_file(output_file);
remove_file(output_file + ".stderr");
remove_file(output_file + ".json");
return ok;
}
@@ -1799,6 +1814,11 @@ function launch_component_worker(args) {
function component_action_worker(state_file, output_file, component, action) {
component = normalize_component_name(component);
// stderr gets its own file instead of being merged into the result channel.
// Merging put apk/opkg warnings and init-script chatter into the same file as
// the JSON result - the whole-file parse then failed, the line scan found no
// object, and a successful action was reported to the UI as "Failed to
// execute" with the tail of a log as its message.
let command = command_env(component_worker_env()) + " " +
command_from_args([
"ucode",
@@ -1807,7 +1827,7 @@ function component_action_worker(state_file, output_file, component, action) {
"component-action",
as_string(component),
as_string(action)
]) + " >" + shell_quote(output_file) + " 2>&1";
]) + " >" + shell_quote(output_file) + " 2>" + shell_quote(output_file + ".stderr");
let status = command_status(command);
finish_component_job(state_file, component, action, status, output_file);
@@ -1896,7 +1916,7 @@ function run_automatic_component_update_check(component) {
"component-action",
as_string(component),
"check_update"
]) + " >" + shell_quote(output_file) + " 2>&1";
]) + " >" + shell_quote(output_file) + " 2>/dev/null";
let status = command_status(command);
let value = output_json_object(output_file);
remove_file(output_file);
+18 -1
View File
@@ -77,6 +77,19 @@ function command_output_from_args(args) {
return command_output(command_from_args(args));
}
// Like command_output but keeps stdout regardless of the exit status. popen()'s
// close() yields a raw wait status, so a command that prints a good answer and
// then exits non-zero - or is signalled - loses all of it above.
function command_output_lenient(command) {
let pipe = fs.popen(command, "r");
if (!pipe)
return "";
let data = pipe.read("all");
pipe.close();
return data != null ? as_string(data) : "";
}
function command_status(command) {
let status = int(system(command));
if (status == -1)
@@ -207,8 +220,12 @@ function first_line_last_field(value) {
return length(fields) > 0 ? as_string(fields[length(fields) - 1]) : "";
}
// The version string is taken from stdout whatever the exit status: sing-box
// prints "sing-box version X" first and may still exit non-zero (a warning about
// a missing config, a signal from a concurrent service stop). Gating on the exit
// code made the dashboard fall back to "unknown" for a perfectly working binary.
function sing_box_version_output() {
return command_exists("sing-box") ? command_output_from_args([ "sing-box", "version" ]) : "";
return command_exists("sing-box") ? command_output_lenient(command_from_args([ "sing-box", "version" ]) + " 2>/dev/null") : "";
}
function sing_box_marker_is(value) {