fix: recover stuck background updates
TallyNote release / linux-x64 (push) Successful in 6m3s

This commit is contained in:
Qiufeng
2026-09-03 06:49:48 +08:00
parent b431fe167e
commit ee89e04aae
11 changed files with 331 additions and 36 deletions
+120 -4
View File
@@ -3,7 +3,7 @@ import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "no
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto";
import {
atomicSwitchRelease,
createSafeArchive,
@@ -18,13 +18,13 @@ import {
selectReleaseAsset,
validateHttpsUrl,
} from "../server/update.js";
import { runUpdate } from "../server/cli/update.js";
import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js";
import { validateUpdateRequest } from "../server/cli/update.js";
import { checkForUpdate, verifyReleaseSignature } from "../server/update-service.js";
import { checkForUpdate, ORPHANED_UPDATE_TIMEOUT_MS, reconcileOrphanedUpdateJobs, verifyReleaseSignature } from "../server/update-service.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_INSTALL_PREFIX", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
const originalFetch = globalThis.fetch;
afterEach(() => {
@@ -207,6 +207,122 @@ describe("更新安全工具", () => {
}
});
it("更新器支持旧客户端创建的 queued/apply 直接更新请求", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-direct-"));
const previousFetch = globalThis.fetch;
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(config.releasesDir, { recursive: true, mode: 0o755 });
const oldRelease = path.join(config.releasesDir, config.appVersion);
await mkdir(path.join(oldRelease, "dist"), { recursive: true, mode: 0o755 });
await writeFile(path.join(oldRelease, "dist", "marker"), "old");
await symlink(oldRelease, config.currentLink);
const source = path.join(root, "source");
await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o755 });
await writeFile(path.join(source, "dist", "marker"), "new");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
const bytes = await readFile(archive);
const digest = createHash("sha256").update(bytes).digest("hex");
const jobId = randomUUID();
database = openDatabase(config);
const now = Date.now();
const assetName = `tallynote-1.2.0-${detectPlatform().target}.tar.gz`;
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url,
expected_sha256, created_at, updated_at, requested_at)
VALUES (?, 'apply', 'queued', '1.2.0', ?, ?, ?, ?, ?, ?)
`).run(jobId, detectPlatform().target, "https://updates.example/" + assetName, digest, now, now, now);
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("/latest")) return new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }] }));
if (url.endsWith("SHA256SUMS")) return new Response(`${digest} ${assetName}\n`);
return new Response(bytes, { headers: { "content-length": String(bytes.length) } });
}) as typeof fetch;
await runUpdate({
metadataUrl: config.updateMetadataUrl,
version: "1.2.0",
currentVersion: config.appVersion,
currentDir: config.currentLink,
stagingDir: path.join(root, "staging"),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
allowedHosts: config.updateAllowedHosts,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
deferCompletion: true,
operation: "apply",
jobId,
sqlite: database.sqlite,
fetchImpl: globalThis.fetch,
});
expect(await readFile(path.join(config.currentLink, "dist", "marker"), "utf8")).toBe("new");
const row = database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(jobId);
expect(row).toEqual({ operation: "apply", status: "applying" });
finalizeUpdateJob(database.sqlite, jobId, "failed");
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
} finally {
globalThis.fetch = previousFetch;
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("在请求和恢复标记丢失后收敛孤儿任务,但保留 staged 下载", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-reconcile-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true });
await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const insert = database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const queuedId = randomUUID();
const applyingId = randomUUID();
const stagedId = randomUUID();
insert.run(queuedId, "apply", "queued", "1.2.0", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt);
insert.run(applyingId, "apply", "applying", config.appVersion, "linux-x64", "https://updates.example/applying.tar.gz", staleAt, staleAt);
insert.run(stagedId, "download", "staged", "1.2.0", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
const now = Date.now();
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(2);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" });
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(applyingId)).toEqual({ status: "completed" });
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ status: "staged" });
} finally {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-"));
try {