diff --git a/examples/plugins/ai-explain/main.mjs b/assets/plugins/ai-explain/main.js similarity index 93% rename from examples/plugins/ai-explain/main.mjs rename to assets/plugins/ai-explain/main.js index 8caec021..84a4649e 100644 --- a/examples/plugins/ai-explain/main.mjs +++ b/assets/plugins/ai-explain/main.js @@ -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 }; diff --git a/examples/plugins/ai-explain/plugin.json b/assets/plugins/ai-explain/plugin.json similarity index 70% rename from examples/plugins/ai-explain/plugin.json rename to assets/plugins/ai-explain/plugin.json index d67f3afc..b0e74d8c 100644 --- a/examples/plugins/ai-explain/plugin.json +++ b/assets/plugins/ai-explain/plugin.json @@ -1,5 +1,5 @@ { "name": "AI Explain", "version": "1.0.0", - "main": "main.mjs" + "main": "main.js" } diff --git a/docs-site/plugins.md b/docs-site/plugins.md index 6c45b708..f5874c1f 100644 --- a/docs-site/plugins.md +++ b/docs-site/plugins.md @@ -38,7 +38,7 @@ Load errors and plugin log output go to the app log with a `plugin:` 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. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index d28bb7c8..13cf2db1 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -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/` diff --git a/src/core/services/plugins/exec.test.ts b/src/core/services/plugins/exec.test.ts index bab461b7..d28bf67a 100644 --- a/src/core/services/plugins/exec.test.ts +++ b/src/core/services/plugins/exec.test.ts @@ -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', []), diff --git a/src/core/services/plugins/exec.ts b/src/core/services/plugins/exec.ts index 5e2d4679..f2a7f6fc 100644 --- a/src/core/services/plugins/exec.ts +++ b/src/core/services/plugins/exec.ts @@ -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 { 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 {