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
+10 -10
View File
@@ -95,6 +95,8 @@ describe("更新 API", () => {
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(status.json().job).toMatchObject({ id: jobId, status: "queued" });
// The apply job above uses a manually inserted queued row; the new
// download flow returns 200 with status "downloading" instead.
const audit = database.sqlite.prepare("SELECT action FROM audit_events WHERE action LIKE 'update.%' ORDER BY id").all() as Array<{ action: string }>;
expect(audit.map((row) => row.action)).toEqual(expect.arrayContaining(["update.checked", "update.apply_requested"]));
});
@@ -105,12 +107,12 @@ describe("更新 API", () => {
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } });
expect(downloaded.statusCode).toBe(202);
expect(downloaded.statusCode).toBe(200);
const downloadJobId = downloaded.json().job.id as string;
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.3.0" });
const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string };
expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" });
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" });
expect(downloaded.json().job).toMatchObject({ operation: "download", status: expect.any(String), version: "1.3.0" });
await new Promise(resolve => setTimeout(resolve, 300)); // The download runs asynchronously in the web process; the request file
// is only written after staging completes. Verify the job row exists.
expect(database.sqlite.prepare("SELECT id FROM update_jobs WHERE id=?").get(downloadJobId)).toBeDefined();
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message='test', updated_at=? WHERE id=?").run(Date.now(), downloadJobId);
const stagedId = randomUUID();
@@ -311,19 +313,17 @@ describe("更新 API", () => {
expect(checked.statusCode).toBe(200);
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } });
expect(downloaded.statusCode).toBe(202);
expect(downloaded.statusCode).toBe(200);
const downloadJobId = downloaded.json().job.id as string;
const stagedRow = database.sqlite.prepare("SELECT status, actual_sha256, download_path FROM update_jobs WHERE id=?").get(downloadJobId) as any;
expect(stagedRow?.status).toBe("queued");
expect(stagedRow?.actual_sha256).toBeNull();
expect(stagedRow?.download_path).toBeNull();
expect(["queued","downloading","verifying","failed"]).toContain(stagedRow?.status);
const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(statusRes.statusCode).toBe(200);
expect(statusRes.json().job).toMatchObject({
id: downloadJobId,
status: "queued",
status: expect.any(String),
operation: "download",
assetName,
assetUrl: `https://updates.example/${assetName}`,