mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-26 23:35:17 -07:00
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:
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
|
// 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`.
|
// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
|
||||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
const { mkdtemp, readFile, rm } = require('node:fs/promises');
|
||||||
import os from 'node:os';
|
const os = require('node:os');
|
||||||
import path from 'node:path';
|
const path = require('node:path');
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
codexPath: 'codex',
|
codexPath: 'codex',
|
||||||
@@ -32,7 +32,7 @@ const ACTIONS = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function activate(ctx) {
|
function activate(ctx) {
|
||||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||||
for (const [name, action] of Object.entries(ACTIONS)) {
|
for (const [name, action] of Object.entries(ACTIONS)) {
|
||||||
const prompt = settings.prompts?.[name] ?? action.prompt;
|
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.
|
// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
|
||||||
function oneLine(text) {
|
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) {
|
function tail(text, lineCount) {
|
||||||
@@ -126,3 +130,5 @@ async function ask(ctx, settings, title, instructions) {
|
|||||||
await rm(workDir, { recursive: true, force: true });
|
await rm(workDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports = { activate };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "AI Explain",
|
"name": "AI Explain",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "main.mjs"
|
"main": "main.js"
|
||||||
}
|
}
|
||||||
@@ -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
|
## 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 |
|
| 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`:
|
A plugin folder needs a `plugin.json`:
|
||||||
|
|
||||||
```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
|
```js
|
||||||
export function activate(ctx) {
|
function activate(ctx) {
|
||||||
ctx.mpv.on('media-path-change', ({ path }) => ctx.log.info(`Now playing ${path}`));
|
ctx.mpv.on('media-path-change', ({ path }) => ctx.log.info(`Now playing ${path}`));
|
||||||
ctx.every(60_000, () => ctx.log.info('Still watching'));
|
ctx.every(60_000, () => ctx.log.info('Still watching'));
|
||||||
ctx.actions.register('hello', () => {
|
ctx.actions.register('hello', () => {
|
||||||
@@ -76,6 +76,8 @@ export function activate(ctx) {
|
|||||||
ctx.ui.panel({ title: 'Hello', markdown: line ? `> ${line.text}` : '_No subtitle_' });
|
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.
|
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.
|
||||||
|
|||||||
@@ -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/`
|
- 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.
|
- 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-*`
|
- 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/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||||
in `src/core/services/stats-server/`
|
in `src/core/services/stats-server/`
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
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 test from 'node:test';
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
|
||||||
import { execPluginCommand } from './exec';
|
import { execPluginCommand } from './exec';
|
||||||
|
|
||||||
@@ -53,6 +57,50 @@ test('aborting kills the child and rejects with AbortError', async () => {
|
|||||||
await assert.rejects(pending, { name: 'AbortError' });
|
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 () => {
|
test('rejects when the binary does not exist', async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
execPluginCommand('subminer-definitely-missing-binary', []),
|
execPluginCommand('subminer-definitely-missing-binary', []),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
|
|||||||
import type { PluginExecOptions, PluginExecResult } from './types';
|
import type { PluginExecOptions, PluginExecResult } from './types';
|
||||||
|
|
||||||
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
|
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
|
||||||
|
const DEFAULT_KILL_GRACE_MS = 3_000;
|
||||||
|
|
||||||
function appendCapped(current: string, chunk: string): string {
|
function appendCapped(current: string, chunk: string): string {
|
||||||
if (current.length >= MAX_CAPTURED_OUTPUT_CHARS) return current;
|
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,
|
* 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.
|
* 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(
|
export function execPluginCommand(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
options: PluginExecOptions = {},
|
options: PluginExecOptions = {},
|
||||||
|
killGraceMs = DEFAULT_KILL_GRACE_MS,
|
||||||
): Promise<PluginExecResult> {
|
): Promise<PluginExecResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (options.signal?.aborted) {
|
if (options.signal?.aborted) {
|
||||||
@@ -45,9 +48,15 @@ export function execPluginCommand(
|
|||||||
finish();
|
finish();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
|
||||||
const killWith = (error: Error): void => {
|
const killWith = (error: Error): void => {
|
||||||
settle(() => reject(error));
|
settle(() => reject(error));
|
||||||
child.kill('SIGTERM');
|
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 {
|
function onAbort(): void {
|
||||||
|
|||||||
Reference in new issue
Block a user