mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 07:43:13 +08:00
387 lines
16 KiB
JavaScript
387 lines
16 KiB
JavaScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
import { isAbsolute, join } from 'node:path';
|
|
import semver from 'semver';
|
|
|
|
import manifest from '../../package.json' with { type: 'json' };
|
|
import { withSessionBindingLock } from '../../src/channels/shared/session-binding-lock.mjs';
|
|
import { NPM_REGISTRY, PACKAGE_NAME } from './update-runtime.mjs';
|
|
|
|
const ACTIVE_STATES = new Set(['installing', 'verifying']);
|
|
const JOB_STATES = new Set([...ACTIVE_STATES, 'restart-required', 'completed', 'failed', 'interrupted']);
|
|
const MAX_METADATA_BYTES = 256 * 1024;
|
|
const SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
|
|
const NO_LOCK = Symbol('no update lock');
|
|
|
|
export function updateError(code) {
|
|
return Object.assign(new Error(code), { code });
|
|
}
|
|
|
|
/** Read only the fixed npm package; neither RPC callers nor registry metadata choose a command. */
|
|
export async function fetchNpmRelease(fetchImpl = globalThis.fetch, timeoutMs = 10_000) {
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(`${NPM_REGISTRY}${encodeURIComponent(PACKAGE_NAME)}/latest`, {
|
|
headers: { accept: 'application/json' },
|
|
redirect: 'error',
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!response.ok) throw updateError('check-failed');
|
|
if (Number(response.headers.get('content-length')) > MAX_METADATA_BYTES) {
|
|
throw updateError('invalid-release');
|
|
}
|
|
const chunks = [];
|
|
let length = 0;
|
|
for await (const chunk of response.body) {
|
|
length += chunk.byteLength;
|
|
if (length > MAX_METADATA_BYTES) throw updateError('invalid-release');
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
const version = value.version;
|
|
if (value.name !== PACKAGE_NAME || typeof version !== 'string'
|
|
|| semver.valid(version) !== version || semver.prerelease(version)) {
|
|
throw updateError('invalid-release');
|
|
}
|
|
const nodeRange = value.engines?.node;
|
|
if (nodeRange !== undefined && (typeof nodeRange !== 'string' || !semver.validRange(nodeRange))) {
|
|
throw updateError('invalid-release');
|
|
}
|
|
const tarball = new URL(value.dist?.tarball);
|
|
const integrity = value.dist?.integrity;
|
|
if (tarball.origin !== new URL(NPM_REGISTRY).origin || tarball.username || tarball.password
|
|
|| tarball.search || tarball.hash
|
|
|| tarball.pathname !== `/@xmanrui/dsh-im/-/dsh-im-${version}.tgz`
|
|
|| typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) {
|
|
throw updateError('invalid-release');
|
|
}
|
|
return { version, nodeRange: nodeRange ?? '*', integrity, tarball: tarball.href };
|
|
} catch (error) {
|
|
if (error?.code === 'invalid-release' || error instanceof SyntaxError || error instanceof TypeError && response?.ok) {
|
|
throw updateError('invalid-release');
|
|
}
|
|
throw updateError('check-failed');
|
|
}
|
|
}
|
|
|
|
function pathsFor(environment) {
|
|
if (!isAbsolute(environment.homeDir ?? '') || !isAbsolute(environment.profileDir ?? '')) return null;
|
|
const key = createHash('sha256').update(environment.profileDir).digest('hex').slice(0, 24);
|
|
const directory = join(environment.homeDir, 'updates', 'dsh-im', key);
|
|
return {
|
|
directory,
|
|
state: join(directory, 'state.json'),
|
|
lock: join(directory, 'install.lock'),
|
|
backup: join(directory, 'before.json'),
|
|
};
|
|
}
|
|
|
|
async function readJson(path, missing = null) {
|
|
try {
|
|
if ((await stat(path)).size > 10 * 1024 * 1024) throw updateError('state-unavailable');
|
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return missing;
|
|
throw updateError('state-unavailable');
|
|
}
|
|
}
|
|
|
|
async function writeJson(path, value) {
|
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
try {
|
|
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
await rename(temporary, path);
|
|
} catch {
|
|
throw updateError('state-unavailable');
|
|
} finally {
|
|
await unlink(temporary).catch((error) => {
|
|
if (error.code !== 'ENOENT') throw updateError('state-unavailable');
|
|
});
|
|
}
|
|
}
|
|
|
|
function processAlive(pid) {
|
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return error.code !== 'ESRCH';
|
|
}
|
|
}
|
|
|
|
function publicJob(job) {
|
|
if (!job) return null;
|
|
const { id, state, targetVersion, message } = job;
|
|
return { id, state, targetVersion, message };
|
|
}
|
|
|
|
/** One update job per profile. Persist intent before starting pnpm, and never apply a restart here. */
|
|
export function createUpdateService({
|
|
runtime,
|
|
runningVersion = manifest.version,
|
|
nodeVersion = process.versions.node,
|
|
fetchImpl = globalThis.fetch,
|
|
now = Date.now,
|
|
checkTimeoutMs = 10_000,
|
|
confirmationTtlMs = 10 * 60_000,
|
|
installTimeoutMs = 15 * 60_000,
|
|
} = {}) {
|
|
const queue = {};
|
|
let checked = null;
|
|
let checking = null;
|
|
let lastCheckAt = -Infinity;
|
|
let activeJob = null;
|
|
let activeTask = null;
|
|
let abortController = null;
|
|
let unsavedJob = null;
|
|
let disposed = false;
|
|
|
|
function assertActive() {
|
|
if (disposed) throw updateError('disposed');
|
|
}
|
|
|
|
async function readJob(environment) {
|
|
const paths = pathsFor(environment);
|
|
if (!paths) return null;
|
|
// A failed final write must not make this Host display the old "installing"
|
|
// record forever. Keep the lock and expose the outcome we actually observed.
|
|
if (unsavedJob?.statePath === paths.state) return unsavedJob.job;
|
|
let job = await readJson(paths.state);
|
|
const lock = await readJson(paths.lock, NO_LOCK);
|
|
if (!job) {
|
|
if (lock !== NO_LOCK) {
|
|
return { id: 'locked', state: 'interrupted', message: 'recovery-required', targetVersion: null };
|
|
}
|
|
return null;
|
|
}
|
|
if (!JOB_STATES.has(job.state) || typeof job.id !== 'string' || !semver.valid(job.targetVersion)) {
|
|
throw updateError('state-unavailable');
|
|
}
|
|
if (ACTIVE_STATES.has(job.state) && job.id !== activeJob?.id) {
|
|
if (lock === NO_LOCK || lock?.id !== job.id || !processAlive(lock?.pid)) {
|
|
job = { ...job, state: 'interrupted', message: 'recovery-required' };
|
|
}
|
|
}
|
|
if (job.id !== activeJob?.id && !ACTIVE_STATES.has(job.state)) {
|
|
if (lock !== NO_LOCK) return { ...job, state: 'interrupted', message: 'recovery-required' };
|
|
// A later Host can retry after a verified manual repair. Never infer this
|
|
// from version equality while an old process lock is still present.
|
|
if (['failed', 'interrupted'].includes(job.state) && environment.packageValid === true
|
|
&& environment.installedVersion === runningVersion && !environment.blockedReason) {
|
|
return job.targetVersion === runningVersion
|
|
? { ...job, state: 'completed', message: 'recovered' }
|
|
: { ...job, recoverable: true };
|
|
}
|
|
}
|
|
if (job.state === 'restart-required' || job.state === 'completed') {
|
|
if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
|
|
return { ...job, state: 'interrupted', message: 'installation-changed' };
|
|
}
|
|
return { ...job, state: runningVersion === job.targetVersion ? 'completed' : 'restart-required' };
|
|
}
|
|
return job;
|
|
}
|
|
|
|
function snapshot(environment, job) {
|
|
let blockedReason = environment.blockedReason ?? checked?.blockedReason ?? null;
|
|
if (job?.state === 'interrupted' && !job.recoverable
|
|
|| job?.state === 'failed' && environment.installedVersion !== runningVersion) {
|
|
blockedReason = 'recovery-required';
|
|
} else if (job?.state === 'restart-required' || environment.installedVersion && environment.installedVersion !== runningVersion) {
|
|
blockedReason = 'pending-restart';
|
|
}
|
|
const busy = ACTIVE_STATES.has(job?.state);
|
|
const canInstall = Boolean(environment.eligible && !blockedReason && !busy && checked?.checkId
|
|
&& now() < checked.expiresAt && checked.installationKey === environment.installationKey
|
|
&& semver.valid(runningVersion) && semver.gt(checked.release.version, runningVersion));
|
|
return {
|
|
runningVersion,
|
|
installedVersion: environment.installedVersion ?? null,
|
|
latestVersion: checked?.release.version ?? null,
|
|
profileName: environment.profileName ?? null,
|
|
environmentKind: environment.environmentKind ?? 'cli',
|
|
// Preserve source protection even when restart/recovery takes status priority.
|
|
sourceInstall: environment.sourceInstall === true || environment.blockedReason === 'source-install',
|
|
canInstall,
|
|
blockedReason,
|
|
checkedAt: checked?.checkedAt ?? null,
|
|
checkId: canInstall ? checked.checkId : null,
|
|
job: publicJob(job),
|
|
};
|
|
}
|
|
|
|
async function status() {
|
|
assertActive();
|
|
const environment = await runtime.inspect();
|
|
return snapshot(environment, await readJob(environment));
|
|
}
|
|
|
|
async function check() {
|
|
assertActive();
|
|
if (checking) return checking;
|
|
// Collapse double clicks without turning a failed request into a cached success.
|
|
if (checked?.checkId && now() - lastCheckAt < 2_000) return status();
|
|
lastCheckAt = now();
|
|
checking = (async () => {
|
|
try {
|
|
const environment = await runtime.inspect({ preflight: true });
|
|
const release = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
|
|
assertActive();
|
|
checked = {
|
|
release,
|
|
checkId: randomUUID(),
|
|
checkedAt: now(),
|
|
expiresAt: now() + confirmationTtlMs,
|
|
installationKey: environment.installationKey,
|
|
profileDir: environment.profileDir,
|
|
blockedReason: environment.blockedReason
|
|
?? (!semver.satisfies(nodeVersion, release.nodeRange) ? 'incompatible-node' : null),
|
|
};
|
|
return snapshot(environment, await readJob(environment));
|
|
} catch (error) {
|
|
if (checked) checked = { ...checked, checkId: null, expiresAt: 0 };
|
|
throw error;
|
|
} finally {
|
|
checking = null;
|
|
}
|
|
})();
|
|
return checking;
|
|
}
|
|
|
|
async function releaseLock(paths, id) {
|
|
const lock = await readJson(paths.lock);
|
|
if (lock?.id === id) await unlink(paths.lock);
|
|
}
|
|
|
|
async function backupProfile(environment, paths, job) {
|
|
const files = {};
|
|
for (const filename of SNAPSHOT_FILES) {
|
|
try {
|
|
const path = join(environment.profileDir, filename);
|
|
if ((await stat(path)).size > 3 * 1024 * 1024) throw updateError('state-unavailable');
|
|
files[filename] = await readFile(path, 'utf8');
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw updateError('state-unavailable');
|
|
}
|
|
}
|
|
// Keep only the previous attempt's small manifests, never credentials or the entire home.
|
|
await writeJson(paths.backup, { jobId: job.id, previousVersion: job.previousVersion, files });
|
|
}
|
|
|
|
function rememberUnsavedJob(paths) {
|
|
activeJob = { ...activeJob, state: 'interrupted', message: 'state-unavailable' };
|
|
unsavedJob = { statePath: paths.state, job: activeJob };
|
|
}
|
|
|
|
async function execute(paths, job, originalEnvironment) {
|
|
const deadline = AbortSignal.timeout(installTimeoutMs);
|
|
try {
|
|
await runtime.install(job.targetVersion, {
|
|
signal: AbortSignal.any([abortController.signal, deadline]),
|
|
expectedInstallationKey: originalEnvironment.installationKey,
|
|
});
|
|
if (disposed) throw updateError('interrupted');
|
|
activeJob = { ...activeJob, state: 'verifying', updatedAt: now() };
|
|
await writeJson(paths.state, activeJob);
|
|
const environment = await runtime.inspect();
|
|
if (environment.homeDir !== originalEnvironment.homeDir || environment.profileDir !== originalEnvironment.profileDir
|
|
|| environment.profileName !== originalEnvironment.profileName || environment.blockedReason === 'installation-changed') {
|
|
throw updateError('installation-changed');
|
|
}
|
|
if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
|
|
throw updateError('verify-failed');
|
|
}
|
|
if (environment.blockedReason && environment.blockedReason !== 'pending-restart') throw updateError('verify-failed');
|
|
activeJob = { ...activeJob, state: 'restart-required', message: null, updatedAt: now() };
|
|
await writeJson(paths.state, activeJob);
|
|
} catch (error) {
|
|
const timedOut = deadline.aborted || error.code === 'install-timeout';
|
|
const interrupted = disposed || abortController.signal.aborted
|
|
|| !timedOut && ['interrupted', 'install-interrupted'].includes(error.code);
|
|
activeJob = {
|
|
...activeJob,
|
|
state: interrupted ? 'interrupted' : 'failed',
|
|
message: interrupted ? 'interrupted' : timedOut ? 'install-timeout'
|
|
: ['verify-failed', 'state-unavailable', 'installation-changed', 'registry-conflict'].includes(error.code)
|
|
? error.code : 'install-failed',
|
|
updatedAt: now(),
|
|
};
|
|
try {
|
|
await writeJson(paths.state, activeJob);
|
|
} catch {
|
|
// Retain the lock when the final record cannot be saved; the next Host must inspect it.
|
|
rememberUnsavedJob(paths);
|
|
return;
|
|
}
|
|
}
|
|
await releaseLock(paths, job.id);
|
|
}
|
|
|
|
function install({ checkId, requestId }) {
|
|
return withSessionBindingLock(queue, 'install', async () => {
|
|
assertActive();
|
|
let environment = await runtime.inspect({ preflight: true });
|
|
const previous = await readJob(environment);
|
|
if (previous?.requestId === requestId) return snapshot(environment, previous);
|
|
if (ACTIVE_STATES.has(previous?.state) || previous?.state === 'restart-required') throw updateError('update-busy');
|
|
const confirmation = checked;
|
|
if (!confirmation || !checkId || confirmation.checkId !== checkId || now() >= confirmation.expiresAt) {
|
|
throw updateError('check-expired');
|
|
}
|
|
if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
|
|
throw updateError('installation-changed');
|
|
}
|
|
if (!snapshot(environment, previous).canInstall) throw updateError(environment.blockedReason ?? 'update-busy');
|
|
const paths = pathsFor(environment);
|
|
if (!paths) throw updateError('state-unavailable');
|
|
const job = {
|
|
id: randomUUID(), requestId, state: 'installing', message: null,
|
|
targetVersion: confirmation.release.version, previousVersion: environment.installedVersion,
|
|
startedAt: now(), updatedAt: now(),
|
|
};
|
|
let locked = false;
|
|
try {
|
|
await mkdir(paths.directory, { recursive: true, mode: 0o700 });
|
|
const lock = await open(paths.lock, 'wx', 0o600);
|
|
locked = true;
|
|
try {
|
|
await lock.writeFile(JSON.stringify({ id: job.id, pid: process.pid, startedAt: now() }));
|
|
} finally {
|
|
await lock.close();
|
|
}
|
|
const currentRelease = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
|
|
if (JSON.stringify(currentRelease) !== JSON.stringify(confirmation.release)) throw updateError('check-expired');
|
|
environment = await runtime.inspect({ preflight: true });
|
|
if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
|
|
throw updateError('installation-changed');
|
|
}
|
|
if (!environment.eligible || environment.blockedReason) throw updateError(environment.blockedReason ?? 'update-busy');
|
|
assertActive();
|
|
await backupProfile(environment, paths, job);
|
|
await writeJson(paths.state, job);
|
|
assertActive();
|
|
} catch (error) {
|
|
if (locked) await releaseLock(paths, job.id);
|
|
if (error.code === 'EEXIST') throw updateError('update-busy');
|
|
if (error.code?.startsWith('E')) throw updateError('state-unavailable');
|
|
throw error;
|
|
}
|
|
activeJob = job;
|
|
abortController = new AbortController();
|
|
activeTask = execute(paths, job, environment).catch(() => {
|
|
// Keep unknown cleanup failures local and keep the on-disk lock for manual recovery.
|
|
rememberUnsavedJob(paths);
|
|
});
|
|
return snapshot(environment, job);
|
|
});
|
|
}
|
|
|
|
async function close() {
|
|
disposed = true;
|
|
abortController?.abort();
|
|
if (activeTask) await activeTask;
|
|
}
|
|
|
|
return Object.freeze({ status, check, install, close });
|
|
}
|