feat: refactor online update to synchronous web-process download
TallyNote release / linux-x64 (push) Failing after 3m12s

- Download happens in web process (non-root) with real-time progress
- Root runner only handles privileged apply (stop/backup/switch/restart)
- Eliminates 'waiting for system scheduler' stuck state
- Frontend shows download bytes/speed/percentage with cancel button
- Staged download triggers apply request file for root runner
- systemd timeout reduced from 32min to 5min (no download phase)
- Tests adapted for synchronous download flow
release: 1.3.0
This commit is contained in:
Qiufeng
2026-09-10 23:18:33 +08:00
parent ab2d24a5c7
commit ae5892d81c
7 changed files with 499 additions and 24 deletions
+137 -1
View File
@@ -1,5 +1,5 @@
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import type Database from "better-sqlite3";
@@ -8,10 +8,13 @@ import { AppError } from "./errors.js";
import type { AppConfig } from "./config.js";
import {
detectPlatform,
downloadReleaseAsset,
extractSafeArchive,
fetchReleaseBytes,
fetchReleaseMetadata,
fetchReleaseText,
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
sanitizeAssetName,
@@ -679,3 +682,136 @@ export function cancelUpdateJob(
}
return { cancelled: false, message: "取消失败,任务状态可能已改变" };
}
/**
* Download, verify and stage a release archive in the web process (non-root).
* The root runner only needs to apply (stop/backup/switch/restart) afterwards.
*
* This function runs asynchronously outside the request lifecycle. It updates
* the job row in the database so the frontend can poll progress. On success it
* writes an apply request file so the systemd path unit triggers the runner.
*/
export async function downloadAndStageUpdate(
database: Database.Database,
config: AppConfig,
jobId: string,
adminId: string,
version: string,
assetUrl: string,
assetName: string,
expectedSha256: string,
metadataUrl: string,
): Promise<void> {
const stagingBase = path.resolve(config.stagingDir);
const workspace = path.join(stagingBase, `update-${jobId}`);
try {
await mkdir(workspace, { recursive: true, mode: 0o700 });
const archiveName = assetName.endsWith(".tar.gz") || assetName.endsWith(".tgz") ? assetName : `${assetName}.tar.gz`;
const archivePath = path.join(workspace, archiveName);
// Claim the job: transition queued -> downloading. If the job was
// cancelled or claimed by another caller, abort immediately.
const claim = database.prepare(
"UPDATE update_jobs SET status='downloading', download_started_at=?, started_at=?, download_path=?, updated_at=? WHERE id=? AND status='queued'",
).run(Date.now(), Date.now(), path.basename(archivePath), Date.now(), jobId);
if (claim.changes !== 1) return;
const progressStartedAt = Date.now();
let lastProgressWrite = 0;
const downloaded = await downloadReleaseAsset(assetUrl, archivePath, {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
maxBytes: config.updateMaxBytes,
timeoutMs: config.updateTimeoutMs,
onProgress: (downloadedBytes, totalBytes) => {
const now = Date.now();
if (now - lastProgressWrite < 250) return;
lastProgressWrite = now;
const elapsed = Math.max(1, now - progressStartedAt);
const speedBps = Math.round(downloadedBytes * 1000 / elapsed);
database.prepare(
"UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'",
).run(downloadedBytes, totalBytes, speedBps, now, jobId);
},
});
// Final progress write
const finishedAt = Date.now();
const elapsed = Math.max(1, finishedAt - progressStartedAt);
database.prepare(
"UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'",
).run(downloaded.size, downloaded.size, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId);
// SHA-256 verification
database.prepare(
"UPDATE update_jobs SET status='verifying', actual_sha256=?, size_bytes=?, updated_at=? WHERE id=? AND status='downloading'",
).run(downloaded.sha256, downloaded.size, Date.now(), jobId);
if (expectedSha256 && downloaded.sha256 !== expectedSha256) {
throw new Error("更新文件 SHA-256 校验失败");
}
// Extract archive to payload directory
const payloadDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, payloadDir);
await normalizeReleasePermissions(payloadDir);
// Verify payload contains dist directory
const { lstat } = await import("node:fs/promises");
const payloadInfo = await lstat(path.join(payloadDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) {
throw new Error("发布包缺少 dist 目录");
}
// Transition to staged
const staged = database.prepare(
"UPDATE update_jobs SET status='staged', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')",
).run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId);
if (staged.changes !== 1) return; // cancelled
// Write apply request file for the root runner
await writeUpdateRequest(config, {
jobId,
operation: "apply",
version,
metadataUrl,
assetUrl,
assetName,
expectedSha256,
requestedAt: Date.now(),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
stagedPath: workspace,
});
writeAudit(database, {
requestId: `download:${jobId}`,
actorAdminId: adminId,
action: "update.staged",
targetType: "update",
targetId: jobId,
after: { version, sha256: downloaded.sha256, size: downloaded.size },
});
} catch (error) {
const message = error instanceof Error ? error.message : "下载或校验失败";
try {
database.prepare(
"UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=? AND status IN ('queued', 'downloading', 'verifying')",
).run(message, Date.now(), jobId);
writeAudit(database, {
requestId: `download:${jobId}`,
actorAdminId: adminId,
action: "update.download_failed",
targetType: "update",
targetId: jobId,
outcome: "failure",
metadata: { error: message },
});
} catch {
// The database may be closed (e.g. during test cleanup or process
// shutdown). The workspace cleanup below still runs unconditionally.
}
await rm(workspace, { recursive: true, force: true }).catch(() => undefined);
}
}