From 44959ed282b28645740866eaecf643f14ea4e8b9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 17 Jul 2026 23:04:56 -0700 Subject: [PATCH] fix(anki,appimage): handle proxy port conflicts and stray GPU child - AnkiConnect proxy now detects EADDRINUSE, logs a warning, and surfaces an overlay notification instead of crashing video startup - Background AppImage bootstrap runs Chromium with in-process-gpu so no GPU child survives app.exit() and outlives the FUSE mount, fixing the remaining DrKonqi "Service Crash" case - Add changelog fragment for the proxy fix; update the AppImage quit fragment with the GPU child root cause --- changes/fix-anki-proxy-port-reuse.md | 4 ++ changes/fix-appimage-quit-service-crash.md | 2 +- src/anki-integration.test.ts | 62 +++++++++++++++++++ src/anki-integration.ts | 1 + .../anki-connect-proxy.test.ts | 38 ++++++++++++ src/anki-integration/anki-connect-proxy.ts | 17 +++++ src/main-entry-runtime.test.ts | 58 +++++++++++++++++ src/main-entry-runtime.ts | 24 +++++++ src/main-entry.ts | 6 +- 9 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 changes/fix-anki-proxy-port-reuse.md diff --git a/changes/fix-anki-proxy-port-reuse.md b/changes/fix-anki-proxy-port-reuse.md new file mode 100644 index 00000000..e6caab4a --- /dev/null +++ b/changes/fix-anki-proxy-port-reuse.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Prevented video startup from crashing when another process already owns the configured AnkiConnect proxy port, and added a notification explaining how to resolve the conflict. diff --git a/changes/fix-appimage-quit-service-crash.md b/changes/fix-appimage-quit-service-crash.md index 0bddadb7..e07d8340 100644 --- a/changes/fix-appimage-quit-service-crash.md +++ b/changes/fix-appimage-quit-service-crash.md @@ -1,4 +1,4 @@ type: fixed area: app -- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: on quit, the AppImage runtime unmounted the FUSE squashfs while Chromium utility children (notably the network service) were still shutting down, killing them with SIGBUS. Background launches (`--background`, used by the mpv plugin and the launcher) now run through a small supervisor that mounts the AppImage via `--appimage-mount`, executes `AppRun` from that mount, and releases the mount only after no process is still executing from it. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to restore the old direct launch. +- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: the short-lived background bootstrap spawned a Chromium GPU child that outlived it (surviving `app.exit`) and died with SIGBUS at session end when the bootstrap's FUSE mount was finally released. The bootstrap now runs with the GPU in-process so it leaves no children behind, and the detached app's mount remains supervised until its Chromium children finish. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to disable the detached-app mount supervisor. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 2cff3587..b4bf358d 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -1,5 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import http from 'node:http'; +import { once } from 'node:events'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -465,6 +467,66 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis assert.equal(privateState.runtime.proxyServer, null); }); +test('AnkiIntegration reports an occupied proxy address through its notification seam', async () => { + const occupiedServer = http.createServer(); + occupiedServer.listen(0, '127.0.0.1'); + await once(occupiedServer, 'listening'); + const occupiedAddress = occupiedServer.address(); + assert.ok(occupiedAddress && typeof occupiedAddress === 'object'); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-proxy-collision-')); + const overlayNotifications: TestOverlayNotificationPayload[] = []; + const integration = new AnkiIntegration( + { + enabled: true, + url: 'http://127.0.0.1:8765', + proxy: { + enabled: true, + host: '127.0.0.1', + port: occupiedAddress.port, + upstreamUrl: 'http://127.0.0.1:8765', + }, + behavior: { + notificationType: 'overlay', + }, + knownWords: { + highlightEnabled: false, + }, + nPlusOne: { + enabled: false, + }, + } as never, + {} as never, + {} as never, + undefined, + undefined, + undefined, + path.join(stateDir, 'known-words-cache.json'), + {}, + undefined, + (payload) => { + overlayNotifications.push(payload as TestOverlayNotificationPayload); + }, + ); + + try { + integration.start(); + await integration.waitUntilReady(); + + assert.deepEqual(overlayNotifications, [ + { + title: 'SubMiner', + body: `AnkiConnect proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`, + variant: 'info', + }, + ]); + } finally { + integration.stop(); + occupiedServer.close(); + await once(occupiedServer, 'close'); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => { const integration = new AnkiIntegration( { diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 1cb36787..e0de77a0 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -461,6 +461,7 @@ export class AnkiIntegration { getDeck: () => this.config.deck, findNotes: async (query, options) => (await this.client.findNotes(query, options)) as number[], + notifyUnavailable: (message) => this.showStatusNotification(message), logInfo: (message, ...args) => log.info(message, ...args), logWarn: (message, ...args) => log.warn(message, ...args), logError: (message, ...args) => log.error(message, ...args), diff --git a/src/anki-integration/anki-connect-proxy.test.ts b/src/anki-integration/anki-connect-proxy.test.ts index 0ab6db5c..c10b1e9c 100644 --- a/src/anki-integration/anki-connect-proxy.test.ts +++ b/src/anki-integration/anki-connect-proxy.test.ts @@ -543,3 +543,41 @@ test('proxy detects self-referential loop configuration', () => { assert.equal(result, true); }); + +test('proxy continues without a local listener when its address is already bound', async () => { + const occupiedServer = http.createServer(); + occupiedServer.listen(0, '127.0.0.1'); + await once(occupiedServer, 'listening'); + const occupiedAddress = occupiedServer.address(); + assert.ok(occupiedAddress && typeof occupiedAddress === 'object'); + const info: string[] = []; + const warnings: string[] = []; + const proxy = new AnkiConnectProxyServer({ + shouldAutoUpdateNewCards: () => true, + processNewCard: async () => undefined, + logInfo: (message) => info.push(message), + logWarn: (message, ...args) => warnings.push([message, ...args].join(' ')), + logError: () => undefined, + }); + + try { + proxy.start({ + host: '127.0.0.1', + port: occupiedAddress.port, + upstreamUrl: 'http://127.0.0.1:8765', + }); + + await proxy.waitUntilReady(); + + assert.equal(proxy.isRunning, false); + assert.deepEqual(warnings, [ + `[anki-proxy] Local proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use; continuing without it. Change ankiConnect.proxy.port or stop the process using that address.`, + ]); + proxy.stop(); + assert.deepEqual(info, []); + } finally { + proxy.stop(); + occupiedServer.close(); + await once(occupiedServer, 'close'); + } +}); diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index 9a8be34a..1cae5f3a 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -27,6 +27,7 @@ export interface AnkiConnectProxyServerDeps { logInfo: (message: string, ...args: unknown[]) => void; logWarn: (message: string, ...args: unknown[]) => void; logError: (message: string, ...args: unknown[]) => void; + notifyUnavailable?: (message: string) => void; } export class AnkiConnectProxyServer { @@ -78,7 +79,23 @@ export class AnkiConnectProxyServer { void this.handleRequest(req, res, options.upstreamUrl); }); + const server = this.server; this.server.on('error', (error) => { + if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') { + this.resolveReady?.(); + this.resolveReady = null; + this.rejectReady = null; + if (this.server === server) { + this.server = null; + } + this.deps.logWarn( + `[anki-proxy] Local proxy unavailable because http://${options.host}:${options.port} is already in use; continuing without it. Change ankiConnect.proxy.port or stop the process using that address.`, + ); + this.deps.notifyUnavailable?.( + `AnkiConnect proxy unavailable because http://${options.host}:${options.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`, + ); + return; + } this.rejectReady?.(error as Error); this.resolveReady = null; this.rejectReady = null; diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index bff95253..2fbcf1e3 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_CONFIG } from './config/definitions'; import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { configureEarlyAppPaths, + exitBackgroundBootstrap, normalizeLaunchMpvExtraArgs, normalizeStartupArgv, normalizeLaunchMpvTargets, @@ -21,10 +22,17 @@ import { shouldHandleStatsDaemonCommandAtEntry, hasTransportedStartupArgs, shouldForwardStartupArgvViaAppControl, + applyBackgroundBootstrapCommandLineSwitches, applyEarlyLinuxCommandLineSwitches, resolveLinuxPasswordStoreValue, } from './main-entry-runtime'; +test('background bootstrap exits through Electron so Chromium children shut down', () => { + const exitCodes: number[] = []; + exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) }); + assert.deepEqual(exitCodes, [0]); +}); + test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { const originalPlatform = process.platform; try { @@ -151,6 +159,56 @@ test('applyEarlyLinuxCommandLineSwitches appends password store before main star ]); }); +test('background bootstrap keeps the GPU in-process so no child outlives its AppImage mount', () => { + const collect = () => { + const switches: Array<[string, string | undefined]> = []; + return { + switches, + commandLine: { + appendSwitch: (name: string, value?: string) => { + switches.push([name, value]); + }, + }, + }; + }; + + const bootstrap = collect(); + applyBackgroundBootstrapCommandLineSwitches( + bootstrap.commandLine, + ['SubMiner.AppImage', '--start', '--background'], + {}, + 'linux', + ); + assert.deepEqual(bootstrap.switches, [['in-process-gpu', undefined]]); + + const detachedChild = collect(); + applyBackgroundBootstrapCommandLineSwitches( + detachedChild.commandLine, + ['SubMiner.AppImage', '--start', '--background'], + { SUBMINER_BACKGROUND_CHILD: '1' }, + 'linux', + ); + assert.deepEqual(detachedChild.switches, []); + + const foreground = collect(); + applyBackgroundBootstrapCommandLineSwitches( + foreground.commandLine, + ['SubMiner.AppImage', '--stop'], + {}, + 'linux', + ); + assert.deepEqual(foreground.switches, []); + + const windows = collect(); + applyBackgroundBootstrapCommandLineSwitches( + windows.commandLine, + ['SubMiner.exe', '--start', '--background'], + {}, + 'win32', + ); + assert.deepEqual(windows.switches, []); +}); + test('transported AppImage visibility commands forward through app control', () => { assert.equal( shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], { diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 3af5cc33..6854ff5f 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -35,6 +35,10 @@ type EarlyAppLike = { setPath: (name: 'userData', value: string) => void; }; +type BackgroundBootstrapAppLike = { + exit: (code: number) => void; +}; + type CommandLineLike = { appendSwitch: (name: string, value?: string) => void; }; @@ -132,6 +136,22 @@ export function applyEarlyLinuxCommandLineSwitches( ); } +// The detach bootstrap exits within milliseconds, but a separate GPU child it +// spawned survives app.exit() and keeps executing from the bootstrap's FUSE +// mount until session teardown finally unmaps it — SIGBUS, surfaced by DrKonqi +// as a "Service Crash" notification on every video close. Keeping the GPU +// in-process means the bootstrap leaves no child behind. +export function applyBackgroundBootstrapCommandLineSwitches( + commandLine: CommandLineLike, + argv: string[], + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== 'linux') return; + if (!shouldDetachBackgroundLaunch(argv, env)) return; + commandLine.appendSwitch('in-process-gpu'); +} + function consumesLaunchMpvValue(token: string): boolean { return ( token.startsWith('--') && @@ -241,6 +261,10 @@ export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.Process return true; } +export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void { + app.exit(0); +} + export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean { if (env.ELECTRON_RUN_AS_NODE === '1') return false; const args = parseCliArgs(argv); diff --git a/src/main-entry.ts b/src/main-entry.ts index c0740571..5f7e5864 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -3,7 +3,9 @@ import { spawn } from 'node:child_process'; import { app, dialog, shell } from 'electron'; import { printHelp } from './cli/help'; import { + applyBackgroundBootstrapCommandLineSwitches, configureEarlyAppPaths, + exitBackgroundBootstrap, normalizeLaunchMpvExtraArgs, normalizeLaunchMpvTargets, normalizeStartupArgv, @@ -174,6 +176,7 @@ function createWindowsRuntimePluginPolicy() { process.argv = normalizeStartupArgv(process.argv, process.env); applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv); applySanitizedEnv(sanitizeStartupEnv(process.env)); +applyBackgroundBootstrapCommandLineSwitches(app.commandLine, process.argv, process.env); const userDataPath = configureEarlyAppPaths(app); const reportFatalError = createFatalErrorReporter({ showErrorBox: (title, details) => dialog.showErrorBox(title, details), @@ -308,7 +311,8 @@ async function runEntryProcess(): Promise { env: sanitizeBackgroundEnv(process.env), }); child.unref(); - process.exit(0); + // Let Electron stop bootstrap Chromium children before its AppImage mount is released. + exitBackgroundBootstrap(app); return; }