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
This commit is contained in:
2026-07-17 23:04:56 -07:00
parent 2398f5c030
commit 44959ed282
9 changed files with 210 additions and 2 deletions
+4
View File
@@ -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.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed type: fixed
area: app 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.
+62
View File
@@ -1,5 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; 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); 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 () => { test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
{ {
+1
View File
@@ -461,6 +461,7 @@ export class AnkiIntegration {
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
findNotes: async (query, options) => findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[], (await this.client.findNotes(query, options)) as number[],
notifyUnavailable: (message) => this.showStatusNotification(message),
logInfo: (message, ...args) => log.info(message, ...args), logInfo: (message, ...args) => log.info(message, ...args),
logWarn: (message, ...args) => log.warn(message, ...args), logWarn: (message, ...args) => log.warn(message, ...args),
logError: (message, ...args) => log.error(message, ...args), logError: (message, ...args) => log.error(message, ...args),
@@ -543,3 +543,41 @@ test('proxy detects self-referential loop configuration', () => {
assert.equal(result, true); 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');
}
});
@@ -27,6 +27,7 @@ export interface AnkiConnectProxyServerDeps {
logInfo: (message: string, ...args: unknown[]) => void; logInfo: (message: string, ...args: unknown[]) => void;
logWarn: (message: string, ...args: unknown[]) => void; logWarn: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void; logError: (message: string, ...args: unknown[]) => void;
notifyUnavailable?: (message: string) => void;
} }
export class AnkiConnectProxyServer { export class AnkiConnectProxyServer {
@@ -78,7 +79,23 @@ export class AnkiConnectProxyServer {
void this.handleRequest(req, res, options.upstreamUrl); void this.handleRequest(req, res, options.upstreamUrl);
}); });
const server = this.server;
this.server.on('error', (error) => { 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.rejectReady?.(error as Error);
this.resolveReady = null; this.resolveReady = null;
this.rejectReady = null; this.rejectReady = null;
+58
View File
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG } from './config/definitions';
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
import { import {
configureEarlyAppPaths, configureEarlyAppPaths,
exitBackgroundBootstrap,
normalizeLaunchMpvExtraArgs, normalizeLaunchMpvExtraArgs,
normalizeStartupArgv, normalizeStartupArgv,
normalizeLaunchMpvTargets, normalizeLaunchMpvTargets,
@@ -21,10 +22,17 @@ import {
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs, hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl, shouldForwardStartupArgvViaAppControl,
applyBackgroundBootstrapCommandLineSwitches,
applyEarlyLinuxCommandLineSwitches, applyEarlyLinuxCommandLineSwitches,
resolveLinuxPasswordStoreValue, resolveLinuxPasswordStoreValue,
} from './main-entry-runtime'; } 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', () => { test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
try { 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', () => { test('transported AppImage visibility commands forward through app control', () => {
assert.equal( assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], { shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
+24
View File
@@ -35,6 +35,10 @@ type EarlyAppLike = {
setPath: (name: 'userData', value: string) => void; setPath: (name: 'userData', value: string) => void;
}; };
type BackgroundBootstrapAppLike = {
exit: (code: number) => void;
};
type CommandLineLike = { type CommandLineLike = {
appendSwitch: (name: string, value?: string) => void; 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 { function consumesLaunchMpvValue(token: string): boolean {
return ( return (
token.startsWith('--') && token.startsWith('--') &&
@@ -241,6 +261,10 @@ export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.Process
return true; return true;
} }
export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
app.exit(0);
}
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean { export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false; if (env.ELECTRON_RUN_AS_NODE === '1') return false;
const args = parseCliArgs(argv); const args = parseCliArgs(argv);
+5 -1
View File
@@ -3,7 +3,9 @@ import { spawn } from 'node:child_process';
import { app, dialog, shell } from 'electron'; import { app, dialog, shell } from 'electron';
import { printHelp } from './cli/help'; import { printHelp } from './cli/help';
import { import {
applyBackgroundBootstrapCommandLineSwitches,
configureEarlyAppPaths, configureEarlyAppPaths,
exitBackgroundBootstrap,
normalizeLaunchMpvExtraArgs, normalizeLaunchMpvExtraArgs,
normalizeLaunchMpvTargets, normalizeLaunchMpvTargets,
normalizeStartupArgv, normalizeStartupArgv,
@@ -174,6 +176,7 @@ function createWindowsRuntimePluginPolicy() {
process.argv = normalizeStartupArgv(process.argv, process.env); process.argv = normalizeStartupArgv(process.argv, process.env);
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv); applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
applySanitizedEnv(sanitizeStartupEnv(process.env)); applySanitizedEnv(sanitizeStartupEnv(process.env));
applyBackgroundBootstrapCommandLineSwitches(app.commandLine, process.argv, process.env);
const userDataPath = configureEarlyAppPaths(app); const userDataPath = configureEarlyAppPaths(app);
const reportFatalError = createFatalErrorReporter({ const reportFatalError = createFatalErrorReporter({
showErrorBox: (title, details) => dialog.showErrorBox(title, details), showErrorBox: (title, details) => dialog.showErrorBox(title, details),
@@ -308,7 +311,8 @@ async function runEntryProcess(): Promise<void> {
env: sanitizeBackgroundEnv(process.env), env: sanitizeBackgroundEnv(process.env),
}); });
child.unref(); child.unref();
process.exit(0); // Let Electron stop bootstrap Chromium children before its AppImage mount is released.
exitBackgroundBootstrap(app);
return; return;
} }