fix(updates): resolve component update hang and multiple page reload loop
- action.uc: sweep stuck flock 1000 processes before tachyon restart, bound service restart with 25s timeout, and prevent worker lockup - shell/index.ts: add 15-minute general hard timeout for non-Tachyon components to prevent infinite modal spin; allow same-version confirmation for both install and reinstall - package.uc: remove disruptive uhttpd restart from luci_postinst to prevent active socket drops - ui.uc: refresh stale PID state before checking value.running in action_ack so dead jobs can always be acknowledged - initController.ts & sessionJobs.ts: persist handled job IDs across reloads in sessionStorage, eliminate redundant modal popups on page reload, and guard against concurrent reloads with safeReloadPage - renderUpdateProgressModal.ts: record active job before reload and poll readiness with fast health check instead of blind 5s freeze
This commit is contained in:
@@ -17,6 +17,7 @@ const COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS = 2000;
|
||||
const COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS = 30000;
|
||||
const COMPONENT_ACTION_MIN_ELAPSED_FOR_SELF_UPDATE_MS = 2000;
|
||||
const COMPONENT_ACTION_SELF_UPDATE_HARD_TIMEOUT_MS = 120000;
|
||||
const COMPONENT_ACTION_GENERAL_HARD_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const COMPONENT_ACTION_STATE_DIR = '/var/run/tachyon/component-actions';
|
||||
const GET_UI_STATE_RPC_TIMEOUT_MS = 3000;
|
||||
|
||||
@@ -736,16 +737,16 @@ export const TachyonShellMethods = {
|
||||
// A reinstall of the same version never changes the version string, so
|
||||
// version-based confirmation can never match. Equality with the baseline
|
||||
// is the confirmation — but only once the job is no longer running and only
|
||||
// for explicit reinstall actions.
|
||||
// when target version matches baseline (or is unspecified).
|
||||
const confirmedSameVersionReinstall = async () => {
|
||||
if (!isSelfUpdate || action !== 'reinstall' || !baselineVersion) {
|
||||
if (!isSelfUpdate || !baselineVersion) {
|
||||
return '';
|
||||
}
|
||||
if (targetVersion && !versionsMatch(targetVersion, baselineVersion)) {
|
||||
return '';
|
||||
}
|
||||
const version = await readTachyonVersion();
|
||||
if (versionsMatch(version, baselineVersion)) {
|
||||
if (version && versionsMatch(version, baselineVersion)) {
|
||||
return version;
|
||||
}
|
||||
return '';
|
||||
@@ -769,7 +770,7 @@ export const TachyonShellMethods = {
|
||||
if ((await confirmedByVersion()) === version) {
|
||||
return true;
|
||||
}
|
||||
} else if (action === 'reinstall') {
|
||||
} else {
|
||||
if ((await confirmedSameVersionReinstall()) === version) {
|
||||
return true;
|
||||
}
|
||||
@@ -825,13 +826,22 @@ export const TachyonShellMethods = {
|
||||
return selfUpdateResult(version || baselineVersion);
|
||||
}
|
||||
|
||||
if (
|
||||
!isSelfUpdate &&
|
||||
Date.now() - jobStartedAt >= COMPONENT_ACTION_GENERAL_HARD_TIMEOUT_MS
|
||||
) {
|
||||
if (stateResponse && !stateResponse.running) {
|
||||
return jobDoneResult(stateResponse);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: _('Component action timed out'),
|
||||
} as Tachyon.MethodFailureResponse;
|
||||
}
|
||||
|
||||
// The job is over according to the state file.
|
||||
if (stateResponse && !stateResponse.running) {
|
||||
if (
|
||||
isSelfUpdate &&
|
||||
stateResponse.success === false &&
|
||||
(isDifferentVersion || action === 'reinstall')
|
||||
) {
|
||||
if (isSelfUpdate && stateResponse.success === false) {
|
||||
const version =
|
||||
(await confirmedByVersion()) ||
|
||||
(await confirmedSameVersionReinstall());
|
||||
@@ -894,11 +904,7 @@ export const TachyonShellMethods = {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isSelfUpdate &&
|
||||
parsedResponse.success === false &&
|
||||
(isDifferentVersion || action === 'reinstall')
|
||||
) {
|
||||
if (isSelfUpdate && parsedResponse.success === false) {
|
||||
const version =
|
||||
(await confirmedByVersion()) ||
|
||||
(await confirmedSameVersionReinstall());
|
||||
|
||||
@@ -626,6 +626,67 @@ describe('TachyonShellMethods.componentAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('completes a same-version Tachyon install as success when target version matches baseline and job is stale', async () => {
|
||||
mocks.fsRead.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
running: false,
|
||||
component: 'tachyon',
|
||||
action: 'install',
|
||||
message:
|
||||
'Component action job is stale or the worker process exited unexpectedly',
|
||||
job_id: 'job-1',
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'component_action_status') {
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unknown command',
|
||||
code: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (args[0] === 'show_version') {
|
||||
return Promise.resolve({
|
||||
stdout: '1.2.77\n',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise = TachyonShellMethods.waitComponentActionJob(
|
||||
'job-1',
|
||||
'tachyon',
|
||||
'install',
|
||||
'1.2.77',
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
component: 'tachyon',
|
||||
action: 'install',
|
||||
message: 'Tachyon has been installed',
|
||||
current_version: '1.2.77',
|
||||
latest_version: '1.2.77',
|
||||
changed: true,
|
||||
status: 'latest',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the modal with success when a self-update never confirms and the hard timeout is reached', async () => {
|
||||
mocks.fsRead.mockRejectedValue(new Error('Access denied'));
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
|
||||
@@ -49,6 +49,11 @@ import {
|
||||
setActiveProgressModalJobId,
|
||||
showUpdateProgressModal,
|
||||
} from './partials/renderUpdateProgressModal';
|
||||
import {
|
||||
loadHandledJobsFromSession,
|
||||
safeReloadPage,
|
||||
saveHandledJobToSession,
|
||||
} from './sessionJobs';
|
||||
|
||||
function getComponentCardTitle(component: Tachyon.ComponentName): string {
|
||||
switch (component) {
|
||||
@@ -128,18 +133,11 @@ let componentActionStateRefreshPromise: Promise<void> | null = null;
|
||||
const followedComponentJobs = new Set<string>();
|
||||
const handledComponentJobs = new Set<string>();
|
||||
|
||||
try {
|
||||
const savedJob = sessionStorage.getItem('tachyon_post_update_job');
|
||||
if (savedJob) {
|
||||
for (const savedJob of loadHandledJobsFromSession()) {
|
||||
handledComponentJobs.add(savedJob);
|
||||
capSetSize(handledComponentJobs);
|
||||
followedComponentJobs.add(savedJob);
|
||||
sessionStorage.removeItem('tachyon_post_update_job');
|
||||
}
|
||||
} catch (_e) {
|
||||
// sessionStorage may be unavailable (private mode); the saved job id is
|
||||
// best-effort and its absence is fine.
|
||||
}
|
||||
capSetSize(handledComponentJobs);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pagehide', () => {
|
||||
@@ -405,12 +403,15 @@ async function waitForTachyonResponsive() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function reloadPageAfterTachyonUpdate() {
|
||||
function reloadPageAfterTachyonUpdate(jobId?: string) {
|
||||
if (jobId) {
|
||||
saveHandledJobToSession(jobId);
|
||||
}
|
||||
// Reload only after the restarted backend actually answers get_ui_state:
|
||||
// a fixed delay either reloaded too early (tab hung on stale component
|
||||
// action state) or wasted seconds on fast routers.
|
||||
void waitForTachyonResponsive().finally(() => {
|
||||
window.location.reload();
|
||||
safeReloadPage();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -620,7 +621,7 @@ async function applyCompletedComponentAction({
|
||||
if (modalController) {
|
||||
modalController.completeSuccess(result.message, { reloadPage: true });
|
||||
} else {
|
||||
reloadPageAfterTachyonUpdate();
|
||||
reloadPageAfterTachyonUpdate(result.job_id);
|
||||
}
|
||||
} else {
|
||||
modalController?.completeSuccess(result.message);
|
||||
@@ -669,6 +670,7 @@ async function completeComponentActionJob(
|
||||
|
||||
handledComponentJobs.add(jobId);
|
||||
capSetSize(handledComponentJobs);
|
||||
saveHandledJobToSession(jobId);
|
||||
setActionLoading(key, false);
|
||||
if (shouldNotify) {
|
||||
showToast(message, 'error');
|
||||
@@ -680,6 +682,7 @@ async function completeComponentActionJob(
|
||||
|
||||
handledComponentJobs.add(jobId);
|
||||
capSetSize(handledComponentJobs);
|
||||
saveHandledJobToSession(jobId);
|
||||
await ackComponentActionJob(jobId);
|
||||
await applyCompletedComponentAction({
|
||||
key,
|
||||
@@ -808,6 +811,7 @@ function handleComponentUiState(uiState: Tachyon.UiState) {
|
||||
} else {
|
||||
handledComponentJobs.add(jobId);
|
||||
capSetSize(handledComponentJobs);
|
||||
saveHandledJobToSession(jobId);
|
||||
void ackComponentActionJob(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { renderButton } from '../../../../partials';
|
||||
import { copyToClipboard } from '../../../../helpers/copyToClipboard';
|
||||
import { TachyonShellMethods } from '../../../methods';
|
||||
import { Tachyon } from '../../../types';
|
||||
import {
|
||||
isReloadInProgress,
|
||||
safeReloadPage,
|
||||
saveHandledJobToSession,
|
||||
} from '../sessionJobs';
|
||||
|
||||
export interface UpdateProgressModalOptions {
|
||||
component: Tachyon.ComponentName;
|
||||
@@ -349,7 +354,10 @@ export function showUpdateProgressModal(
|
||||
text: _('Reload now'),
|
||||
onClick: () => {
|
||||
reloadProbeActive = false;
|
||||
window.location.reload();
|
||||
if (activeModalJobId) {
|
||||
saveHandledJobToSession(activeModalJobId);
|
||||
}
|
||||
safeReloadPage();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -362,51 +370,31 @@ export function showUpdateProgressModal(
|
||||
);
|
||||
|
||||
const probeAndReload = async () => {
|
||||
// Pause 2 seconds initially to allow init scripts and rpcd reload to commence
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
// Pause 1.5s initially to allow init scripts and rpcd reload to commence
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
while (reloadProbeActive && attempt < maxAttempts) {
|
||||
while (
|
||||
reloadProbeActive &&
|
||||
attempt < maxAttempts &&
|
||||
!isReloadInProgress()
|
||||
) {
|
||||
attempt += 1;
|
||||
statusEl.textContent = `${_('Verifying router and service readiness...')} (${attempt}/${maxAttempts})`;
|
||||
|
||||
try {
|
||||
const res = await TachyonShellMethods.getSystemInfo();
|
||||
if (res && res.success) {
|
||||
statusEl.textContent = _(
|
||||
'Services are online, waiting for full initialization...',
|
||||
);
|
||||
// Wait for the service to fully stabilize after the first
|
||||
// successful probe; rpcd can come up before tachyon init finishes.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
if (!reloadProbeActive) return;
|
||||
|
||||
// Confirm services are still up after the settle window
|
||||
try {
|
||||
const confirm = await TachyonShellMethods.getSystemInfo();
|
||||
if (confirm && confirm.success) {
|
||||
statusEl.textContent = _(
|
||||
'Services are online and ready! Reloading page...',
|
||||
);
|
||||
reloadBtn.textContent = _('Reloading...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
if (activeModalJobId) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
'tachyon_post_update_job',
|
||||
activeModalJobId,
|
||||
);
|
||||
} catch (_e) {
|
||||
// sessionStorage may be unavailable; cleanup is
|
||||
// best-effort.
|
||||
saveHandledJobToSession(activeModalJobId);
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
safeReloadPage();
|
||||
return;
|
||||
}
|
||||
} catch (_confirmErr) {
|
||||
// Services went away during settle — keep probing
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// RPC is unavailable while daemon/rpcd is reloading; keep probing
|
||||
}
|
||||
@@ -414,7 +402,7 @@ export function showUpdateProgressModal(
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
|
||||
if (reloadProbeActive) {
|
||||
if (reloadProbeActive && !isReloadInProgress()) {
|
||||
statusEl.textContent = _(
|
||||
'Services restarted. Click to reload page.',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const HANDLED_JOBS_STORAGE_KEY = 'tachyon_handled_component_jobs';
|
||||
const LEGACY_JOB_STORAGE_KEY = 'tachyon_post_update_job';
|
||||
|
||||
let isPageReloading = false;
|
||||
|
||||
export function safeReloadPage(): void {
|
||||
if (isPageReloading) {
|
||||
return;
|
||||
}
|
||||
isPageReloading = true;
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
export function isReloadInProgress(): boolean {
|
||||
return isPageReloading;
|
||||
}
|
||||
|
||||
export function saveHandledJobToSession(jobId: string): void {
|
||||
if (!jobId || typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(HANDLED_JOBS_STORAGE_KEY);
|
||||
const list: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!list.includes(jobId)) {
|
||||
list.push(jobId);
|
||||
if (list.length > 30) {
|
||||
list.shift();
|
||||
}
|
||||
sessionStorage.setItem(HANDLED_JOBS_STORAGE_KEY, JSON.stringify(list));
|
||||
}
|
||||
sessionStorage.setItem(LEGACY_JOB_STORAGE_KEY, jobId);
|
||||
} catch (_e) {
|
||||
// Ignore private mode or quota errors
|
||||
}
|
||||
}
|
||||
|
||||
export function loadHandledJobsFromSession(): string[] {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
const result = new Set<string>();
|
||||
try {
|
||||
const raw = sessionStorage.getItem(HANDLED_JOBS_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const id of parsed) {
|
||||
if (typeof id === 'string' && id) {
|
||||
result.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const legacy = sessionStorage.getItem(LEGACY_JOB_STORAGE_KEY);
|
||||
if (legacy) {
|
||||
result.add(legacy);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore private mode errors
|
||||
}
|
||||
return Array.from(result);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
isReloadInProgress,
|
||||
loadHandledJobsFromSession,
|
||||
safeReloadPage,
|
||||
saveHandledJobToSession,
|
||||
} from '../sessionJobs';
|
||||
|
||||
describe('sessionJobs', () => {
|
||||
let storage: Record<string, string> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
storage = {};
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
getItem: vi.fn((key: string) => storage[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
storage[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete storage[key];
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('saves and loads handled jobs from session storage', () => {
|
||||
saveHandledJobToSession('job-1');
|
||||
saveHandledJobToSession('job-2');
|
||||
|
||||
const loaded = loadHandledJobsFromSession();
|
||||
expect(loaded).toContain('job-1');
|
||||
expect(loaded).toContain('job-2');
|
||||
});
|
||||
|
||||
it('loads legacy tachyon_post_update_job if present', () => {
|
||||
storage['tachyon_post_update_job'] = 'legacy-job-99';
|
||||
|
||||
const loaded = loadHandledJobsFromSession();
|
||||
expect(loaded).toContain('legacy-job-99');
|
||||
});
|
||||
|
||||
it('does not duplicate job IDs in session storage', () => {
|
||||
saveHandledJobToSession('job-dup');
|
||||
saveHandledJobToSession('job-dup');
|
||||
|
||||
const loaded = loadHandledJobsFromSession();
|
||||
expect(loaded.filter((id) => id === 'job-dup').length).toBe(1);
|
||||
});
|
||||
|
||||
it('safeReloadPage calls window.location.reload only once', () => {
|
||||
const reloadMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
location: {
|
||||
reload: reloadMock,
|
||||
},
|
||||
});
|
||||
|
||||
safeReloadPage();
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(isReloadInProgress()).toBe(true);
|
||||
|
||||
// Second call should be a no-op
|
||||
safeReloadPage();
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -3258,6 +3258,7 @@ var COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS = 2e3;
|
||||
var COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS = 3e4;
|
||||
var COMPONENT_ACTION_MIN_ELAPSED_FOR_SELF_UPDATE_MS = 2e3;
|
||||
var COMPONENT_ACTION_SELF_UPDATE_HARD_TIMEOUT_MS = 12e4;
|
||||
var COMPONENT_ACTION_GENERAL_HARD_TIMEOUT_MS = 15 * 60 * 1e3;
|
||||
var COMPONENT_ACTION_STATE_DIR = "/var/run/tachyon/component-actions";
|
||||
var GET_UI_STATE_RPC_TIMEOUT_MS = 3e3;
|
||||
function sleep(ms) {
|
||||
@@ -3780,14 +3781,14 @@ var TachyonShellMethods = {
|
||||
return "";
|
||||
};
|
||||
const confirmedSameVersionReinstall = async () => {
|
||||
if (!isSelfUpdate || action !== "reinstall" || !baselineVersion) {
|
||||
if (!isSelfUpdate || !baselineVersion) {
|
||||
return "";
|
||||
}
|
||||
if (targetVersion && !versionsMatch(targetVersion, baselineVersion)) {
|
||||
return "";
|
||||
}
|
||||
const version = await readTachyonVersion();
|
||||
if (versionsMatch(version, baselineVersion)) {
|
||||
if (version && versionsMatch(version, baselineVersion)) {
|
||||
return version;
|
||||
}
|
||||
return "";
|
||||
@@ -3804,7 +3805,7 @@ var TachyonShellMethods = {
|
||||
if (await confirmedByVersion() === version) {
|
||||
return true;
|
||||
}
|
||||
} else if (action === "reinstall") {
|
||||
} else {
|
||||
if (await confirmedSameVersionReinstall() === version) {
|
||||
return true;
|
||||
}
|
||||
@@ -3845,8 +3846,17 @@ var TachyonShellMethods = {
|
||||
}
|
||||
return selfUpdateResult(version || baselineVersion);
|
||||
}
|
||||
if (!isSelfUpdate && Date.now() - jobStartedAt >= COMPONENT_ACTION_GENERAL_HARD_TIMEOUT_MS) {
|
||||
if (stateResponse && !stateResponse.running) {
|
||||
if (isSelfUpdate && stateResponse.success === false && (isDifferentVersion || action === "reinstall")) {
|
||||
return jobDoneResult(stateResponse);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: _("Component action timed out")
|
||||
};
|
||||
}
|
||||
if (stateResponse && !stateResponse.running) {
|
||||
if (isSelfUpdate && stateResponse.success === false) {
|
||||
const version = await confirmedByVersion() || await confirmedSameVersionReinstall();
|
||||
if (version) {
|
||||
if (await settleVersion(version)) {
|
||||
@@ -3893,7 +3903,7 @@ var TachyonShellMethods = {
|
||||
if (parsedResponse.running) {
|
||||
continue;
|
||||
}
|
||||
if (isSelfUpdate && parsedResponse.success === false && (isDifferentVersion || action === "reinstall")) {
|
||||
if (isSelfUpdate && parsedResponse.success === false) {
|
||||
const version = await confirmedByVersion() || await confirmedSameVersionReinstall();
|
||||
if (version) {
|
||||
if (await settleVersion(version)) {
|
||||
@@ -18778,6 +18788,66 @@ function describeSameReleaseBuild({
|
||||
return { kind: "fallback", text: "" };
|
||||
}
|
||||
|
||||
// src/tachyon/tabs/updates/sessionJobs.ts
|
||||
var HANDLED_JOBS_STORAGE_KEY = "tachyon_handled_component_jobs";
|
||||
var LEGACY_JOB_STORAGE_KEY = "tachyon_post_update_job";
|
||||
var isPageReloading = false;
|
||||
function safeReloadPage() {
|
||||
if (isPageReloading) {
|
||||
return;
|
||||
}
|
||||
isPageReloading = true;
|
||||
if (typeof window !== "undefined" && window.location) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
function isReloadInProgress() {
|
||||
return isPageReloading;
|
||||
}
|
||||
function saveHandledJobToSession(jobId) {
|
||||
if (!jobId || typeof sessionStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(HANDLED_JOBS_STORAGE_KEY);
|
||||
const list = raw ? JSON.parse(raw) : [];
|
||||
if (!list.includes(jobId)) {
|
||||
list.push(jobId);
|
||||
if (list.length > 30) {
|
||||
list.shift();
|
||||
}
|
||||
sessionStorage.setItem(HANDLED_JOBS_STORAGE_KEY, JSON.stringify(list));
|
||||
}
|
||||
sessionStorage.setItem(LEGACY_JOB_STORAGE_KEY, jobId);
|
||||
} catch (_e) {
|
||||
}
|
||||
}
|
||||
function loadHandledJobsFromSession() {
|
||||
if (typeof sessionStorage === "undefined") {
|
||||
return [];
|
||||
}
|
||||
const result = /* @__PURE__ */ new Set();
|
||||
try {
|
||||
const raw = sessionStorage.getItem(HANDLED_JOBS_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const id of parsed) {
|
||||
if (typeof id === "string" && id) {
|
||||
result.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const legacy = sessionStorage.getItem(LEGACY_JOB_STORAGE_KEY);
|
||||
if (legacy) {
|
||||
result.add(legacy);
|
||||
}
|
||||
} catch (_e) {
|
||||
}
|
||||
return Array.from(result);
|
||||
}
|
||||
|
||||
// src/tachyon/tabs/updates/partials/renderUpdateProgressModal.ts
|
||||
function formatSha(sha) {
|
||||
if (!sha) return "";
|
||||
@@ -19020,7 +19090,10 @@ function showUpdateProgressModal(options) {
|
||||
text: _("Reload now"),
|
||||
onClick: () => {
|
||||
reloadProbeActive = false;
|
||||
window.location.reload();
|
||||
if (activeModalJobId) {
|
||||
saveHandledJobToSession(activeModalJobId);
|
||||
}
|
||||
safeReloadPage();
|
||||
}
|
||||
});
|
||||
actionButtonContainer.replaceChildren(
|
||||
@@ -19031,46 +19104,29 @@ function showUpdateProgressModal(options) {
|
||||
])
|
||||
);
|
||||
const probeAndReload = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
||||
while (reloadProbeActive && attempt < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
while (reloadProbeActive && attempt < maxAttempts && !isReloadInProgress()) {
|
||||
attempt += 1;
|
||||
statusEl.textContent = `${_("Verifying router and service readiness...")} (${attempt}/${maxAttempts})`;
|
||||
try {
|
||||
const res = await TachyonShellMethods.getSystemInfo();
|
||||
if (res && res.success) {
|
||||
statusEl.textContent = _(
|
||||
"Services are online, waiting for full initialization..."
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
||||
if (!reloadProbeActive) return;
|
||||
try {
|
||||
const confirm = await TachyonShellMethods.getSystemInfo();
|
||||
if (confirm && confirm.success) {
|
||||
statusEl.textContent = _(
|
||||
"Services are online and ready! Reloading page..."
|
||||
);
|
||||
reloadBtn.textContent = _("Reloading...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
if (activeModalJobId) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"tachyon_post_update_job",
|
||||
activeModalJobId
|
||||
);
|
||||
} catch (_e) {
|
||||
saveHandledJobToSession(activeModalJobId);
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
safeReloadPage();
|
||||
return;
|
||||
}
|
||||
} catch (_confirmErr) {
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
if (reloadProbeActive) {
|
||||
if (reloadProbeActive && !isReloadInProgress()) {
|
||||
statusEl.textContent = _(
|
||||
"Services restarted. Click to reload page."
|
||||
);
|
||||
@@ -19221,16 +19277,11 @@ var componentActionStateUnsubscribe = null;
|
||||
var componentActionStateRefreshPromise = null;
|
||||
var followedComponentJobs = /* @__PURE__ */ new Set();
|
||||
var handledComponentJobs = /* @__PURE__ */ new Set();
|
||||
try {
|
||||
const savedJob = sessionStorage.getItem("tachyon_post_update_job");
|
||||
if (savedJob) {
|
||||
for (const savedJob of loadHandledJobsFromSession()) {
|
||||
handledComponentJobs.add(savedJob);
|
||||
capSetSize(handledComponentJobs);
|
||||
followedComponentJobs.add(savedJob);
|
||||
sessionStorage.removeItem("tachyon_post_update_job");
|
||||
}
|
||||
} catch (_e) {
|
||||
}
|
||||
capSetSize(handledComponentJobs);
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pagehide", () => {
|
||||
pageUnloading2 = true;
|
||||
@@ -19414,9 +19465,12 @@ async function waitForTachyonResponsive() {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function reloadPageAfterTachyonUpdate() {
|
||||
function reloadPageAfterTachyonUpdate(jobId) {
|
||||
if (jobId) {
|
||||
saveHandledJobToSession(jobId);
|
||||
}
|
||||
void waitForTachyonResponsive().finally(() => {
|
||||
window.location.reload();
|
||||
safeReloadPage();
|
||||
});
|
||||
}
|
||||
function patchSystemInfoAfterMutation(result) {
|
||||
@@ -19570,7 +19624,7 @@ async function applyCompletedComponentAction({
|
||||
if (modalController) {
|
||||
modalController.completeSuccess(result.message, { reloadPage: true });
|
||||
} else {
|
||||
reloadPageAfterTachyonUpdate();
|
||||
reloadPageAfterTachyonUpdate(result.job_id);
|
||||
}
|
||||
} else {
|
||||
modalController?.completeSuccess(result.message);
|
||||
@@ -19604,6 +19658,7 @@ async function completeComponentActionJob(key, jobId, response) {
|
||||
}
|
||||
handledComponentJobs.add(jobId);
|
||||
capSetSize(handledComponentJobs);
|
||||
saveHandledJobToSession(jobId);
|
||||
setActionLoading(key, false);
|
||||
if (shouldNotify) {
|
||||
showToast(message, "error");
|
||||
@@ -19614,6 +19669,7 @@ async function completeComponentActionJob(key, jobId, response) {
|
||||
}
|
||||
handledComponentJobs.add(jobId);
|
||||
capSetSize(handledComponentJobs);
|
||||
saveHandledJobToSession(jobId);
|
||||
await ackComponentActionJob(jobId);
|
||||
await applyCompletedComponentAction({
|
||||
key,
|
||||
@@ -19708,6 +19764,7 @@ function handleComponentUiState(uiState) {
|
||||
} else {
|
||||
handledComponentJobs.add(jobId);
|
||||
capSetSize(handledComponentJobs);
|
||||
saveHandledJobToSession(jobId);
|
||||
void ackComponentActionJob(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ let command_output = common.command_output;
|
||||
let command_output_from_args = common.command_output_from_args;
|
||||
let write_json = common.write_json;
|
||||
let write_file = common.write_file;
|
||||
let bounded_command = common.bounded_command;
|
||||
let kill_matching_command = common.kill_matching_command;
|
||||
|
||||
let tmp_dir = "";
|
||||
let lock_held = false;
|
||||
@@ -337,14 +339,15 @@ function action_fail(component, action, message, current_version, latest_version
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function run_logged(description, command) {
|
||||
function run_logged(description, command, timeout_seconds) {
|
||||
init_tmp_dir();
|
||||
let output_file = make_tmp_file("command");
|
||||
if (output_file == "")
|
||||
output_file = "/tmp/tachyon-updates-command." + owner_pid();
|
||||
|
||||
updates_log(description);
|
||||
let status = command_status(as_string(command) + " >" + shell_quote(output_file) + " 2>&1");
|
||||
let run_cmd = timeout_seconds ? bounded_command(command, timeout_seconds) : as_string(command);
|
||||
let status = command_status(run_cmd + " >" + shell_quote(output_file) + " 2>&1");
|
||||
for (let line in split(read_file(output_file), "\n"))
|
||||
if (trim(as_string(line)) != "")
|
||||
updates_log(line);
|
||||
@@ -939,12 +942,11 @@ function restart_tachyon_after_successful_change() {
|
||||
prepare_sing_box_service_disabled();
|
||||
return;
|
||||
}
|
||||
// Clear any stuck flock holders or pending rc.common waits
|
||||
system(kill_matching_command("-E '99-tachyon-wan|flock 1000|init[.]d/tachyon'"));
|
||||
// Kill orphaned logread -f processes before restart to prevent FD cascade.
|
||||
// action.uc runs as a child of the old watchdog and inherits its logread pipe FD.
|
||||
// Without this, the old logread process may keep the pipe alive across the restart,
|
||||
// causing the new watchdog's config generator to inherit stale FDs and hit the 1024 limit.
|
||||
command_success_from_args([ "killall", "logread" ]);
|
||||
run_logged("Restarting Tachyon after successful component change", command_from_args([ SERVICE_INIT, "restart" ]));
|
||||
run_logged("Restarting Tachyon after successful component change", command_from_args([ SERVICE_INIT, "restart" ]), 25);
|
||||
}
|
||||
|
||||
function stop_tachyon_before_sing_box_change() {
|
||||
|
||||
@@ -447,6 +447,42 @@ function command_output_from_args(args) {
|
||||
return command_output(command_from_args(args) + " 2>/dev/null");
|
||||
}
|
||||
|
||||
let timeout_prefix_cache = null;
|
||||
|
||||
function timeout_prefix() {
|
||||
if (timeout_prefix_cache != null)
|
||||
return timeout_prefix_cache;
|
||||
|
||||
if (command_status("timeout 1 /bin/true >/dev/null 2>&1") == 0)
|
||||
timeout_prefix_cache = [ "timeout" ];
|
||||
else if (command_status("timeout -t 1 /bin/true >/dev/null 2>&1") == 0)
|
||||
timeout_prefix_cache = [ "timeout", "-t" ];
|
||||
else
|
||||
timeout_prefix_cache = [];
|
||||
|
||||
return timeout_prefix_cache;
|
||||
}
|
||||
|
||||
function bounded_command(command, seconds) {
|
||||
seconds = as_string(seconds || "30");
|
||||
let prefix = timeout_prefix();
|
||||
if (length(prefix) == 0)
|
||||
return as_string(command);
|
||||
|
||||
return join(" ", prefix) + " " + seconds + " " + as_string(command);
|
||||
}
|
||||
|
||||
function kill_matching_command(grep_args) {
|
||||
return "__anc=\" \"; __p=$$; " +
|
||||
"while [ -n \"$__p\" ] && [ \"$__p\" -gt 1 ] 2>/dev/null; do " +
|
||||
"__anc=\"$__anc$__p \"; " +
|
||||
"__p=$(awk '{ sub(/.*\\) /, \"\"); print $2 }' \"/proc/$__p/stat\" 2>/dev/null); " +
|
||||
"done; " +
|
||||
"ps 2>/dev/null | grep " + grep_args + " | grep -v grep | grep -v -E 'action[.]uc|updates[.]uc' | awk '{print $1}' | " +
|
||||
"while read _pid; do case \"$__anc\" in *\" $_pid \"*) continue;; esac; " +
|
||||
"kill -9 \"$_pid\" 2>/dev/null; done; true";
|
||||
}
|
||||
|
||||
function ensure_dir(path) {
|
||||
path = as_string(path);
|
||||
if (path == "") return false;
|
||||
@@ -559,5 +595,8 @@ return {
|
||||
write_file,
|
||||
file_exists,
|
||||
parent_dir,
|
||||
get_mixed_port
|
||||
get_mixed_port,
|
||||
timeout_prefix,
|
||||
bounded_command,
|
||||
kill_matching_command
|
||||
};
|
||||
@@ -299,8 +299,6 @@ function luci_postinst() {
|
||||
if (!PACKAGE_TEST_MODE) {
|
||||
if (path_exists("/etc/init.d/rpcd"))
|
||||
command_success_from_args([ "/etc/init.d/rpcd", "restart" ]);
|
||||
if (path_exists("/etc/init.d/uhttpd"))
|
||||
command_success_from_args([ "/etc/init.d/uhttpd", "restart" ]);
|
||||
command_success_from_args([ "logger", "-t", "tachyon", "[info] Package defaults applied" ]);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1470,7 +1470,16 @@ function action_ack(kind, job_id_value) {
|
||||
return;
|
||||
}
|
||||
|
||||
refresh_pid_job_state(path, "UI action worker exited unexpectedly");
|
||||
let value = read_json_file(path);
|
||||
if (type(value) == "object" && value.running === true) {
|
||||
let pid = as_string(value.pid || "");
|
||||
if (!job_pid_valid(pid) || !pid_running(pid)) {
|
||||
write_stale_action_state(path, "UI action worker is no longer running");
|
||||
value = read_json_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (type(value) == "object" && value.running === true) {
|
||||
action_start_response(false, job_id_value, "UI action is still running");
|
||||
exit(1);
|
||||
|
||||
Reference in New Issue
Block a user