diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 5827f90a8a..029ad195c6 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -107,6 +107,7 @@ } from './lint/typescriptExtraLibs' import { createWindmillAta, genAtaRoot } from './lint/typescriptAta' import { readModelMarkers } from './lint/markers' + import { registerEditor as registerLintEditor } from './lint/headlessModelHost' import { buildDenoImportMap, hasLanguageServers, lspServersFor } from './lint/lspLanguageConfig' import { aiChatManager } from './copilot/chat/AIChatManager.svelte' import type { Selection } from 'monaco-editor' @@ -1683,8 +1684,16 @@ }) let loadTimeout: number | undefined = undefined + // Announce this editor to the headless lint host: when the AI chat lints the same item + // in the same workspace, it reads these markers instead of touching this model. + let unregisterFromLintHost: (() => void) | undefined = undefined + onMount(async () => { if (BROWSER) { + unregisterFromLintHost = registerLintEditor(uri, $workspaceStore ?? '', () => ({ + markers: getLintErrors(), + content: getCode() + })) if (loadAsync) { loadTimeout = setTimeout(() => loadMonaco().then((x) => (disposeMethod = x)), 0) } else { @@ -1696,6 +1705,7 @@ onDestroy(() => { console.log('destroying editor') + unregisterFromLintHost?.() valueAfterDispose = getCode() pasteCleanup?.() destroyed = true diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 81dffa7c94..4e52ea2476 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -3997,7 +3997,14 @@ type LintTargetArgs = { async function resolveLintTarget( args: LintTargetArgs, workspace: string -): Promise<{ content: string; language: string; lintPath: string; label: string }> { +): Promise<{ + content: string + language: string + itemKind: string + storagePath: string + subPath?: string + label: string +}> { if (args.kind === 'script') { const script = await loadScriptForEdit(args.path, workspace).catch(() => { throw new Error(`No draft or deployed script found at "${args.path}".`) @@ -4005,7 +4012,8 @@ async function resolveLintTarget( return { content: script.content, language: script.language, - lintPath: args.path, + itemKind: 'script', + storagePath: args.path, label: `script "${args.path}"` } } @@ -4027,8 +4035,11 @@ async function resolveLintTarget( return { content: module.value.content, language: module.value.language, - // The editor mounts flow modules at "/"; lint the same URI. - lintPath: `${args.path}/${args.module_id}`, + // The editor mounts a flow module at "/"; the owned model + // mirrors that under the flow's draft cell. + itemKind: 'flow', + storagePath: args.path, + subPath: args.module_id, label: `module "${args.module_id}" of flow "${args.path}"` } } @@ -4050,7 +4061,9 @@ async function resolveLintTarget( return { content: runnable.inlineScript.content ?? '', language: runnable.inlineScript.language, - lintPath: `${args.path}/${runnableKey}`, + itemKind: 'app', + storagePath: args.path, + subPath: runnableKey, label: `runnable "${runnableKey}" of app "${args.path}"` } } @@ -4153,8 +4166,10 @@ async function getLintErrors(args: LintTargetArgs, ctx: WriteDraftCtx): Promise< const outcome = await lintCode({ content: target.content, scriptLang: target.language, - path: target.lintPath, - workspace + workspace, + itemKind: target.itemKind, + storagePath: target.storagePath, + subPath: target.subPath }) const result = outcome.result const incomplete = outcome.status === 'incomplete' diff --git a/frontend/src/lib/components/lint/headlessLint.ts b/frontend/src/lib/components/lint/headlessLint.ts index 2fb8f0d650..3287988d6f 100644 --- a/frontend/src/lib/components/lint/headlessLint.ts +++ b/frontend/src/lib/components/lint/headlessLint.ts @@ -11,8 +11,9 @@ import { setMonacoTypescriptOptions, TS_DIAGNOSTIC_CODES_TO_IGNORE } from '../monacoLanguagesOptions' -import { computeModelPath, computeModelUri } from './monacoUri' +import { computeModelUri, computeOwnedUri } from './monacoUri' import { readModelMarkers } from './markers' +import { acquireOwnedModel, readThrough, releaseOwnedModel } from './headlessModelHost' import { ensureCustomWmillTypes, ensureResourceTypeNamespace } from './typescriptExtraLibs' import { createWindmillAta, genAtaRoot } from './typescriptAta' import { lintWithLsp } from './headlessLsp' @@ -35,15 +36,20 @@ export function canLintHeadless(scriptLang: string | undefined): boolean { export interface HeadlessLintRequest { content: string scriptLang: string - /** Canonical editor path without extension: script path, `/`, `/`. */ - path: string workspace: string + /** The draft cell this code lives in — its identity, keyed like UserDraft. */ + itemKind: string + storagePath: string + /** A unit within the cell: a flow module id or an app runnable key; absent for a whole script. */ + subPath?: string } -// Models we created ourselves, least recently used first. -const ownedModels: string[] = [] -const MAX_OWNED_MODELS = 4 +/** The editor path this request maps to — where a mounted editor would put its model. */ +function editorPathOf(req: HeadlessLintRequest): string { + return req.subPath ? `${req.storagePath}/${req.subPath}` : req.storagePath +} +const MAX_ATA_INSTANCES = 4 const absolutePathExtraLibs = new Map void }>() const ataByKey = new Map Promise>() const pendingByUri = new Map>() @@ -52,31 +58,6 @@ function withDeadline(promise: Promise, ms: number): Promise { return Promise.race([promise, new Promise((resolve) => setTimeout(resolve, ms))]) } -function touchOwnedModel(uriString: string) { - const at = ownedModels.indexOf(uriString) - if (at >= 0) ownedModels.splice(at, 1) - ownedModels.push(uriString) -} - -function evictOwnedModels(keepUri: string) { - while (ownedModels.length > MAX_OWNED_MODELS) { - const candidate = ownedModels.shift() - if (!candidate) continue - if (candidate === keepUri) { - // Still in use: keep tracking it, or a later lint would mistake it for a model - // owned by an editor and refuse to update its content. - ownedModels.push(candidate) - continue - } - const model = meditor.getModel(Uri.parse(candidate)) - // An editor may have adopted it in the meantime; disposing it would close the - // user's buffer. - if (model && !model.isAttachedToEditor()) { - model.dispose() - } - } -} - export async function lintCode( req: HeadlessLintRequest, opts?: { timeoutMs?: number } @@ -94,26 +75,27 @@ export async function lintCode( scriptLang: req.scriptLang, editorLang, workspace: req.workspace, - path: req.path, + path: editorPathOf(req), timeoutMs: opts?.timeoutMs }) } - const uriString = computeModelUri( - computeModelPath(req.path, req.scriptLang), + const ownedUri = computeOwnedUri( + { workspace: req.workspace, itemKind: req.itemKind, storagePath: req.storagePath }, + req.subPath, req.scriptLang, editorLang ) - // Two lints of the same URI would race on model content; run them in order. - const previous = pendingByUri.get(uriString) ?? Promise.resolve(undefined) + // Two lints of the same owned model would race on its content; run them in order. + const previous = pendingByUri.get(ownedUri) ?? Promise.resolve(undefined) const run = previous .catch(() => undefined) - .then(() => lintOne(req, uriString, editorLang, opts?.timeoutMs ?? 3000)) + .then(() => lintOne(req, ownedUri, editorLang, opts?.timeoutMs ?? 3000)) pendingByUri.set( - uriString, + ownedUri, run.finally(() => { - if (pendingByUri.get(uriString) === run) pendingByUri.delete(uriString) + if (pendingByUri.get(ownedUri) === run) pendingByUri.delete(ownedUri) }) as Promise ) return run @@ -121,7 +103,7 @@ export async function lintCode( async function lintOne( req: HeadlessLintRequest, - uriString: string, + ownedUri: string, editorLang: string, timeoutMs: number ): Promise { @@ -132,69 +114,72 @@ async function lintOne( await initializeVscode('headlessLint') keepModelAroundToAvoidDisposalOfWorkers() - const uri = Uri.parse(uriString) - let model = meditor.getModel(uri) - let contentMismatch = false - if (model) { - // Attachment decides, not who created it: an editor can adopt a model we made, and - // its buffer is what the user sees and would save. Never write to it. - if (model.isAttachedToEditor() || !ownedModels.includes(uriString)) { - contentMismatch = model.getValue() !== req.content - } else if (model.getValue() !== req.content) { - model.setValue(req.content) - // Markers from the previous content survive until revalidation finishes, and a - // matching marker count would let the wait below settle on them. - meditor.setModelMarkers(model, editorLang, []) - touchOwnedModel(uriString) + // If an editor in this workspace is showing the item, read its markers — never touch a + // model it owns. Its markers still have to settle: right after an edit the buffer holds + // the new content but the worker may not have revalidated yet. + const editorUri = computeModelUri(editorPathOf(req), req.scriptLang, editorLang) + const rt = readThrough(editorUri, req.workspace, req.content) + if (rt) { + const settled = await waitForMarkersToSettle(Uri.parse(editorUri), editorLang, timeoutMs) + const result = rt.reread() + return settled + ? { status: 'complete', result, contentMismatch: rt.contentMismatch } + : { + status: 'incomplete', + result, + missing: ['the TypeScript checker'], + contentMismatch: rt.contentMismatch + } + } + + // Otherwise lint an owned model. + const uri = acquireOwnedModel(ownedUri, req.content, editorLang) + try { + // Bounded: these fetch over the network, and lints of one URI are serialized behind + // each other, so one hung request would wedge the tool for that item indefinitely. + if (editorLang === 'typescript') { + await withDeadline( + Promise.all([ + ensureResourceTypeNamespace(req.workspace, req.scriptLang), + ensureCustomWmillTypes(req.workspace) + ]).catch((e) => console.error('headlessLint: extra libs unavailable', e)), + timeoutMs + ) } - } else { - model = meditor.createModel(req.content, editorLang, uri) - touchOwnedModel(uriString) - evictOwnedModels(uriString) - } - // Bounded: these fetch over the network, and lints of one URI are serialized behind - // each other, so one hung request would wedge the tool for that item indefinitely. - if (editorLang === 'typescript') { - await withDeadline( - Promise.all([ - ensureResourceTypeNamespace(req.workspace, req.scriptLang), - ensureCustomWmillTypes(req.workspace) - ]).catch((e) => console.error('headlessLint: extra libs unavailable', e)), - timeoutMs - ) - } + if (req.scriptLang === 'bun' || req.scriptLang === 'bunnative') { + await withDeadline( + acquireTypes(req, ownedUri).catch((e) => + console.error('headlessLint: type acquisition failed', e) + ), + timeoutMs + ) + } - if (req.scriptLang === 'bun' || req.scriptLang === 'bunnative') { - await withDeadline( - acquireTypes(req, uriString).catch((e) => - console.error('headlessLint: type acquisition failed', e) - ), - timeoutMs - ) + const settled = await waitForMarkersToSettle(uri, editorLang, timeoutMs) + const result = readModelMarkers(uri) + // A worker that never came up leaves the markers empty for lack of analysis; report + // it as incomplete rather than let an empty set read as clean. + return settled + ? { status: 'complete', result } + : { status: 'incomplete', result, missing: ['the TypeScript checker'] } + } finally { + releaseOwnedModel(ownedUri) } - - const settled = await waitForMarkersToSettle(uri, editorLang, timeoutMs) - const result = readModelMarkers(uri) - // A worker that never came up leaves the markers empty for lack of analysis; report it - // as incomplete rather than let an empty set read as clean. - return settled - ? { status: 'complete', result, contentMismatch } - : { status: 'incomplete', result, missing: ['the TypeScript checker'], contentMismatch } } async function acquireTypes(req: HeadlessLintRequest, uriString: string): Promise { const key = `${req.workspace}:${uriString}` let ata = ataByKey.get(key) if (!ata) { - while (ataByKey.size >= MAX_OWNED_MODELS) { + while (ataByKey.size >= MAX_ATA_INSTANCES) { const oldest = ataByKey.keys().next().value if (oldest === undefined) break ataByKey.delete(oldest) } ata = await createWindmillAta({ root: await genAtaRoot(req.workspace), - scriptPath: req.path, + scriptPath: editorPathOf(req), modelUri: uriString, absolutePathExtraLibs }) diff --git a/frontend/src/lib/components/lint/headlessModelHost.ts b/frontend/src/lib/components/lint/headlessModelHost.ts new file mode 100644 index 0000000000..ff28ab335e --- /dev/null +++ b/frontend/src/lib/components/lint/headlessModelHost.ts @@ -0,0 +1,123 @@ +import { editor as meditor, Uri } from 'monaco-editor' +import type { ScriptLintResult } from '../copilot/chat/shared' + +// The single owner of every model the headless linter touches. Two rules: +// 1. When an editor is already showing the item (same URI and workspace), read ITS +// markers — never create or write. +// 2. Otherwise work in an owned model whose URI is disjoint from every editor's (see +// OWNED_ROOT in monacoUri.ts), so an owned model can never be one an editor owns. +// Nothing outside this module mutates Monaco's model registry for linting. + +interface RegisteredEditor { + /** Editor's own view of its markers and current buffer, read lazily. */ + read: () => { markers: ScriptLintResult; content: string } +} + +// keyed by workspace + editor URI: two editors on the same path in different workspaces +// are distinct registrations, so a fork's editor never shadows the parent's. +const registeredEditors = new Map() + +// owned models we created, least-recently-used first; keyed by owned URI string +const ownedModels: string[] = [] +const MAX_OWNED_MODELS = 4 +// owned models currently mid-lint (awaiting settle); the evictor must not dispose them +const inFlight = new Set() + +function editorKey(workspace: string, uri: string): string { + return `${workspace}\n${uri}` +} + +/** + * An editor announces the URI it owns, its workspace, and how to read its markers/content. + * Returns a disposer for unmount. This is what makes read-through workspace-correct: a + * session linting a fork never reads a parent-workspace editor's markers. + */ +export function registerEditor( + uri: string, + workspace: string, + read: () => { markers: ScriptLintResult; content: string } +): () => void { + const k = editorKey(workspace, uri) + const entry: RegisteredEditor = { read } + registeredEditors.set(k, entry) + return () => { + if (registeredEditors.get(k) === entry) registeredEditors.delete(k) + } +} + +export interface ReadThrough { + /** The editor's buffer differs from the content we were asked to lint. */ + contentMismatch: boolean + /** Re-read the editor's markers (call after waiting for them to settle). */ + reread: () => ScriptLintResult +} + +/** + * If an editor in the same workspace owns `editorUri`, return a handle to its markers (and + * whether its buffer differs from `requestedContent`). Never creates or writes a model. + */ +export function readThrough( + editorUri: string, + workspace: string, + requestedContent: string +): ReadThrough | undefined { + const reg = registeredEditors.get(editorKey(workspace, editorUri)) + if (!reg) return undefined + return { + contentMismatch: reg.read().content !== requestedContent, + reread: () => reg.read().markers + } +} + +function touch(uriString: string) { + const at = ownedModels.indexOf(uriString) + if (at >= 0) ownedModels.splice(at, 1) + ownedModels.push(uriString) +} + +function evict(keepUri: string) { + // Bounded by the list length so a set of all-protected models can't spin forever. + let guard = ownedModels.length + while (ownedModels.length > MAX_OWNED_MODELS && guard-- > 0) { + const candidate = ownedModels.shift() + if (!candidate) continue + // Protect the model this lint is using and any other lint's in-flight model — a + // concurrent lint of a different item is still awaiting its markers. + if (candidate === keepUri || inFlight.has(candidate)) { + ownedModels.push(candidate) + continue + } + const model = meditor.getModel(Uri.parse(candidate)) + // Owned models are never editor-attached (disjoint namespace); the guard is defence + // in depth against a future change that violates that. + if (model && !model.isAttachedToEditor()) model.dispose() + } +} + +/** + * Ensure an owned model exists at `ownedUri` holding `content`, marking it in-flight so a + * concurrent lint's eviction cannot dispose it. The caller must call `releaseOwnedModel` + * when done. Clears stale markers on a content change so a later settle cannot succeed on + * the previous version's markers. + */ +export function acquireOwnedModel(ownedUri: string, content: string, editorLang: string): Uri { + inFlight.add(ownedUri) + const uri = Uri.parse(ownedUri) + const existing = meditor.getModel(uri) + if (existing) { + if (existing.getValue() !== content) { + existing.setValue(content) + meditor.setModelMarkers(existing, editorLang, []) + } + touch(ownedUri) + } else { + meditor.createModel(content, editorLang, uri) + touch(ownedUri) + } + evict(ownedUri) + return uri +} + +export function releaseOwnedModel(ownedUri: string) { + inFlight.delete(ownedUri) +} diff --git a/frontend/src/lib/components/lint/monacoUri.ts b/frontend/src/lib/components/lint/monacoUri.ts index 5cdadb7562..45b0cdf236 100644 --- a/frontend/src/lib/components/lint/monacoUri.ts +++ b/frontend/src/lib/components/lint/monacoUri.ts @@ -14,7 +14,8 @@ export function computeModelPath(path: string | undefined, scriptLang: string | return path } -export function computeModelUri( +/** The file part (path + extension, no leading slash) shared by every URI scheme below. */ +function computeFilePart( filePath: string, scriptLang: string | undefined, editorLang: string @@ -25,10 +26,37 @@ export function computeModelUri( } else { file = `${filePath}.${scriptLang == 'tsx' ? 'tsx' : langToExt(editorLang)}` } - if (file.startsWith('/')) { - file = file.slice(1) - } + return file.startsWith('/') ? file.slice(1) : file +} + +export function computeModelUri( + filePath: string, + scriptLang: string | undefined, + editorLang: string +): string { + const file = computeFilePart(filePath, scriptLang, editorLang) return !RANDOMIZED_PATH_LANGS.includes(scriptLang ?? '') ? `file:///${file}` : `file:///tmp/monaco/${file}` } + +// A reserved root segment no workspace name or script path can produce, so owned-model +// URIs are disjoint from every editor URI. This is the invariant a headless lint relies on +// to never adopt, overwrite, or dispose a model an editor owns; keep owned URIs under it. +export const OWNED_ROOT = '__wmlint__' + +/** + * URI for a model the headless linter owns, keyed by the draft cell it reflects + * (workspace + itemKind + storagePath) plus an optional sub-path (a flow module id or an + * app file). One linted code unit ⇔ one owned model, in a namespace no editor can enter. + */ +export function computeOwnedUri( + cell: { workspace: string; itemKind: string; storagePath: string }, + subPath: string | undefined, + scriptLang: string | undefined, + editorLang: string +): string { + const inner = subPath ? `${cell.storagePath}/${subPath}` : cell.storagePath + const file = computeFilePart(inner, scriptLang, editorLang) + return `file:///${OWNED_ROOT}/${cell.workspace}/${cell.itemKind}/${file}` +}