fix(tools): recover interrupted managed installs

Persist atomic promotion state beside each managed tool, restore usable backups after interrupted renames, and retain precise recovery failures. Verify installed executable hashes from the staged manifest record and make cleanup diagnostics unable to strand repair single-flight state.
This commit is contained in:
Sucukdeluxe
2026-08-12 01:14:17 +02:00
parent d32f42746d
commit 60b5aa8ca9
3 changed files with 400 additions and 83 deletions
+115 -3
View File
@@ -27,10 +27,33 @@ function writeExistingInstallation(installPath: string, contents = 'old executab
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), contents);
}
function writeVerifiedInstallation(installPath: string, contents = 'old executable'): void {
writeExistingInstallation(installPath, contents);
const toolManifest = manifest();
fs.writeFileSync(path.join(installPath, '.tool-manifest.json'), JSON.stringify({
id: toolManifest.id,
version: toolManifest.version,
sourceUrl: toolManifest.sourceUrl,
archiveName: toolManifest.archiveName,
sha256: toolManifest.sha256,
executableHashes: { 'streamlink.exe': sha256(contents) }
}));
}
function writeInterruptedPromotionJournal(installPath: string, backupPath: string, stagingPath: string, phase: string): void {
fs.writeFileSync(`${installPath}.transaction.json`, JSON.stringify({
installationDirectory: installPath,
backupDirectory: backupPath,
stagingDirectory: stagingPath,
phase
}));
}
function createInstaller(options: {
archiveContents?: string;
extract?: (archivePath: string, destinationPath: string) => Promise<void>;
rename?: (sourcePath: string, destinationPath: string) => void;
remove?: (targetPath: string, options: { recursive?: boolean; force?: boolean }) => void;
} = {}) {
const installPath = path.join(directory, 'installed');
const tempPath = path.join(directory, 'temporary');
@@ -50,7 +73,8 @@ function createInstaller(options: {
temporaryDirectory: tempPath,
download,
extract,
rename: options.rename
rename: options.rename,
remove: options.remove
}),
download
};
@@ -127,7 +151,7 @@ describe('managed tool installer', () => {
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
});
it('reports promotion failure when the previous installation cannot be restored', async () => {
it('reports recovery failure when the previous installation cannot be restored', async () => {
const installPath = path.join(directory, 'installed');
const { installer } = createInstaller({
rename: (sourcePath, destinationPath) => {
@@ -145,7 +169,7 @@ describe('managed tool installer', () => {
const result = await installer.repair(manifest());
expect(result.success).toBe(false);
expect(result.error).toBe('promotion-failed');
expect(result.error).toBe('recovery-restore-failed');
});
it('reports a verified manifest version only after a staged install is promoted', async () => {
@@ -208,4 +232,92 @@ describe('managed tool installer', () => {
expect(secondResult.success).toBe(true);
expect(download).toHaveBeenCalledTimes(1);
});
it('restores a valid previous tool at the active path after interruption between backup and promotion', () => {
const { installer, installPath } = createInstaller();
const backupPath = `${installPath}.backup-interrupted`;
const stagingPath = `${installPath}.stage-interrupted`;
writeVerifiedInstallation(backupPath, 'old executable');
writeVerifiedInstallation(stagingPath, 'new executable');
writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created');
expect(installer.status(manifest())).toMatchObject({ state: 'verified', verified: true });
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
expect(fs.existsSync(backupPath)).toBe(false);
expect(fs.existsSync(stagingPath)).toBe(false);
expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false);
});
it('keeps a valid promoted tool after interruption between promotion and journal cleanup', () => {
const { installer, installPath } = createInstaller();
const backupPath = `${installPath}.backup-interrupted`;
const stagingPath = `${installPath}.stage-interrupted`;
writeVerifiedInstallation(installPath, 'new executable');
writeVerifiedInstallation(backupPath, 'old executable');
writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created');
expect(installer.status(manifest())).toMatchObject({ state: 'verified', verified: true });
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
expect(fs.existsSync(backupPath)).toBe(false);
expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false);
});
it('reports a precise recovery failure while preserving the usable backup path', async () => {
const installPath = path.join(directory, 'installed');
const backupPath = `${installPath}.backup-interrupted`;
const stagingPath = `${installPath}.stage-interrupted`;
const { installer } = createInstaller({
rename: (sourcePath, destinationPath) => {
if (sourcePath === backupPath && destinationPath === installPath) {
throw new Error('restore denied');
}
fs.renameSync(sourcePath, destinationPath);
}
});
writeVerifiedInstallation(backupPath, 'old executable');
writeVerifiedInstallation(stagingPath, 'new executable');
writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created');
const result = await installer.repair(manifest());
expect(result.success).toBe(false);
expect(result.error).toBe('recovery-restore-failed');
expect(result.detail).toContain('restore denied');
expect(fs.readFileSync(path.join(backupPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
});
it('does not leave a repair in flight when archive cleanup fails', async () => {
let archiveCleanupFailed = false;
const { installer, download } = createInstaller({
remove: (targetPath, options) => {
if (!archiveCleanupFailed && String(targetPath).includes(manifest().archiveName)) {
archiveCleanupFailed = true;
throw new Error('archive cleanup denied');
}
fs.rmSync(targetPath, options);
}
});
const first = await installer.repair(manifest());
const second = await installer.repair(manifest());
expect(first.success).toBe(true);
expect(first.diagnostics).toContain('archive cleanup denied');
expect(second.success).toBe(true);
expect(download).toHaveBeenCalledTimes(2);
});
it('marks a changed managed executable corrupt and repairs it from a verified archive', async () => {
const { installer, installPath } = createInstaller();
const installed = await installer.repair(manifest());
expect(installed.success).toBe(true);
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'changed executable');
expect(installer.status(manifest())).toMatchObject({ state: 'corrupt', verified: false });
const repaired = await installer.repair(manifest());
expect(repaired.success).toBe(true);
expect(repaired.status).toMatchObject({ state: 'verified', verified: true });
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
});
});