Writing down what each endpoint actually guarantees made the places it didn't obvious — shell arguments reaching a crontab or a bash -c unescaped, master.conf written without tmp+rename, and conf edits that could be saved without ever being parsed.
177 lines
7.8 KiB
PHP
177 lines
7.8 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Job killer. Terminates a running job, clears the lock files it left behind, and corrects
|
|
// its stat file — the stop button on the scheduler page.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Escalating termination, then cleanup. SIGTERM to the whole process group, up to three
|
|
// seconds to exit, SIGKILL if it did not. The group is the target rather than the pid
|
|
// because a Varaverk script is mostly other processes — rsync, ssh, curl, docker — and
|
|
// killing only the parent would orphan every one of them still holding the resources the
|
|
// next run needs.
|
|
//
|
|
// The stat file is the job's own record, and a killed job never gets to correct it. This
|
|
// endpoint does that on its behalf, which is what stops the scheduler page from showing a
|
|
// spinner for a job that no longer exists.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Kills the group, falls back to the tree.
|
|
// `ps -o pgid=` resolves the process group; when that fails the fallback is pkill -P
|
|
// plus the pid itself. Two strategies, because a job whose runner already exited can
|
|
// leave children whose group id is no longer discoverable from the recorded pid.
|
|
//
|
|
// Waits before escalating.
|
|
// Six 500ms checks between TERM and KILL. Scripts have cleanup handlers — releasing
|
|
// locks, finishing a write, unmounting — and killing immediately would skip exactly the
|
|
// work that makes the next run safe.
|
|
//
|
|
// Reports honestly when the kill failed.
|
|
// ok mirrors whether the process is actually gone. A process in uninterruptible sleep
|
|
// survives SIGKILL, and the response says so rather than claiming success and leaving
|
|
// the user to discover the job is still running.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// The job id is validated, and the pid is an integer before it reaches a shell.
|
|
// ^[a-zA-Z0-9_./\-]+\.sh$ with an explicit '..' check on the id, and (int) casts on
|
|
// every pid — the recorded one and each lock's owner — before interpolation. The signal
|
|
// commands are the only shell in this file and none of them can carry anything but a
|
|
// number.
|
|
//
|
|
// pid < 2 is refused.
|
|
// A zero, missing, or malformed pid in the stat file would make `kill -TERM -0` signal
|
|
// the caller's own process group — the web server. Rejecting anything below 2 also
|
|
// excludes init.
|
|
//
|
|
// A job that is not running is a no-op success.
|
|
// Both the missing-stat-file and status-not-running paths exit before any signal is
|
|
// sent, so pressing stop twice cannot kill an unrelated process that has since been
|
|
// assigned the recorded pid.
|
|
//
|
|
// The pid is only cleared from the stat file if the process is confirmed gone.
|
|
// D-state processes survive SIGKILL. Clearing the pid there would lose the only handle
|
|
// anyone has on a process that is still holding locks, and would report the job stopped
|
|
// while it continues to run.
|
|
//
|
|
// Lock clearing is bounded to the lock directory and to dead owners.
|
|
// glob over /tmp/unraid_locks/*.lock — a hardcoded literal, not a config value — and a
|
|
// lock is removed only when its recorded owner is this pid or is no longer in /proc. A
|
|
// lock held by a live, unrelated process is never touched.
|
|
//
|
|
// Deliberate side effect: stale locks from other jobs are reaped too.
|
|
// The dead-owner test is not scoped to this job, so one stop clears every abandoned
|
|
// lock on the host. That is intentional — a lock whose owner does not exist is by
|
|
// definition stale, and leaving it to be found later means a future run refuses to
|
|
// start for no reason.
|
|
//
|
|
// The name-based fallback covers pid rotation.
|
|
// A lock file named after the script is removed regardless of its recorded owner,
|
|
// because a rotated pid can make a genuinely stale lock look live.
|
|
//
|
|
// REQUEST
|
|
// POST id=<Category/name.sh>
|
|
//
|
|
// RESPONSE
|
|
// {"ok":true,"killed":true,"locks":["…"],"error":null}
|
|
// {"ok":true,"msg":"Not running"}
|
|
// {"ok":false,"killed":false,"locks":[…],
|
|
// "error":"Process still alive after SIGKILL (D-state) — lock may persist"}
|
|
// {"ok":false,"error":"Invalid id"|"No stat file — script may not be running"
|
|
// |"No valid PID in stat file"}
|
|
//
|
|
// DEPENDS ON
|
|
// include/scheduler.php vv_job_stat_path()
|
|
// /tmp/unraid_locks lock files written by common.sh's locking helper
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
header('Content-Type: application/json');
|
|
require_once dirname(__DIR__) . '/include/scheduler.php';
|
|
|
|
$id = trim($_POST['id'] ?? '');
|
|
|
|
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
|
|
exit;
|
|
}
|
|
|
|
$statFile = vv_job_stat_path($id);
|
|
if (!file_exists($statFile)) {
|
|
echo json_encode(['ok' => false, 'error' => 'No stat file — script may not be running']);
|
|
exit;
|
|
}
|
|
|
|
$stat = json_decode(file_get_contents($statFile) ?: '{}', true) ?: [];
|
|
|
|
if (($stat['status'] ?? '') !== 'running') {
|
|
echo json_encode(['ok' => true, 'msg' => 'Not running']);
|
|
exit;
|
|
}
|
|
|
|
$pid = (int)($stat['pid'] ?? 0);
|
|
if ($pid < 2) {
|
|
echo json_encode(['ok' => false, 'error' => 'No valid PID in stat file']);
|
|
exit;
|
|
}
|
|
|
|
// Kill the whole process group so the script and all its children die together.
|
|
// pgid is usually the same as the session leader PID from run_job.sh.
|
|
$pgid = (int)trim(shell_exec("ps -o pgid= -p $pid 2>/dev/null") ?: '0');
|
|
|
|
if ($pgid > 1) {
|
|
shell_exec("kill -TERM -$pgid 2>/dev/null");
|
|
} else {
|
|
// Fallback: kill the direct PID and its children
|
|
shell_exec("pkill -TERM -P $pid 2>/dev/null");
|
|
shell_exec("kill -TERM $pid 2>/dev/null");
|
|
}
|
|
|
|
// Give it up to 3s to exit gracefully
|
|
$dead = false;
|
|
for ($i = 0; $i < 6; $i++) {
|
|
usleep(500000);
|
|
if (!file_exists("/proc/$pid")) { $dead = true; break; }
|
|
}
|
|
|
|
// Force-kill if still alive
|
|
if (!$dead) {
|
|
if ($pgid > 1) shell_exec("kill -KILL -$pgid 2>/dev/null");
|
|
shell_exec("pkill -KILL -P $pid 2>/dev/null");
|
|
shell_exec("kill -KILL $pid 2>/dev/null");
|
|
usleep(300000);
|
|
$dead = !file_exists("/proc/$pid");
|
|
}
|
|
|
|
// Clear any lock files in /tmp/unraid_locks whose content matches this PID
|
|
$lockDir = '/tmp/unraid_locks';
|
|
$cleared = [];
|
|
foreach (glob("$lockDir/*.lock") ?: [] as $lf) {
|
|
$content = trim(file_get_contents($lf) ?: '');
|
|
$lockPid = (int)explode(':', $content)[0];
|
|
if ($lockPid === $pid || !file_exists("/proc/$lockPid")) {
|
|
@unlink($lf);
|
|
$cleared[] = basename($lf);
|
|
}
|
|
}
|
|
|
|
// Also clear by script name in case PID rotated
|
|
$scriptBase = basename($id, '.sh');
|
|
$namedLock = "$lockDir/{$scriptBase}.lock";
|
|
if (file_exists($namedLock)) {
|
|
@unlink($namedLock);
|
|
if (!in_array(basename($namedLock), $cleared)) $cleared[] = basename($namedLock);
|
|
}
|
|
|
|
// Update stat file — only clear pid if actually dead (D-state processes survive SIGKILL)
|
|
$now = time();
|
|
$stat['status'] = $dead ? 'stopped' : 'running';
|
|
$stat['end'] = $dead ? $now : ($stat['end'] ?? null);
|
|
$stat['exit'] = $dead ? -1 : ($stat['exit'] ?? null);
|
|
if ($dead) unset($stat['pid']);
|
|
file_put_contents($statFile, json_encode($stat));
|
|
|
|
echo json_encode([
|
|
'ok' => $dead,
|
|
'killed' => $dead,
|
|
'locks' => $cleared,
|
|
'error' => $dead ? null : 'Process still alive after SIGKILL (D-state) — lock may persist',
|
|
]);
|