Stop the media seed from holding the onboard, and therefore the partnership, open for weeks

A first seed is ~28 TB behind a 12.5 MB/s bwlimit, and it ran inline as Step 9d, so the
phase-2 flag every status reader depends on was written only after it finished.
This commit is contained in:
Gmer4Lfe
2026-08-17 07:40:00 -04:00
parent 9d2610a911
commit ad623353bc
6 changed files with 466 additions and 61 deletions
+131
View File
@@ -0,0 +1,131 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Start and stop the background media seed — Rsync/media_seed.sh, the multi-week first push
// of every DAILY_SYNC_SHARES entry to the partner. Onboard Step 13 dispatches it once; this
// endpoint is how the Partnership tab restarts it after a failure and stops it on demand.
//
// OPERATIONAL MODEL
// The seed used to run inline inside partnership_onboard.sh, which meant the only control a
// person had over ~28 TB of transfer was killing the onboard. It is its own job now, with its
// own record at /var/log/varaverk/Rsync/media_seed.json, and these two actions are its whole
// control surface. Progress is read by include/partnership.php and rendered on the partner's
// card; nothing here reports progress.
//
// DESIGN PRINCIPLES
// Stopping is safe by construction, and that is why a Stop button exists at all.
// rsync.sh runs --inplace --partial, so a stopped seed loses the file in flight, not the
// share, and a later start resumes rather than restarting. Anything else and the button
// would be a trap on a transfer measured in weeks.
//
// The job id is a literal.
// Nothing about which job runs comes from the request, so there is no path, no script
// name and no argument for a caller to influence. This endpoint can start exactly one
// script and signal exactly one recorded pid.
//
// OPERATIONAL SAFEGUARDS
// POST only, for both actions. Unraid's CSRF guard is POST-only and jQuery-injected, so a
// GET here would be both unguarded and, from native fetch(), silently unauthenticated.
// See README-unraid.md.
//
// Stop signals the process group, not the pid.
// run_job.sh is dispatched under setsid, so its pid is its process group leader and
// kill -TERM -<pgid> reaches the rsync and the ssh beneath it. Signalling the pid alone
// would reap the wrapper and leave the transfer running with no record pointing at it.
//
// The recorded pid is verified to still be that job before it is signalled.
// A stale json from a run killed by a reboot can name a pid the kernel has since reused.
// /proc/<pid>/cmdline is checked for the seed script's own path first, so at worst this
// refuses to stop something; it cannot kill an unrelated process.
//
// Starting is delegated, not duplicated.
// run_job.sh already refuses to start a job that is running, and media_seed.sh takes its
// own lock. This endpoint does not re-implement either check — it dispatches and reports
// what the record then says.
//
// REQUEST
// POST action=start dispatch the seed detached
// POST action=stop terminate a running seed
//
// RESPONSE
// {"ok":true,"status":"running"} start: the job record went live within the wait window
// {"ok":true,"stopped":true} stop: the process group was signalled
// {"ok":false,"error":string} wrong method, unknown action, or nothing to act on
//
// DEPENDS ON
// Rsync/media_seed.sh the job itself
// Plugin/unraid/run_job.sh the wrapper that writes the record
// include/config.php SCRIPTS_DIR
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$action = $_POST['action'] ?? '';
$dir = rtrim(SCRIPTS_DIR, '/');
$script = $dir . '/Rsync/media_seed.sh';
$runner = $dir . '/Plugin/unraid/run_job.sh';
$stat = '/var/log/varaverk/Rsync/media_seed.json';
if ($action === 'start') {
if (!is_file($script) || !is_file($runner)) {
echo json_encode(['ok' => false, 'error' => 'media_seed.sh not found on this host']);
exit;
}
$cmd = 'setsid /bin/bash ' . escapeshellarg($runner)
. ' ' . escapeshellarg('Rsync/media_seed.sh')
. ' ' . escapeshellarg($script)
. ' --manual >/dev/null 2>&1 </dev/null &';
shell_exec($cmd);
// Report what the record says, not that the command was issued. run_job.sh writes its
// stat file before running the script, so a live record is the difference between a seed
// that started and one that was refused for already running or died on its gate check.
for ($i = 0; $i < 10; $i++) {
if (is_file($stat)) {
$j = json_decode((string)file_get_contents($stat), true);
if (is_array($j) && ($j['status'] ?? '') === 'running'
&& time() - filemtime($stat) < 60) {
echo json_encode(['ok' => true, 'status' => 'running']);
exit;
}
}
usleep(500000);
}
echo json_encode(['ok' => false, 'error' => 'Seed did not start — check Rsync/media_seed.log']);
exit;
}
if ($action === 'stop') {
if (!is_file($stat)) {
echo json_encode(['ok' => false, 'error' => 'No seed has been run on this host']);
exit;
}
$j = json_decode((string)file_get_contents($stat), true);
$pid = (int)($j['pid'] ?? 0);
if (($j['status'] ?? '') !== 'running' || !$pid || !is_dir("/proc/$pid")) {
echo json_encode(['ok' => false, 'error' => 'Seed is not running']);
exit;
}
// See "The recorded pid is verified" above — cmdline is NUL-separated, so the script path
// is matched against the raw bytes rather than a split.
$cmdline = @file_get_contents("/proc/$pid/cmdline") ?: '';
if (strpos($cmdline, 'media_seed.sh') === false) {
echo json_encode(['ok' => false, 'error' => 'Recorded PID is no longer the seed — record is stale']);
exit;
}
shell_exec('kill -TERM -' . $pid . ' 2>/dev/null');
usleep(400000);
if (is_dir("/proc/$pid")) shell_exec('kill -KILL -' . $pid . ' 2>/dev/null');
echo json_encode(['ok' => true, 'stopped' => true]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
+59
View File
@@ -230,6 +230,64 @@ function vv_pt_remote_system(string $ip, string $sshKey): array {
];
}
// ── Media seed progress ───────────────────────────────────────────────────────
//
// Onboard Step 13 dispatches Rsync/media_seed.sh detached, because a first seed of a full
// media library is a multi-week transfer and used to hold the onboard — and therefore the
// phase-2 flag, and therefore this whole page — open for the duration.
//
// Detaching it means nothing on screen would mention it at all unless something reads its
// job record, which is what this does. The seed is a push from this host to the partner, so
// it is rendered on the partner's card even though every byte of evidence for it is local.
//
// Returns [] when there has never been a seed. A stale "running" record whose pid is gone is
// reported as stopped rather than running: a record is not a process.
function vv_pt_media_seed(): array {
$stat = '/var/log/varaverk/Rsync/media_seed.json';
$log = '/var/log/varaverk/Rsync/media_seed.log';
if (!is_file($stat)) return [];
$j = json_decode((string)file_get_contents($stat), true);
if (!is_array($j)) return [];
$status = (string)($j['status'] ?? '');
$pid = (int)($j['pid'] ?? 0);
if ($status === 'running' && (!$pid || !is_dir("/proc/$pid"))) {
$status = 'stopped';
}
$share = '';
$line = '';
if (is_file($log)) {
// rsync --info=progress2 rewrites one line with \r, so the tail is read as bytes and
// split on both terminators — splitting on \n alone yields a single enormous "line"
// holding every progress repaint of the current file.
$fh = fopen($log, 'r');
if ($fh) {
fseek($fh, 0, SEEK_END);
$size = ftell($fh);
fseek($fh, max(0, $size - 65536));
$tail = (string)fread($fh, 65536);
fclose($fh);
$parts = preg_split('/[\r\n]+/', $tail) ?: [];
for ($i = count($parts) - 1; $i >= 0; $i--) {
$p = trim($parts[$i]);
if ($p === '') continue;
if ($line === '') $line = $p;
if (preg_match('/Seeding:\s*(\S+)/', $p, $m)) { $share = $m[1]; break; }
}
}
}
return array_filter([
'status' => $status,
'start' => isset($j['start']) ? (int)$j['start'] : null,
'end' => isset($j['end']) ? (int)$j['end'] : null,
'share' => $share,
'line' => mb_substr($line, 0, 160),
], fn($v) => $v !== null && $v !== '');
}
// ── Per-node data ─────────────────────────────────────────────────────────────
function vv_pt_nodes(): array {
@@ -362,6 +420,7 @@ function vv_pt_nodes(): array {
'api_key_set' => $apiKeySet,
'api_key_preview' => $apiKeyPreview,
'metrics' => $metrics,
'media_seed' => $isMe ? [] : vv_pt_media_seed(),
];
}
return $nodes;
+49
View File
@@ -379,6 +379,30 @@ function vvPtHostChanged(el) {
document.getElementById('vv-pt-hosts-save').style.display = any ? '' : 'none';
}
// Stop the background media seed. Safe to press: rsync.sh runs --inplace --partial, so this
// costs the file in flight and a later start resumes the share rather than restarting it.
async function vvPtStopSeed(btn) {
if (!await vvConfirm('Stop the media seed?\n\nThe transfer resumes from where it stopped when restarted.')) return;
btn.disabled = true; btn.textContent = '⟳';
try {
const r = await fetch('/plugins/varaverk/api/media_seed.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'stop'
})
});
const d = await r.json();
if (!d.ok) { vvAlert('Stop failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = '■ Stop'; return; }
btn.textContent = '✓';
if (typeof vvPtLoad === 'function') vvPtLoad();
} catch (e) {
btn.disabled = false; btn.textContent = '■ Stop';
vvAlert('Error: ' + e);
}
}
async function vvPtSaveHosts(btn) {
const changedEls = document.querySelectorAll('.vv-pt-host-inp.changed');
if (!changedEls.length) return;
@@ -1123,6 +1147,31 @@ function _renderActions(nodes, cfg) {
style="opacity:.4;font-size:10px;">🗑 Keys</button>
</div>
</div>`;
// Media seed strip. The seed is dispatched detached by onboard Step 13 and runs for
// days, so without this the page would show a finished onboard and no sign that
// terabytes are still moving underneath it.
const ms = remote.media_seed || {};
if (ms.status) {
const seedRunning = ms.status === 'running';
const seedColor = seedRunning ? '#ff9800'
: ms.status === 'ok' ? '#4caf50'
: ms.status === 'stopped' ? '#666' : '#f44336';
const seedLabel = seedRunning ? 'Seeding'
: ms.status === 'ok' ? 'Seed complete'
: ms.status === 'stopped' ? 'Seed stopped' : 'Seed failed';
html += `<div style="margin-top:9px;padding-top:8px;border-top:1px solid #181818;">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;">
<span style="font-size:9px;font-weight:600;color:${seedColor};">${seedLabel}</span>
${ms.share ? `<span style="font-size:9px;color:#444;font-family:monospace;">${vvEscHtml(ms.share)}</span>` : ''}
${seedRunning ? `<button class="vv-pt-action-btn warn" onclick="vvPtStopSeed(this)"
style="font-size:9px;opacity:.55;margin-left:auto;">■ Stop</button>` : ''}
</div>
${ms.line ? `<div style="margin-top:3px;font-size:9px;color:#2e2e2e;font-family:monospace;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"
>${vvEscHtml(ms.line)}</div>` : ''}
</div>`;
}
}
// ── Delete Keys panel ──────────────────────────────────────────────────