mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(anime): harden bridge updates and startup lifecycle
- Wait for in-flight starts and stop sidecars before replacement - Restore the previous bundle when activation fails - Clarify unchecked managed bridge status and bump the AUR package release
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, mkdtemp, readdir, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, mkdtemp, readdir, rename, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
@@ -232,3 +232,59 @@ test('stageBridgeUpdate downloads beside the install and commit swaps it in', as
|
||||
assert.ok(!(await exists(`${managed}.next`)));
|
||||
assert.ok(await exists(path.join(managed, BUNDLE_MARKER_FILE)));
|
||||
});
|
||||
|
||||
test('stageBridgeUpdate commit leaves the existing install in place when its backup move fails', async () => {
|
||||
const root = await tempRoot();
|
||||
const managed = path.join(root, 'managed');
|
||||
await writeBundle(managed, 'MExtensionServer-v1.0.5.0.jar');
|
||||
await writeBundleMarker(managed, 'v1.0.5.0');
|
||||
const { options } = fakeUpstream();
|
||||
const staged = await stageBridgeUpdate({
|
||||
...options,
|
||||
installDir: managed,
|
||||
systemDirs: [],
|
||||
renameImpl: async () => {
|
||||
throw new Error('backup move failed');
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(staged.commit(), /backup move failed/);
|
||||
|
||||
assert.ok(await exists(path.join(managed, 'MExtensionServer-v1.0.5.0.jar')));
|
||||
assert.equal(await readBundleMarker(managed), 'v1.0.5.0');
|
||||
assert.ok(await exists(path.join(`${managed}.next`, `MExtensionServer-${LATEST}.jar`)));
|
||||
assert.deepEqual(
|
||||
(await readdir(root)).filter((entry) => entry.startsWith('managed.backup-')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('stageBridgeUpdate commit restores the existing install when activation fails', async () => {
|
||||
const root = await tempRoot();
|
||||
const managed = path.join(root, 'managed');
|
||||
await writeBundle(managed, 'MExtensionServer-v1.0.5.0.jar');
|
||||
await writeBundleMarker(managed, 'v1.0.5.0');
|
||||
const { options } = fakeUpstream();
|
||||
let renameCalls = 0;
|
||||
const staged = await stageBridgeUpdate({
|
||||
...options,
|
||||
installDir: managed,
|
||||
systemDirs: [],
|
||||
renameImpl: async (fromPath, toPath) => {
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 2) throw new Error('activation failed');
|
||||
await rename(fromPath, toPath);
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(staged.commit(), /activation failed/);
|
||||
|
||||
assert.equal(renameCalls, 3);
|
||||
assert.ok(await exists(path.join(managed, 'MExtensionServer-v1.0.5.0.jar')));
|
||||
assert.equal(await readBundleMarker(managed), 'v1.0.5.0');
|
||||
assert.ok(await exists(path.join(`${managed}.next`, `MExtensionServer-${LATEST}.jar`)));
|
||||
assert.deepEqual(
|
||||
(await readdir(root)).filter((entry) => entry.startsWith('managed.backup-')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
bundleReleaseUrl,
|
||||
@@ -70,6 +70,8 @@ export interface EnsureBridgeOptions extends BridgeReleaseOptions {
|
||||
systemDirs?: string[];
|
||||
/** Replaces the unzip/tar extraction. Tests only. */
|
||||
extractImpl?: (zipPath: string, targetDir: string) => Promise<void>;
|
||||
/** Replaces atomic directory moves. Tests only. */
|
||||
renameImpl?: typeof rename;
|
||||
onProgress?: (progress: InstallProgress) => void;
|
||||
}
|
||||
|
||||
@@ -297,12 +299,41 @@ export async function stageBridgeUpdate(options: EnsureBridgeOptions): Promise<S
|
||||
return {
|
||||
version: downloaded.tagName,
|
||||
commit: async () => {
|
||||
await rm(options.installDir, { recursive: true, force: true });
|
||||
await rename(stagingDir, options.installDir);
|
||||
const binaries = await findBundleBinaries(options.installDir);
|
||||
if (!binaries)
|
||||
throw new Error('The updated anime bridge is missing its java runtime or jar.');
|
||||
return describeInstall(binaries, options.installDir, 'managed');
|
||||
const backupRoot = await mkdtemp(`${options.installDir}.backup-`);
|
||||
const backupDir = path.join(backupRoot, 'previous');
|
||||
const renameImpl = options.renameImpl ?? rename;
|
||||
let backupHoldsInstall = false;
|
||||
|
||||
try {
|
||||
await renameImpl(options.installDir, backupDir);
|
||||
backupHoldsInstall = true;
|
||||
try {
|
||||
await renameImpl(stagingDir, options.installDir);
|
||||
} catch (replaceError) {
|
||||
try {
|
||||
await renameImpl(backupDir, options.installDir);
|
||||
backupHoldsInstall = false;
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError(
|
||||
[replaceError, restoreError],
|
||||
`Failed to activate the staged anime bridge and restore the previous install. ` +
|
||||
`The previous install remains at ${backupDir}.`,
|
||||
);
|
||||
}
|
||||
throw replaceError;
|
||||
}
|
||||
|
||||
const binaries = await findBundleBinaries(options.installDir);
|
||||
if (!binaries)
|
||||
throw new Error('The updated anime bridge is missing its java runtime or jar.');
|
||||
await rm(backupRoot, { recursive: true, force: true });
|
||||
backupHoldsInstall = false;
|
||||
return describeInstall(binaries, options.installDir, 'managed');
|
||||
} finally {
|
||||
if (!backupHoldsInstall) {
|
||||
await rm(backupRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,3 +208,57 @@ test('requests that arrive mid-update wait for the new bridge instead of startin
|
||||
assert.equal(started(), 2);
|
||||
assert.equal(ensured!.install?.version, 'v1.0.6.0');
|
||||
});
|
||||
|
||||
test('an update waits for an in-flight start and stops its sidecar before commit', async () => {
|
||||
let releaseFirstStart: () => void = () => undefined;
|
||||
const firstStartGate = new Promise<void>((resolve) => {
|
||||
releaseFirstStart = resolve;
|
||||
});
|
||||
let markFirstStartEntered: () => void = () => undefined;
|
||||
const firstStartEntered = new Promise<void>((resolve) => {
|
||||
markFirstStartEntered = resolve;
|
||||
});
|
||||
const events: string[] = [];
|
||||
let startCount = 0;
|
||||
let current = OLD;
|
||||
const { runtime } = await setup({
|
||||
ensureBinaries: async () => current,
|
||||
stageBridgeUpdate: async () => ({
|
||||
version: LATEST,
|
||||
commit: async () => {
|
||||
events.push('commit');
|
||||
current = NEW;
|
||||
return NEW;
|
||||
},
|
||||
}),
|
||||
startSidecar: async () => {
|
||||
const id = ++startCount;
|
||||
events.push(`start:${id}`);
|
||||
if (id === 1) {
|
||||
markFirstStartEntered();
|
||||
await firstStartGate;
|
||||
}
|
||||
return {
|
||||
client: { listAnimeSources: async () => [] } as unknown as AnimeBridgeClient,
|
||||
baseUrl: `http://127.0.0.1:${id}`,
|
||||
port: id,
|
||||
stop: async () => {
|
||||
events.push(`stop:${id}`);
|
||||
},
|
||||
onExit: () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const firstStart = runtime.ensureBridge();
|
||||
await firstStartEntered;
|
||||
const update = runtime.updateBridge();
|
||||
await tick();
|
||||
assert.deepEqual(events, ['start:1']);
|
||||
|
||||
releaseFirstStart();
|
||||
await Promise.all([firstStart, update]);
|
||||
|
||||
assert.deepEqual(events, ['start:1', 'stop:1', 'commit', 'start:2']);
|
||||
assert.equal(runtime.getSnapshot().bridge.install?.version, NEW.version);
|
||||
});
|
||||
|
||||
@@ -250,11 +250,17 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
|
||||
/** Stop the bridge and its proxy on purpose, without disturbing playback state. */
|
||||
async function stopBridge(): Promise<void> {
|
||||
const pendingStart = starting;
|
||||
starting = null;
|
||||
try {
|
||||
await pendingStart;
|
||||
} catch {
|
||||
// A failed start has no sidecar to stop.
|
||||
}
|
||||
const handle = sidecar;
|
||||
const proxy = stripProxy;
|
||||
sidecar = null;
|
||||
stripProxy = null;
|
||||
starting = null;
|
||||
await proxy?.close();
|
||||
await handle?.stop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user