fix(plugins): SIGKILL exec children that ignore SIGTERM

- Escalate timed-out or aborted plugin exec children to SIGKILL after a grace period
- Move ai-explain example to assets/plugins and convert it to CommonJS
- Update plugin docs for the new path and the .js/.mjs module formats
This commit is contained in:
sudacode committed 2026-10-26 21:55:51 -07:00
1 parent ca534cd232
commit 0f53b8beca
6 files changed
+76 -11

No files matched your search

@@ -1,8 +1,8 @@
// Sends the current subtitle line, with a few lines of context, to `codex exec` and shows the
// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const { mkdtemp, readFile, rm } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const DEFAULT_SETTINGS = {
codexPath: 'codex',
@@ -32,7 +32,7 @@ const ACTIONS = {
},
};
export function activate(ctx) {
function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
for (const [name, action] of Object.entries(ACTIONS)) {
const prompt = settings.prompts?.[name] ?? action.prompt;
@@ -49,7 +49,11 @@ function quote(text) {
// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
function oneLine(text) {
return text.split('\n').map((part) => part.trim()).filter(Boolean).join(' ');
return text
.split('\n')
.map((part) => part.trim())
.filter(Boolean)
.join(' ');
}
function tail(text, lineCount) {
@@ -126,3 +130,5 @@ async function ask(ctx, settings, title, instructions) {
await rm(workDir, { recursive: true, force: true });
}
}
module.exports = { activate };
@@ -1,5 +1,5 @@
{
"name": "AI Explain",
"version": "1.0.0",
"main": "main.mjs"
"main": "main.js"
}
+6 -4
View File
@@ -38,7 +38,7 @@ Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
## Example: explain the current line with Codex
The repository ships [`examples/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/examples/plugins/ai-explain). It sends the current line, plus the lines around it, to [`codex exec`](https://github.com/openai/codex) and shows the answer in a panel. It needs a logged-in Codex CLI.
The repository ships [`assets/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/ai-explain). It sends the current line, plus the lines around it, to [`codex exec`](https://github.com/openai/codex) and shows the answer in a panel. It needs a logged-in Codex CLI.
| Action | What it does |
| ----------- | --------------------------------------------------------------- |
@@ -62,13 +62,13 @@ Press `Esc` to close the panel. Closing it, or starting another request, stops t
A plugin folder needs a `plugin.json`:
```json
{ "name": "My Plugin", "version": "1.0.0", "main": "main.mjs" }
{ "name": "My Plugin", "version": "1.0.0", "main": "main.js" }
```
`main` points to a CommonJS or ES module that exports `activate(ctx)`. It may return a cleanup function, or the module can export `deactivate()`. Everything a plugin registers through `ctx` (listeners, timers, actions, panels, running programs) is cleaned up automatically when SubMiner quits.
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. It may return a cleanup function, or the module can export `deactivate()`. Everything a plugin registers through `ctx` (listeners, timers, actions, panels, running programs) is cleaned up automatically when SubMiner quits.
```js
export function activate(ctx) {
function activate(ctx) {
ctx.mpv.on('media-path-change', ({ path }) => ctx.log.info(`Now playing ${path}`));
ctx.every(60_000, () => ctx.log.info('Still watching'));
ctx.actions.register('hello', () => {
@@ -76,6 +76,8 @@ export function activate(ctx) {
ctx.ui.panel({ title: 'Hello', markdown: line ? `> ${line.text}` : '_No subtitle_' });
});
}
module.exports = { activate };
```
An error thrown by a plugin is logged and does not crash SubMiner. A plugin that blocks with a long synchronous loop still freezes the app, so keep slow work asynchronous.
+1 -1
View File
@@ -34,7 +34,7 @@ Read when: you need to find the owner module for a behavior or test surface
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/renderer/modals/plugin-panel*.ts`; example in `examples/plugins/`
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
- Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
+48
View File
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { setTimeout as sleep } from 'node:timers/promises';
import { execPluginCommand } from './exec';
@@ -53,6 +57,50 @@ test('aborting kills the child and rejects with AbortError', async () => {
await assert.rejects(pending, { name: 'AbortError' });
});
test('force-kills a child that ignores SIGTERM after the grace period', async (t) => {
if (process.platform === 'win32') return t.skip('POSIX signals only');
const pidFile = path.join(await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-')), 'pid');
const controller = new AbortController();
const pending = execPluginCommand(
runtime,
[
'-e',
`process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`,
],
{ signal: controller.signal },
100,
);
let pid = 0;
for (let i = 0; i < 100 && !pid; i += 1) {
pid = Number(await readFile(pidFile, 'utf8').catch(() => '0'));
if (!pid) await sleep(20);
}
assert.ok(pid > 0, 'child never reported its pid');
try {
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
let alive = true;
for (let i = 0; i < 100 && alive; i += 1) {
await sleep(20);
alive = isRunning(pid);
}
assert.equal(alive, false, 'child survived SIGTERM');
} finally {
if (isRunning(pid)) process.kill(pid, 'SIGKILL');
await rm(path.dirname(pidFile), { recursive: true, force: true });
}
});
function isRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
test('rejects when the binary does not exist', async () => {
await assert.rejects(
execPluginCommand('subminer-definitely-missing-binary', []),
+9
View File
@@ -4,6 +4,7 @@ import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
import type { PluginExecOptions, PluginExecResult } from './types';
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
const DEFAULT_KILL_GRACE_MS = 3_000;
function appendCapped(current: string, chunk: string): string {
if (current.length >= MAX_CAPTURED_OUTPUT_CHARS) return current;
@@ -13,11 +14,13 @@ function appendCapped(current: string, chunk: string): string {
/**
* Runs `command` without a shell and collects its output. Resolves with the exit code on any exit,
* including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts.
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs`.
*/
export function execPluginCommand(
command: string,
args: string[],
options: PluginExecOptions = {},
killGraceMs = DEFAULT_KILL_GRACE_MS,
): Promise<PluginExecResult> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
@@ -45,9 +48,15 @@ export function execPluginCommand(
finish();
};
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
const killWith = (error: Error): void => {
settle(() => reject(error));
child.kill('SIGTERM');
const forceKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
}, killGraceMs);
forceKill.unref();
child.once('exit', () => clearTimeout(forceKill));
};
function onAbort(): void {