mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(config): hot-reload safe config updates and document behavior
This commit is contained in:
111
src/core/services/config-hot-reload.test.ts
Normal file
111
src/core/services/config-hot-reload.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||
import {
|
||||
classifyConfigHotReloadDiff,
|
||||
createConfigHotReloadRuntime,
|
||||
type ConfigHotReloadRuntimeDeps,
|
||||
} from './config-hot-reload';
|
||||
|
||||
test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.subtitleStyle.fontSize = prev.subtitleStyle.fontSize + 2;
|
||||
next.websocket.port = prev.websocket.port + 1;
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
|
||||
});
|
||||
|
||||
test('config hot reload runtime debounces rapid watch events', () => {
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
const pendingTimers = new Map<number, () => void>();
|
||||
let nextTimerId = 1;
|
||||
let reloadCalls = 0;
|
||||
|
||||
const deps: ConfigHotReloadRuntimeDeps = {
|
||||
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
reloadConfigStrict: () => {
|
||||
reloadCalls += 1;
|
||||
return {
|
||||
ok: true,
|
||||
config: deepCloneConfig(DEFAULT_CONFIG),
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
};
|
||||
},
|
||||
watchConfigPath: (_path, onChange) => {
|
||||
watchedChangeCallback = onChange;
|
||||
return { close: () => {} };
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
const id = nextTimerId;
|
||||
nextTimerId += 1;
|
||||
pendingTimers.set(id, callback);
|
||||
return id as unknown as NodeJS.Timeout;
|
||||
},
|
||||
clearTimeout: (timeout) => {
|
||||
pendingTimers.delete(timeout as unknown as number);
|
||||
},
|
||||
debounceMs: 25,
|
||||
onHotReloadApplied: () => {},
|
||||
onRestartRequired: () => {},
|
||||
onInvalidConfig: () => {},
|
||||
};
|
||||
|
||||
const runtime = createConfigHotReloadRuntime(deps);
|
||||
runtime.start();
|
||||
assert.equal(reloadCalls, 1);
|
||||
if (!watchedChangeCallback) {
|
||||
throw new Error('Expected watch callback to be registered.');
|
||||
}
|
||||
const trigger = watchedChangeCallback as () => void;
|
||||
|
||||
trigger();
|
||||
trigger();
|
||||
trigger();
|
||||
assert.equal(pendingTimers.size, 1);
|
||||
|
||||
for (const callback of pendingTimers.values()) {
|
||||
callback();
|
||||
}
|
||||
assert.equal(reloadCalls, 2);
|
||||
});
|
||||
|
||||
test('config hot reload runtime reports invalid config and skips apply', () => {
|
||||
const invalidMessages: string[] = [];
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
|
||||
const runtime = createConfigHotReloadRuntime({
|
||||
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
reloadConfigStrict: () => ({
|
||||
ok: false,
|
||||
error: 'Invalid JSON',
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
watchConfigPath: (_path, onChange) => {
|
||||
watchedChangeCallback = onChange;
|
||||
return { close: () => {} };
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
callback();
|
||||
return 1 as unknown as NodeJS.Timeout;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
debounceMs: 0,
|
||||
onHotReloadApplied: () => {
|
||||
throw new Error('Hot reload should not apply for invalid config.');
|
||||
},
|
||||
onRestartRequired: () => {
|
||||
throw new Error('Restart warning should not trigger for invalid config.');
|
||||
},
|
||||
onInvalidConfig: (message) => {
|
||||
invalidMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
assert.equal(watchedChangeCallback, null);
|
||||
assert.equal(invalidMessages.length, 1);
|
||||
});
|
||||
159
src/core/services/config-hot-reload.ts
Normal file
159
src/core/services/config-hot-reload.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { type ReloadConfigStrictResult } from '../../config';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
|
||||
export interface ConfigHotReloadDiff {
|
||||
hotReloadFields: string[];
|
||||
restartRequiredFields: string[];
|
||||
}
|
||||
|
||||
export interface ConfigHotReloadRuntimeDeps {
|
||||
getCurrentConfig: () => ResolvedConfig;
|
||||
reloadConfigStrict: () => ReloadConfigStrictResult;
|
||||
watchConfigPath: (configPath: string, onChange: () => void) => { close: () => void };
|
||||
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
|
||||
clearTimeout: (timeout: NodeJS.Timeout) => void;
|
||||
debounceMs?: number;
|
||||
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
|
||||
onRestartRequired: (fields: string[]) => void;
|
||||
onInvalidConfig: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface ConfigHotReloadRuntime {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
function isEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
|
||||
const hotReloadFields: string[] = [];
|
||||
const restartRequiredFields: string[] = [];
|
||||
|
||||
if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) {
|
||||
hotReloadFields.push('subtitleStyle');
|
||||
}
|
||||
if (!isEqual(prev.keybindings, next.keybindings)) {
|
||||
hotReloadFields.push('keybindings');
|
||||
}
|
||||
if (!isEqual(prev.shortcuts, next.shortcuts)) {
|
||||
hotReloadFields.push('shortcuts');
|
||||
}
|
||||
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
|
||||
hotReloadFields.push('secondarySub.defaultMode');
|
||||
}
|
||||
if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) {
|
||||
hotReloadFields.push('ankiConnect.ai');
|
||||
}
|
||||
|
||||
const keys = new Set([
|
||||
...(Object.keys(prev) as Array<keyof ResolvedConfig>),
|
||||
...(Object.keys(next) as Array<keyof ResolvedConfig>),
|
||||
]);
|
||||
|
||||
for (const key of keys) {
|
||||
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'secondarySub') {
|
||||
const normalizedPrev = {
|
||||
...prev.secondarySub,
|
||||
defaultMode: next.secondarySub.defaultMode,
|
||||
};
|
||||
if (!isEqual(normalizedPrev, next.secondarySub)) {
|
||||
restartRequiredFields.push('secondarySub');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'ankiConnect') {
|
||||
const normalizedPrev = {
|
||||
...prev.ankiConnect,
|
||||
ai: next.ankiConnect.ai,
|
||||
};
|
||||
if (!isEqual(normalizedPrev, next.ankiConnect)) {
|
||||
restartRequiredFields.push('ankiConnect');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isEqual(prev[key], next[key])) {
|
||||
restartRequiredFields.push(String(key));
|
||||
}
|
||||
}
|
||||
|
||||
return { hotReloadFields, restartRequiredFields };
|
||||
}
|
||||
|
||||
export function createConfigHotReloadRuntime(
|
||||
deps: ConfigHotReloadRuntimeDeps,
|
||||
): ConfigHotReloadRuntime {
|
||||
let watcher: { close: () => void } | null = null;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let watchedPath: string | null = null;
|
||||
const debounceMs = deps.debounceMs ?? 250;
|
||||
|
||||
const reloadWithDiff = () => {
|
||||
const prev = deps.getCurrentConfig();
|
||||
const result = deps.reloadConfigStrict();
|
||||
if (!result.ok) {
|
||||
deps.onInvalidConfig(`Config reload failed: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (watchedPath !== result.path) {
|
||||
watchPath(result.path);
|
||||
}
|
||||
|
||||
const diff = classifyDiff(prev, result.config);
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.onHotReloadApplied(diff, result.config);
|
||||
}
|
||||
if (diff.restartRequiredFields.length > 0) {
|
||||
deps.onRestartRequired(diff.restartRequiredFields);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReload = () => {
|
||||
if (timer) {
|
||||
deps.clearTimeout(timer);
|
||||
}
|
||||
timer = deps.setTimeout(() => {
|
||||
timer = null;
|
||||
reloadWithDiff();
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
const watchPath = (configPath: string) => {
|
||||
watcher?.close();
|
||||
watcher = deps.watchConfigPath(configPath, scheduleReload);
|
||||
watchedPath = configPath;
|
||||
};
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
if (watcher) {
|
||||
return;
|
||||
}
|
||||
const result = deps.reloadConfigStrict();
|
||||
if (!result.ok) {
|
||||
deps.onInvalidConfig(`Config watcher startup failed: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
watchPath(result.path);
|
||||
},
|
||||
stop: () => {
|
||||
if (timer) {
|
||||
deps.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
watcher?.close();
|
||||
watcher = null;
|
||||
watchedPath = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { classifyDiff as classifyConfigHotReloadDiff };
|
||||
@@ -108,3 +108,4 @@ export {
|
||||
createOverlayManager,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
} from './overlay-manager';
|
||||
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';
|
||||
|
||||
Reference in New Issue
Block a user