Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/core/binary-self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
} from 'node:fs';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { GITHUB_REPO } from './restart-report.js';
import { getReleaseStream, resolveHttpProxy } from './release-download.js';
Expand Down Expand Up @@ -175,13 +176,27 @@ export interface BinarySelfUpdateResult {
bytes: number;
}

export type BinaryUpdatePhase = 'download' | 'checksum' | 'probe' | 'swap';

export interface BinaryUpdateProgress {
/** Current stage. `download` events carry live byte counts; later stages
* report the fully-downloaded size so the UI stays busy past 100%. */
phase: BinaryUpdatePhase;
bytesReceived: number;
/** Asset size from the response's Content-Length, or null when the
* (injected or chunked) stream does not announce one. */
bytesTotal: number | null;
}

export interface BinarySelfUpdateDeps {
/** Fetch a URL as a readable stream (injected for tests). */
fetchStream?: (url: string) => Promise<NodeJS.ReadableStream>;
/** Read the published checksum for `asset`, or null when none is published. */
fetchChecksum?: (url: string) => Promise<string | null>;
/** Execute the downloaded candidate before activation (injected for tests). */
probeBinary?: (path: string) => { status: number | null; signal?: NodeJS.Signals | null; error?: Error; stdout?: string | Buffer | null; stderr?: string | Buffer | null };
/** Observe download byte counts and stage transitions (dashboard progress). */
onProgress?: (progress: BinaryUpdateProgress) => void;
}

/**
Expand Down Expand Up @@ -253,7 +268,25 @@ export async function replaceStandaloneBinary(

try {
const res = await fetchStream(`${base}/${asset}`);
await pipeline(res, createWriteStream(tmp));
// The default transport resolves an IncomingMessage carrying
// Content-Length; injected test streams (and chunked responses) do not.
const headers = (res as { headers?: Record<string, string | string[] | undefined> }).headers;
const declared = headers ? Number(Array.isArray(headers['content-length']) ? headers['content-length'][0] : headers['content-length']) : NaN;
const bytesTotal = Number.isFinite(declared) && declared > 0 ? declared : null;
let bytesReceived = 0;
const emit = (phase: BinaryUpdatePhase): void => {
deps.onProgress?.({ phase, bytesReceived, bytesTotal });
};
const counter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytesReceived += chunk.length;
emit('download');
callback(null, chunk);
},
});
emit('download');
await pipeline(res, counter, createWriteStream(tmp));
emit('checksum');
const expected = await fetchChecksum(`${base}/${asset}.sha256`);
if (expected) {
const actual = await sha256File(tmp);
Expand All @@ -274,6 +307,7 @@ export async function replaceStandaloneBinary(
// floor; an embedded native built on a newer distro can still fail at dlopen.
// Probe the complete module graph before the atomic rename so self-update has
// the same fail-safe contract as npm postinstall and install.sh.
emit('probe');
const probe = probeBinary(tmp);
if (probe.error || probe.status !== 0) {
const raw = probe.error?.message || probe.stderr || `exit ${probe.status ?? probe.signal ?? 'unknown'}`;
Expand All @@ -285,6 +319,7 @@ export async function replaceStandaloneBinary(
throw new Error(`${asset} 版本校验失败(期望 ${version},实际 ${candidateVersion || 'unknown'}),保留现有版本`);
}
// Atomic swap. NOT a write to `target` — that is ETXTBSY (see header).
emit('swap');
renameSync(tmp, target);
return { asset, target, bytes };
} catch (error) {
Expand Down
31 changes: 28 additions & 3 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { createConfigApi } from './core/plugins/runtime.js';
import { createHmac, randomBytes } from 'node:crypto';
import { logger } from './utils/logger.js';
import { isStandaloneBinary } from './core/self-spawn.js';
import { currentUpdateStrategy, replaceStandaloneBinary, releaseAssetName } from './core/binary-self-update.js';
import { currentUpdateStrategy, replaceStandaloneBinary, releaseAssetName, type BinaryUpdatePhase } from './core/binary-self-update.js';
import { gracefulProcessExitCode } from './pm2-graceful-exit.js';
import { config, isWildcardBindHost } from './config.js';
import { createCompanionApi, loadCompanionSecret, type CompanionRuntime } from './dashboard/companion-api.js';
Expand Down Expand Up @@ -1914,6 +1914,15 @@ let updateInFlight = false;
// update, and restart requests do not reuse the removed old runtime realpath.
let lastSuccessfulUpdatePlan: GlobalInstallPlan | undefined;
let pendingBinaryRestart: { target: string; version: string } | undefined;
// Live progress of a standalone-binary download running in THIS dashboard
// process (the ~174MB asset makes the run request long-lived). Surfaced via
// /api/update/status for the UI poller; undefined outside a binary swap.
let binaryUpdateProgress: {
version: string;
phase: BinaryUpdatePhase;
bytesReceived: number;
bytesTotal: number | null;
} | undefined;

// Local-dev counterpart: the checkout a successful /api/update/run built, and
// its post-build HEAD. Pinned so the follow-up /api/update/restart applies THIS
Expand Down Expand Up @@ -4745,6 +4754,12 @@ const server = createServer(async (req, res) => {
: installPlan ? formatGlobalInstallCommand(installPlan) : null,
node: checkNode(),
installs: detectBotmuxInstalls(),
// In-process only: a CLI/maintenance update in another process holds
// the same file lock but does not flip this flag. The UI treats a 409
// plus `updateInFlight === false` as a cross-process update with unknown
// progress rather than declaring success.
updateInFlight,
...(binaryUpdateProgress ? { binaryUpdate: binaryUpdateProgress } : {}),
});
}

Expand Down Expand Up @@ -4849,14 +4864,19 @@ const server = createServer(async (req, res) => {
await withFileLock(globalInstallUpdateLockTarget(), async () => {
acquired = true;
if (hasActiveRestartLease()) { blockedByRestart = true; return; }
await replaceStandaloneBinary(newVersion, runStrategy.target);
await replaceStandaloneBinary(newVersion, runStrategy.target, {
onProgress: (progress) => {
binaryUpdateProgress = { version: newVersion, ...progress };
},
});
pendingBinaryRestart = { target: runStrategy.target, version: newVersion };
}, { maxWaitMs: 2_000 });
} catch (e) {
if (!acquired) return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
return jsonRes(res, 500, { ok: false, error: 'install_failed', detail: e instanceof Error ? e.message : String(e) });
} finally {
updateInFlight = false;
binaryUpdateProgress = undefined;
}
if (blockedByRestart) return jsonRes(res, 409, { ok: false, error: 'restart_in_flight' });
return jsonRes(res, 200, {
Expand Down Expand Up @@ -5032,7 +5052,11 @@ const server = createServer(async (req, res) => {
return;
}
} else {
await replaceStandaloneBinary(targetVersion, binaryTarget!);
await replaceStandaloneBinary(targetVersion, binaryTarget!, {
onProgress: (progress) => {
binaryUpdateProgress = { version: targetVersion, ...progress };
},
});
pendingBinaryRestart = { target: binaryTarget!, version: targetVersion };
}

Expand Down Expand Up @@ -5120,6 +5144,7 @@ const server = createServer(async (req, res) => {
return;
} finally {
updateInFlight = false;
binaryUpdateProgress = undefined;
}
}

Expand Down
Loading
Loading