fix(updates): prevent premature self-update completion while background job is running

This commit is contained in:
Dushnilin
2026-08-29 07:55:36 +03:00
parent 404db8ae05
commit 5543a6ce46
5 changed files with 153 additions and 41 deletions
@@ -702,10 +702,14 @@ export const TachyonShellMethods = {
return cleanA === cleanB; return cleanA === cleanB;
}; };
const isDifferentVersion = Boolean(
targetVersion && !versionsMatch(targetVersion, baselineVersion),
);
// A self-update restarts the service mid-worker: the worker can die // A self-update restarts the service mid-worker: the worker can die
// before writing its result, the state file can stay "running" forever // before writing its result, the state file can stay "running" forever
// (recycled pid), and RPC can go away during the swap. The installed // (recycled pid), and RPC can go away during the swap. The installed
// version is the only reliable ground truth. // version is the only reliable ground truth when upgrading across versions.
const confirmedByVersion = async () => { const confirmedByVersion = async () => {
if (!isSelfUpdate) return ''; if (!isSelfUpdate) return '';
if ( if (
@@ -716,7 +720,7 @@ export const TachyonShellMethods = {
} }
const version = await readTachyonVersion(); const version = await readTachyonVersion();
if (!version) return ''; if (!version) return '';
if (targetVersion && versionsMatch(version, targetVersion)) { if (isDifferentVersion && versionsMatch(version, targetVersion)) {
return version; return version;
} }
if ( if (
@@ -733,14 +737,14 @@ export const TachyonShellMethods = {
// version-based confirmation can never match. Equality with the baseline // version-based confirmation can never match. Equality with the baseline
// is the confirmation — but only once the job is no longer running. // is the confirmation — but only once the job is no longer running.
const confirmedSameVersionReinstall = async () => { const confirmedSameVersionReinstall = async () => {
if (!isSelfUpdate || action !== 'reinstall' || !baselineVersion) { if (!isSelfUpdate || !baselineVersion) {
return '';
}
if (targetVersion && !versionsMatch(targetVersion, baselineVersion)) {
return ''; return '';
} }
const version = await readTachyonVersion(); const version = await readTachyonVersion();
if ( if (versionsMatch(version, baselineVersion)) {
versionsMatch(version, baselineVersion) &&
(!targetVersion || versionsMatch(version, targetVersion))
) {
return version; return version;
} }
return ''; return '';
@@ -760,14 +764,14 @@ export const TachyonShellMethods = {
) { ) {
return false; return false;
} }
if ((await confirmedByVersion()) === version) { if (isDifferentVersion) {
return true; if ((await confirmedByVersion()) === version) {
} return true;
if ( }
version === baselineVersion && } else {
(await confirmedSameVersionReinstall()) === version if ((await confirmedSameVersionReinstall()) === version) {
) { return true;
return true; }
} }
selfUpdateVersionMatchedAt = 0; selfUpdateVersionMatchedAt = 0;
return false; return false;
@@ -845,23 +849,28 @@ export const TachyonShellMethods = {
if ((statusResponse.code ?? 0) !== 0 || !parsedResponse) { if ((statusResponse.code ?? 0) !== 0 || !parsedResponse) {
if (isSelfUpdate) { if (isSelfUpdate) {
const version = const version = await confirmedByVersion();
(await confirmedByVersion()) ||
(await confirmedSameVersionReinstall());
if (version) { if (version) {
if (await settleVersion(version)) { if (await settleVersion(version)) {
return selfUpdateResult(version); return selfUpdateResult(version);
} }
continue; continue;
} }
// A self-update replaces the package mid-flight: RPC failures are
// expected during the swap window and must not fail the operation. const failure = componentActionFailure(statusResponse, parsedResponse);
// The version confirmation above is the only exit that matters. if (transientRpc.shouldContinue(failure.error)) {
if (!stateResponse?.running) {
continue; continue;
} }
transientRpc.reset();
continue; const sameVersion = await confirmedSameVersionReinstall();
if (sameVersion) {
if (await settleVersion(sameVersion)) {
return selfUpdateResult(sameVersion);
}
continue;
}
return failure;
} }
if (stateResponse?.running) { if (stateResponse?.running) {
@@ -405,7 +405,7 @@ describe('TachyonShellMethods.componentAction', () => {
'1.2.78', '1.2.78',
); );
await vi.advanceTimersByTimeAsync(10000); await vi.advanceTimersByTimeAsync(45000);
await expect(responsePromise).resolves.toEqual({ await expect(responsePromise).resolves.toEqual({
success: true, success: true,
@@ -731,4 +731,90 @@ describe('TachyonShellMethods.componentAction', () => {
error: 'Tachyon update did not complete within the timeout', error: 'Tachyon update did not complete within the timeout',
}); });
}); });
it('does not prematurely confirm same-release Tachyon update while the background job is still running', async () => {
let jobFinished = false;
mocks.fsRead.mockImplementation(() =>
Promise.resolve(
JSON.stringify({
success: true,
running: !jobFinished,
component: 'tachyon',
action: 'install',
message: jobFinished ? 'Tachyon has been installed' : 'Component action is running',
current_version: '1.3.4',
latest_version: '1.3.4',
job_id: 'job-1',
}),
),
);
mocks.executeShellCommand.mockImplementation(({ args }) => {
if (args[0] === 'component_action_status') {
return Promise.resolve({
stdout: JSON.stringify({
success: true,
running: !jobFinished,
component: 'tachyon',
action: 'install',
message: jobFinished ? 'Tachyon has been installed' : 'Component action is running',
current_version: '1.3.4',
latest_version: '1.3.4',
job_id: 'job-1',
}),
stderr: '',
code: 0,
});
}
if (args[0] === 'show_version') {
return Promise.resolve({
stdout: '1.3.4\n',
stderr: '',
code: 0,
});
}
return Promise.resolve({
stdout: '',
stderr: 'Unexpected command',
code: 1,
});
});
const responsePromise = TachyonShellMethods.waitComponentActionJob(
'job-1',
'tachyon',
'install',
'1.3.4',
);
// After 5 seconds, job is still running: must not resolve yet
await vi.advanceTimersByTimeAsync(5000);
let resolved = false;
responsePromise.then(() => {
resolved = true;
});
await Promise.resolve();
expect(resolved).toBe(false);
// Now job finishes in backend
jobFinished = true;
await vi.advanceTimersByTimeAsync(3000);
await expect(responsePromise).resolves.toEqual({
success: true,
data: {
success: true,
running: false,
component: 'tachyon',
action: 'install',
message: 'Tachyon has been installed',
current_version: '1.3.4',
latest_version: '1.3.4',
job_id: 'job-1',
},
});
});
}); });
@@ -215,12 +215,12 @@ describe('TachyonShellMethods Fuzzer Actions', () => {
code: 0, code: 0,
}); });
const patternsPayload: any = { const patternsPayload = {
zapret2: { splits: ['1', '2', 'midsld'] }, zapret2: { splits: ['1', '2', 'midsld'] },
zapret: { splits: ['1'] }, zapret: { splits: ['1'] },
byedpi: { splits: ['1'] }, byedpi: { splits: ['1'] },
custom_strategies: [], custom_strategies: [],
}; } as unknown as Parameters<typeof TachyonShellMethods.saveFuzzerPatterns>[0];
const res = await TachyonShellMethods.saveFuzzerPatterns(patternsPayload); const res = await TachyonShellMethods.saveFuzzerPatterns(patternsPayload);
expect(res.success).toBe(true); expect(res.success).toBe(true);
@@ -19,6 +19,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
let currentState: Tachyon.FuzzerState | null = null; let currentState: Tachyon.FuzzerState | null = null;
let resultFilter: 'all' | 'success' | 'fast' = 'all'; let resultFilter: 'all' | 'success' | 'fast' = 'all';
let autoApplyEnabled = false; let autoApplyEnabled = false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let currentDpiDetection: Tachyon.FuzzerDpiDetection | null = null; let currentDpiDetection: Tachyon.FuzzerDpiDetection | null = null;
// Patterns Config State // Patterns Config State
@@ -1267,7 +1268,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
list.forEach((item, idx) => { list.forEach((item, idx) => {
const isBest = state.best_strategy && state.best_strategy.id === item.id; const isBest = state.best_strategy && state.best_strategy.id === item.id;
let statusBadge = `<span class="label ${item.success ? 'badge-success' : 'badge-danger'}" style="font-size: 10px; padding: 2px 6px; border-radius: 3px;">${item.success ? `HTTP ${item.http_code}` : 'DROP'}</span>`; const statusBadge = `<span class="label ${item.success ? 'badge-success' : 'badge-danger'}" style="font-size: 10px; padding: 2px 6px; border-radius: 3px;">${item.success ? `HTTP ${item.http_code}` : 'DROP'}</span>`;
// If suite sub-probes exist, render multi-endpoint summary // If suite sub-probes exist, render multi-endpoint summary
let subProbesHtml = ''; let subProbesHtml = '';
@@ -3686,6 +3686,9 @@ var TachyonShellMethods = {
const cleanB = b.replace(/^v/i, "").trim(); const cleanB = b.replace(/^v/i, "").trim();
return cleanA === cleanB; return cleanA === cleanB;
}; };
const isDifferentVersion = Boolean(
targetVersion && !versionsMatch(targetVersion, baselineVersion)
);
const confirmedByVersion = async () => { const confirmedByVersion = async () => {
if (!isSelfUpdate) return ""; if (!isSelfUpdate) return "";
if (Date.now() - jobStartedAt < COMPONENT_ACTION_MIN_ELAPSED_FOR_SELF_UPDATE_MS) { if (Date.now() - jobStartedAt < COMPONENT_ACTION_MIN_ELAPSED_FOR_SELF_UPDATE_MS) {
@@ -3693,7 +3696,7 @@ var TachyonShellMethods = {
} }
const version = await readTachyonVersion(); const version = await readTachyonVersion();
if (!version) return ""; if (!version) return "";
if (targetVersion && versionsMatch(version, targetVersion)) { if (isDifferentVersion && versionsMatch(version, targetVersion)) {
return version; return version;
} }
if (!targetVersion && baselineVersion && !versionsMatch(version, baselineVersion)) { if (!targetVersion && baselineVersion && !versionsMatch(version, baselineVersion)) {
@@ -3702,11 +3705,14 @@ var TachyonShellMethods = {
return ""; return "";
}; };
const confirmedSameVersionReinstall = async () => { const confirmedSameVersionReinstall = async () => {
if (!isSelfUpdate || action !== "reinstall" || !baselineVersion) { if (!isSelfUpdate || !baselineVersion) {
return "";
}
if (targetVersion && !versionsMatch(targetVersion, baselineVersion)) {
return ""; return "";
} }
const version = await readTachyonVersion(); const version = await readTachyonVersion();
if (versionsMatch(version, baselineVersion) && (!targetVersion || versionsMatch(version, targetVersion))) { if (versionsMatch(version, baselineVersion)) {
return version; return version;
} }
return ""; return "";
@@ -3719,11 +3725,14 @@ var TachyonShellMethods = {
if (Date.now() - selfUpdateVersionMatchedAt < COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS) { if (Date.now() - selfUpdateVersionMatchedAt < COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS) {
return false; return false;
} }
if (await confirmedByVersion() === version) { if (isDifferentVersion) {
return true; if (await confirmedByVersion() === version) {
} return true;
if (version === baselineVersion && await confirmedSameVersionReinstall() === version) { }
return true; } else {
if (await confirmedSameVersionReinstall() === version) {
return true;
}
} }
selfUpdateVersionMatchedAt = 0; selfUpdateVersionMatchedAt = 0;
return false; return false;
@@ -3781,18 +3790,25 @@ var TachyonShellMethods = {
const parsedResponse = parseComponentActionResult(statusResponse); const parsedResponse = parseComponentActionResult(statusResponse);
if ((statusResponse.code ?? 0) !== 0 || !parsedResponse) { if ((statusResponse.code ?? 0) !== 0 || !parsedResponse) {
if (isSelfUpdate) { if (isSelfUpdate) {
const version = await confirmedByVersion() || await confirmedSameVersionReinstall(); const version = await confirmedByVersion();
if (version) { if (version) {
if (await settleVersion(version)) { if (await settleVersion(version)) {
return selfUpdateResult(version); return selfUpdateResult(version);
} }
continue; continue;
} }
if (!stateResponse?.running) { const failure2 = componentActionFailure(statusResponse, parsedResponse);
if (transientRpc.shouldContinue(failure2.error)) {
continue; continue;
} }
transientRpc.reset(); const sameVersion = await confirmedSameVersionReinstall();
continue; if (sameVersion) {
if (await settleVersion(sameVersion)) {
return selfUpdateResult(sameVersion);
}
continue;
}
return failure2;
} }
if (stateResponse?.running) { if (stateResponse?.running) {
transientRpc.reset(); transientRpc.reset();
@@ -12998,7 +13014,7 @@ function renderStrategyFuzzerModal(ruleNames = []) {
} }
list.forEach((item, idx) => { list.forEach((item, idx) => {
const isBest = state.best_strategy && state.best_strategy.id === item.id; const isBest = state.best_strategy && state.best_strategy.id === item.id;
let statusBadge = `<span class="label ${item.success ? "badge-success" : "badge-danger"}" style="font-size: 10px; padding: 2px 6px; border-radius: 3px;">${item.success ? `HTTP ${item.http_code}` : "DROP"}</span>`; const statusBadge = `<span class="label ${item.success ? "badge-success" : "badge-danger"}" style="font-size: 10px; padding: 2px 6px; border-radius: 3px;">${item.success ? `HTTP ${item.http_code}` : "DROP"}</span>`;
let subProbesHtml = ""; let subProbesHtml = "";
if (item.sub_probes && item.sub_probes.length > 1) { if (item.sub_probes && item.sub_probes.length > 1) {
const passedSub = item.sub_probes.filter((p) => p.success).length; const passedSub = item.sub_probes.filter((p) => p.success).length;