Files
SubMiner/src/anime-bridge/sidecar-process.ts
T
sudacode ca5fc341cb fix(anime): harden the extension bridge against untrusted repos and hangs
Addresses CodeRabbit review feedback on the Anime Browser:

- reject repository package/apk names that are not plain identifiers, and
  verify the install target resolves inside the extensions directory
- count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in
  header values so a trailing one cannot eat the list separator
- key the bridge extension-id cache by APK content, so an in-place upgrade
  re-uploads instead of running the previous build
- bound every bridge, release-listing, and download request with a timeout
- enforce the APK size limit while streaming rather than after buffering
- read APK bytes on demand instead of holding a base64 copy per extension
  for the lifetime of the browser
- serialize preference mutations and write the file atomically
- handle the sidecar spawn error event, and wait for the child to exit in
  stop() before returning
- report a failed Anime Browser bootstrap instead of showing the starting
  banner forever
- keep the preferences panel's save confirmation and in-flight multi-select
  edits by re-rendering only on a structural schema change
2026-08-06 21:48:00 -07:00

134 lines
4.5 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import { createServer } from 'node:net';
import { AnimeBridgeClient } from './bridge-client';
import type { BundleBinaries } from './sidecar-bundle';
/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
const READY_POLL_INTERVAL_MS = 500;
/** How long to wait for the child to go after each signal before escalating. */
const STOP_TIMEOUT_MS = 5_000;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Ask the OS for a free loopback port, then hand it to the JVM. */
export async function allocatePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
server.close();
reject(new Error('Could not allocate a loopback port for the anime bridge.'));
return;
}
const { port } = address;
server.close(() => resolve(port));
});
});
}
export interface SidecarHandle {
baseUrl: string;
port: number;
client: AnimeBridgeClient;
stop: () => Promise<void>;
}
export interface StartSidecarOptions {
binaries: BundleBinaries;
port?: number;
readyTimeoutMs?: number;
spawnImpl?: typeof spawn;
onLog?: (line: string) => void;
}
/**
* Launch the bridge and wait until it reports the capabilities this client
* needs. The desktop launch contract is `java -jar MExtensionServer.jar <port>`,
* run from the JAR's own directory.
*/
export async function startSidecar(options: StartSidecarOptions): Promise<SidecarHandle> {
const { binaries } = options;
const port = options.port ?? (await allocatePort());
const baseUrl = `http://127.0.0.1:${port}`;
const spawnProcess = options.spawnImpl ?? spawn;
const child: ChildProcess = spawnProcess(
binaries.javaPath,
['-jar', binaries.jarPath, String(port)],
{
cwd: path.dirname(binaries.jarPath),
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const log = options.onLog;
if (log) {
child.stdout?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
child.stderr?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
}
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let spawnError: Error | null = null;
const hasExited = new Promise<void>((resolve) => {
child.once('exit', (code, signal) => {
exited = { code, signal };
resolve();
});
// A ChildProcess is an EventEmitter: without this listener a failed spawn
// (a missing or non-executable java) throws in the main process instead of
// failing the readiness loop below.
child.once('error', (error: Error) => {
spawnError = error;
if (exited === null) exited = { code: null, signal: null };
resolve();
});
});
const stop = async (): Promise<void> => {
if (exited !== null) return;
// Graceful first: the server exposes a shutdown endpoint.
try {
await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
} catch {
// Falling through to a signal is fine; the endpoint may already be gone.
}
if (exited !== null) return;
child.kill();
// kill() only sends the signal. Wait for the process to actually go, so a
// restart cannot race the old one still holding the port.
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
if (exited === null) {
child.kill('SIGKILL');
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
}
};
const client = new AnimeBridgeClient({ baseUrl });
const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
while (Date.now() < deadline) {
if (spawnError !== null) {
throw new Error(`Anime bridge could not start: ${(spawnError as Error).message}`);
}
if (exited !== null) {
const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
throw new Error(
`Anime bridge exited before becoming ready (code ${code}, signal ${signal}).`,
);
}
if (await client.isReady()) return { baseUrl, port, client, stop };
await delay(READY_POLL_INTERVAL_MS);
}
await stop();
throw new Error(
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
);
}