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:
2026-09-02 00:40:58 -07:00
parent 2bcb6c7e98
commit 835a09fa67
9 changed files with 172 additions and 17 deletions
@@ -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-')),
[],
);
});
+38 -7
View File
@@ -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);
});
+7 -1
View File
@@ -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();
}