fix: make online updates recoverable
TallyNote release / linux-x64 (push) Successful in 7m45s

This commit is contained in:
Qiufeng
2026-09-05 14:56:15 +08:00
parent a080f531cd
commit ed0b492461
12 changed files with 526 additions and 165 deletions
+34 -10
View File
@@ -154,19 +154,19 @@ function signatureAssetFor(metadata: ReleaseMetadata, sums: ReleaseAsset): Relea
export async function attachSidecarHash(
metadata: ReleaseMetadata,
asset: ReleaseAsset,
options: { allowedHosts: readonly string[]; baseUrl: string; maxBytes: number; publicKey?: string | undefined; requireSignature?: boolean | undefined },
options: { allowedHosts: readonly string[]; baseUrl: string; maxBytes: number; timeoutMs?: number | undefined; publicKey?: string | undefined; requireSignature?: boolean | undefined },
): Promise<{ asset: ReleaseAsset; signatureVerified: boolean }> {
let signatureVerified = false;
if (asset.sha256 && (!options.publicKey || !options.requireSignature)) return { asset, signatureVerified };
const sums = metadata.assets.find((candidate) => /^(?:sha256sums?|checksums?)(?:\.txt)?$/i.test(path.basename(candidate.name)));
if (!sums) return { asset, signatureVerified };
try {
const content = await fetchReleaseText(sums.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: Math.min(options.maxBytes, 2 * 1024 * 1024) });
const content = await fetchReleaseText(sums.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: Math.min(options.maxBytes, 2 * 1024 * 1024), timeoutMs: options.timeoutMs });
const sha256 = sha256FromSums(content, asset.name);
if (options.publicKey) {
const signatureAsset = signatureAssetFor(metadata, sums);
if (signatureAsset) {
const signature = await fetchReleaseBytes(signatureAsset.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: 64 * 1024 });
const signature = await fetchReleaseBytes(signatureAsset.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: 64 * 1024, timeoutMs: options.timeoutMs });
signatureVerified = verifyReleaseSignature(content, signature, options.publicKey);
}
}
@@ -183,6 +183,7 @@ function policy(config: AppConfig) {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
maxRedirects: 3,
timeoutMs: config.updateTimeoutMs,
} as const;
}
@@ -222,6 +223,7 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
allowedHosts: config.updateAllowedHosts,
baseUrl: metadataUrl,
maxBytes: config.updateMaxBytes,
timeoutMs: config.updateTimeoutMs,
publicKey: config.updatePublicKey,
requireSignature: config.updateRequireSignature,
});
@@ -426,6 +428,17 @@ function requestJobId(filePath: string): string | null {
}
}
function recoveryStateJobId(filePath: string): string | null {
try {
const info = lstatSync(filePath);
if (!info.isFile() || info.isSymbolicLink()) return null;
const match = /^job_id=([0-9a-f-]{36})$/m.exec(readFileSync(filePath, "utf8"));
return match?.[1] ?? null;
} catch {
return null;
}
}
function currentReleaseVersion(config: AppConfig): string | null {
try {
const target = realpathSync(config.currentLink);
@@ -460,6 +473,12 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
const statePresent = stateMtime !== null;
const requestFresh = requestPresent && now - (requestMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
const stateFresh = statePresent && now - (stateMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
// The request marker is the hand-off contract between the web process and
// the privileged runner. A queued row with a matching, unexpired marker is
// still owned by that hand-off even when the runner has not written its
// recovery state yet (for example while systemd is starting it).
const requestMarkerJobId = requestPresent ? requestJobId(config.updateRequestPath) : null;
const stateMarkerJobId = statePresent ? recoveryStateJobId(statePath) : null;
// A staged download is normally kept for an explicit apply. The one
// exception is the hand-off window where the API has already changed the
// operation to `apply` but crashed before writing the request file. That
@@ -472,7 +491,10 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
// the UI as an endless "queued" task. Once the short hand-off window has
// elapsed and no recovery marker exists, release the queue explicitly;
// a fresh state marker proves that the runner has already claimed it.
if (row.status === "queued" && typeof row.updatedAt === "number" && !stateFresh && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh;
const matchingFreshState = stateMarkerJobId === row.id && stateFresh;
if (row.status === "queued" && typeof row.updatedAt === "number" && !matchingFreshState && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
if (matchingFreshRequest) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
@@ -503,7 +525,7 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
// A stale request/state marker therefore no longer protects an orphaned
// row forever, while a fresh marker remains owned by the runner.
if (row.status === "staged") {
if (row.operation !== "apply" || requestFresh || stateFresh) continue;
if (row.operation !== "apply" || matchingFreshRequest || matchingFreshState) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
@@ -529,7 +551,7 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
}
continue;
}
if (requestFresh || stateFresh) continue;
if (matchingFreshRequest || matchingFreshState) continue;
const status: "completed" | "failed" = row.status === "applying" && releaseVersion === row.version ? "completed" : "failed";
const errorMessage = status === "failed" ? "更新任务超时,已释放更新队列" : null;
const changed = database.transaction(() => {
@@ -586,15 +608,15 @@ export function cancelUpdateJob(
jobId?: string,
): { cancelled: boolean; message?: string } {
const job = jobId
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=?").get(jobId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get() as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined;
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=? AND admin_id=?").get(jobId, adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE admin_id=? AND status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get(adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined;
if (!job) return { cancelled: false, message: "当前没有处于等待调度或下载中的更新任务" };
if (job.status !== "queued" && job.status !== "downloading") return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" };
const now = Date.now();
const changed = database.transaction(() => {
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND status IN ('queued', 'downloading')").run(now, now, job.id);
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND status IN ('queued', 'downloading')").run(now, now, job.id, adminId);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId,
@@ -610,7 +632,9 @@ export function cancelUpdateJob(
})();
if (changed) {
forceRemoveRequest(config.updateRequestPath);
// The request marker is shared by the privileged runner. Never remove a
// newer/different administrator's request while cancelling this row.
if (requestJobId(config.updateRequestPath) === job.id) forceRemoveRequest(config.updateRequestPath);
if (job.downloadPath) {
const target = path.isAbsolute(job.downloadPath) ? job.downloadPath : path.join(config.stagingDir, job.downloadPath);
import("node:fs/promises").then(({ rm }) => rm(target, { recursive: true, force: true })).catch(() => {});