From 835a09fa679c0450be4277f0ffb092e200ea1b41 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 2 Sep 2026 00:40:58 -0700 Subject: [PATCH] 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 --- changes/anime-browser.md | 2 +- packaging/aur/subminer-bin/.SRCINFO | 2 +- packaging/aur/subminer-bin/PKGBUILD | 2 +- src/animeui/format.test.ts | 16 +++-- src/animeui/format.ts | 2 +- .../runtime/anime-bridge-installer.test.ts | 58 ++++++++++++++++++- src/main/runtime/anime-bridge-installer.ts | 45 +++++++++++--- ...nime-browser-runtime-bridge-update.test.ts | 54 +++++++++++++++++ src/main/runtime/anime-browser-runtime.ts | 8 ++- 9 files changed, 172 insertions(+), 17 deletions(-) diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 8ea4e7fe..11d41431 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -30,4 +30,4 @@ area: anime - Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays. - Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal. - The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency. -- SubMiner records which bridge release it installed and, once the bridge is running, checks GitHub for a newer one; when there is, the banner offers an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The Extensions tab shows the bridge version, where it lives, and who updates it. +- SubMiner records which bridge release it installed and, once the bridge is running, checks GitHub for a newer one; when there is, the banner offers an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The updater waits for a bridge still starting to stop and keeps the previous bundle until the replacement is active. The Extensions tab shows the bridge version, where it lives, and who updates it without describing an unchecked install as current. diff --git a/packaging/aur/subminer-bin/.SRCINFO b/packaging/aur/subminer-bin/.SRCINFO index 071ea757..d2e3e50c 100644 --- a/packaging/aur/subminer-bin/.SRCINFO +++ b/packaging/aur/subminer-bin/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = subminer-bin pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration pkgver = 0.6.2 - pkgrel = 1 + pkgrel = 2 url = https://github.com/ksyasuda/SubMiner arch = x86_64 license = GPL-3.0-or-later diff --git a/packaging/aur/subminer-bin/PKGBUILD b/packaging/aur/subminer-bin/PKGBUILD index 061f4e4e..8167fcd7 100644 --- a/packaging/aur/subminer-bin/PKGBUILD +++ b/packaging/aur/subminer-bin/PKGBUILD @@ -2,7 +2,7 @@ pkgname=subminer-bin pkgver=0.6.2 -pkgrel=1 +pkgrel=2 pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration' arch=('x86_64') url='https://github.com/ksyasuda/SubMiner' diff --git a/src/animeui/format.test.ts b/src/animeui/format.test.ts index 16a135ae..8189aef2 100644 --- a/src/animeui/format.test.ts +++ b/src/animeui/format.test.ts @@ -84,8 +84,16 @@ test('describeBridgeInstall says who updates the bridge', () => { }), /v1\.0\.6\.0 is available/, ); - assert.match( - describeBridgeInstall({ origin: 'managed', version: null, dir: '/d', updateAvailable: null }), - /unknown version.*up to date/, - ); +}); + +test('describeBridgeInstall does not treat an unchecked managed bridge as up to date', () => { + const description = describeBridgeInstall({ + origin: 'managed', + version: null, + dir: '/d', + updateAvailable: null, + }); + + assert.match(description, /unknown version.*checks this installation for updates after startup/); + assert.doesNotMatch(description, /up to date/); }); diff --git a/src/animeui/format.ts b/src/animeui/format.ts index 3c5f11e9..3984a704 100644 --- a/src/animeui/format.ts +++ b/src/animeui/format.ts @@ -45,5 +45,5 @@ export function describeBridgeInstall(install: AnimeBrowserBridgeInstall | null) if (install.updateAvailable !== null) { return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner. ${install.updateAvailable} is available from the banner above.`; } - return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner and up to date.`; + return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner. SubMiner checks this installation for updates after startup.`; } diff --git a/src/main/runtime/anime-bridge-installer.test.ts b/src/main/runtime/anime-bridge-installer.test.ts index 5bfe6ef7..b4f67af2 100644 --- a/src/main/runtime/anime-bridge-installer.test.ts +++ b/src/main/runtime/anime-bridge-installer.test.ts @@ -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-')), + [], + ); +}); diff --git a/src/main/runtime/anime-bridge-installer.ts b/src/main/runtime/anime-bridge-installer.ts index 63647ebd..061da605 100644 --- a/src/main/runtime/anime-bridge-installer.ts +++ b/src/main/runtime/anime-bridge-installer.ts @@ -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; + /** Replaces atomic directory moves. Tests only. */ + renameImpl?: typeof rename; onProgress?: (progress: InstallProgress) => void; } @@ -297,12 +299,41 @@ export async function stageBridgeUpdate(options: EnsureBridgeOptions): Promise { - 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 }); + } + } }, }; } diff --git a/src/main/runtime/anime-browser-runtime-bridge-update.test.ts b/src/main/runtime/anime-browser-runtime-bridge-update.test.ts index 8c8bf4fd..bbe31136 100644 --- a/src/main/runtime/anime-browser-runtime-bridge-update.test.ts +++ b/src/main/runtime/anime-browser-runtime-bridge-update.test.ts @@ -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((resolve) => { + releaseFirstStart = resolve; + }); + let markFirstStartEntered: () => void = () => undefined; + const firstStartEntered = new Promise((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); +}); diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index 5e8188cc..0cca8fd7 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -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 { + 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(); }