Keep app-server scheduling out of DOM bridge lint allowlist

This commit is contained in:
murashit 2026-06-09 21:25:57 +09:00
parent 90d6bf6ac2
commit f86743490d
4 changed files with 96 additions and 33 deletions

View file

@ -9,22 +9,6 @@ const typeScriptFiles = ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"];
const nodeJavaScriptFiles = ["*.mjs", "scripts/**/*.mjs"];
const typeScriptConfigFiles = ["*.config.ts"];
const lintedTypeScriptFiles = [...typeScriptFiles, ...typeScriptConfigFiles];
const imperativeDomRestrictions = [
{
selector:
"CallExpression[callee.property.name=/^(createEl|createDiv|createSpan|appendChild|replaceChildren|insertBefore|removeChild|append|prepend|before|after|replaceWith|remove|insertAdjacentHTML|insertAdjacentElement|insertAdjacentText|setAttr|empty)$/]",
message: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.",
},
{
selector:
"AssignmentExpression[left.type='MemberExpression'][left.property.name=/^(innerHTML|outerHTML|textContent|value|checked|onclick|ondblclick|oninput|onchange|onkeydown|onkeyup|onmousedown|onmouseup|onmousemove|onpointerdown|onpointerup|onblur|onfocus|onselect|onscroll)$/]",
message: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.",
},
{
selector: "CallExpression[callee.property.name=/^(addEventListener|removeEventListener)$/]",
message: "Keep imperative DOM event wiring in an explicit bridge module or Obsidian-owned UI boundary.",
},
];
const preactFormRestrictions = [
{
selector: "JSXAttribute[name.name=/^(defaultValue|defaultChecked)$/]",
@ -43,6 +27,25 @@ const unsafeIteratorRestrictions = [
message: "Avoid reading iterator.next().value directly; use for...of or inspect the typed IteratorResult first.",
},
];
const imperativeDomWriteRestrictions = [
{
selector:
"CallExpression[callee.property.name=/^(createEl|createDiv|createSpan|appendChild|replaceChildren|insertBefore|removeChild|append|prepend|before|after|replaceWith|remove|insertAdjacentHTML|insertAdjacentElement|insertAdjacentText|setAttr|empty)$/]",
message: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.",
},
{
selector:
"AssignmentExpression[left.type='MemberExpression'][left.property.name=/^(innerHTML|outerHTML|textContent|value|checked|onclick|ondblclick|oninput|onchange|onkeydown|onkeyup|onmousedown|onmouseup|onmousemove|onpointerdown|onpointerup|onblur|onfocus|onselect|onscroll)$/]",
message: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.",
},
];
const imperativeDomEventRestrictions = [
{
selector: "CallExpression[callee.property.name=/^(addEventListener|removeEventListener)$/]",
message: "Keep imperative DOM event wiring in an explicit bridge module or Obsidian-owned UI boundary.",
},
];
const imperativeDomRestrictions = [...imperativeDomWriteRestrictions, ...imperativeDomEventRestrictions];
const pureChatModelRestrictions = [
{
selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']",
@ -79,7 +82,6 @@ const chatImperativeDomBridgeFiles = [
"src/features/chat/ui/turn-diff.tsx",
];
const nonChatImperativeDomBridgeFiles = [
"src/app-server/structured-ephemeral-turn.ts",
"src/features/selection-rewrite/popover.tsx",
"src/features/selection-rewrite/runner.ts",
"src/features/thread-picker/modal.ts",
@ -92,6 +94,7 @@ const nonChatImperativeDomBridgeFiles = [
"src/shared/ui/textarea-caret.ts",
"src/shared/ui/ui-root.tsx",
];
const nonUiEventListenerFiles = ["src/shared/lifecycle/abortable.ts"];
const codexPanelEslintPlugin = {
rules: {
"no-self-referential-initializer-callback": {
@ -259,9 +262,11 @@ export default defineConfig([
HTMLTextAreaElement: "readonly",
KeyboardEvent: "readonly",
NodeJS: "readonly",
clearTimeout: "readonly",
console: "readonly",
document: "readonly",
requestAnimationFrame: "readonly",
setTimeout: "readonly",
},
},
rules: {
@ -314,7 +319,7 @@ export default defineConfig([
},
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/features/chat/**/*.{ts,tsx}", ...nonChatImperativeDomBridgeFiles],
ignores: ["src/features/chat/**/*.{ts,tsx}", ...nonChatImperativeDomBridgeFiles, ...nonUiEventListenerFiles],
rules: {
"no-restricted-syntax": [
"error",
@ -362,6 +367,18 @@ export default defineConfig([
],
},
},
{
files: nonUiEventListenerFiles,
rules: {
"no-restricted-syntax": [
"error",
...removedChatStateEscapeHatchRestrictions,
...unsafeIteratorRestrictions,
...imperativeDomWriteRestrictions,
...preactFormRestrictions,
],
},
},
{
files: ["src/features/chat/chat-state.ts", "src/features/chat/display/**/*.{ts,tsx}"],
rules: {
@ -404,5 +421,11 @@ export default defineConfig([
],
},
},
{
files: ["src/app-server/**/*.{ts,tsx}"],
rules: {
"obsidianmd/prefer-window-timers": "off",
},
},
eslintConfigPrettier,
]);

View file

@ -4,6 +4,7 @@ import {
structuredTurnRunMatches,
transitionStructuredTurnRunLifecycle,
} from "./structured-turn-run-lifecycle";
import { abortablePromise, throwIfAbortSignalAborted } from "../shared/lifecycle/abortable";
import type { InitializeResponse } from "../generated/app-server/InitializeResponse";
import type { RequestId } from "../generated/app-server/RequestId";
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
@ -24,6 +25,18 @@ interface StructuredTurnRuntimeOverride {
type StructuredTurnProgressEvent = { type: "agent-message-delta"; delta: string } | { type: "reasoning-activity" };
interface StructuredEphemeralTurnTimers {
setTimeout(callback: () => void, delayMs: number): unknown;
clearTimeout(timer: unknown): void;
}
const DEFAULT_STRUCTURED_EPHEMERAL_TURN_TIMERS: StructuredEphemeralTurnTimers = {
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timer) => {
clearTimeout(timer as ReturnType<typeof setTimeout>);
},
};
export interface StructuredEphemeralTurnClient {
connect(): Promise<InitializeResponse>;
disconnect(): void;
@ -68,18 +81,20 @@ export interface RunStructuredEphemeralTurnOptions {
signal?: AbortSignal | undefined;
onProgress?: (event: StructuredTurnProgressEvent) => void;
clientFactory?: StructuredEphemeralTurnClientFactory | undefined;
timers?: StructuredEphemeralTurnTimers | undefined;
}
export async function runStructuredEphemeralTurn(options: RunStructuredEphemeralTurnOptions): Promise<Turn> {
throwIfAborted(options.signal, options.abortMessage);
let state = createStructuredEphemeralTurnState();
let timeout: number | undefined;
const timers = options.timers ?? DEFAULT_STRUCTURED_EPHEMERAL_TURN_TIMERS;
let timeout: unknown;
let rejectCompletedTurn: ((error: Error) => void) | null = null;
let handleNotification: (notification: ServerNotification) => void = () => undefined;
const completedTurn = new Promise<Turn>((resolve, reject) => {
rejectCompletedTurn = reject;
timeout = window.setTimeout(() => {
timeout = timers.setTimeout(() => {
if (state.lifecycle.kind === "completed") return;
state = completeStructuredEphemeralTurnState(state);
reject(new Error(options.timedOutMessage));
@ -149,7 +164,7 @@ export async function runStructuredEphemeralTurn(options: RunStructuredEphemeral
: await abortable(completedTurn, options.signal, options.abortMessage);
} finally {
state = completeStructuredEphemeralTurnState(state);
if (timeout !== undefined) window.clearTimeout(timeout);
if (timeout !== undefined) timers.clearTimeout(timeout);
client.disconnect();
}
}
@ -216,21 +231,11 @@ function turnWithCollectedItems(turn: Turn, completedItems: readonly ThreadItem[
}
function throwIfAborted(signal: AbortSignal | undefined, message: string | undefined): void {
if (signal?.aborted) throw structuredEphemeralTurnAbortError(message);
throwIfAbortSignalAborted(signal, () => structuredEphemeralTurnAbortError(message));
}
function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined, message: string | undefined): Promise<T> {
if (!signal) return promise;
throwIfAborted(signal, message);
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
reject(structuredEphemeralTurnAbortError(message));
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
signal.removeEventListener("abort", onAbort);
});
});
return abortablePromise(promise, signal, () => structuredEphemeralTurnAbortError(message));
}
function structuredEphemeralTurnAbortError(message: string | undefined): Error {

View file

@ -0,0 +1,17 @@
export function throwIfAbortSignalAborted(signal: AbortSignal | undefined, abortError: () => Error): void {
if (signal?.aborted) throw abortError();
}
export function abortablePromise<T>(promise: Promise<T>, signal: AbortSignal | undefined, abortError: () => Error): Promise<T> {
if (!signal) return promise;
throwIfAbortSignalAborted(signal, abortError);
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
signal.removeEventListener("abort", onAbort);
});
});
}

View file

@ -76,6 +76,24 @@ describe("runStructuredEphemeralTurn", () => {
expect(remove).toHaveBeenCalledTimes(add.mock.calls.length);
});
it("uses injected timers for structured turn timeout cleanup", async () => {
const timers = {
setTimeout: vi.fn((_callback: () => void, _delayMs: number) => 123),
clearTimeout: vi.fn(),
};
const { clientFactory } = fakeStructuredTurnClientFactory((fake) => {
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
});
await runStructuredEphemeralTurn({
...runOptions(clientFactory),
timers,
});
expect(timers.setTimeout).toHaveBeenCalledWith(expect.any(Function), 10_000);
expect(timers.clearTimeout).toHaveBeenCalledWith(123);
});
it("rejects server requests with the configured message", async () => {
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
fake.connectImpl = async () => {