mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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:
@@ -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,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.
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'], {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user