set a trap for the editor buffer corruption

Not a fix — I could not find the cause by reading, and shipping a guess dressed as
one is worse than saying so.

What reading DID settle, and it removes two of the three candidates:

- The end-of-turn refresh cannot be it. `reloadFile` sets `originalContent` and
  clears `isDirty`, so no refresh can produce the dirty dot in the screenshot.
- Nothing in the app types into the page: no `execCommand('insertText')`, no
  synthetic keyboard events, no clipboard writes anywhere near the editor.

That leaves Monaco's own change event, which means the model was edited the way a
paste or a programmatic `executeEdits` edits it. Neither is identifiable from a
screenshot taken afterwards, which is why this needs to be caught in the act.

`reportSuspiciousEdit` logs any insertion over 200 characters arriving while the
editor does NOT have focus, with an Error constructed at that moment so the stack
names the caller. A person cannot type 200 characters into an editor they are not
focused on; whatever does will be in that trace.

It prevents nothing and changes no behaviour. Delete it once the cause is known.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:15:33 +00:00
co-authored by Claude Opus 5
parent 23f2ecb950
commit fc8d02e8e1
2 changed files with 50 additions and 1 deletions
+11
View File
@@ -422,6 +422,17 @@ outside the example's new README:
only thing that writes a buffer without a keystroke, it runs exactly when a background job is
producing output, and it lands its result by path.
**Narrowed 2026-08-16, and a trap is now set.** `reloadFile` sets `originalContent` and clears
`isDirty`, so the end-of-turn refresh CANNOT produce the dirty dot in the screenshot — the refresh
path is out. Nothing in the app injects text into the page either: no `execCommand('insertText')`,
no synthetic keyboard events, no clipboard writes near the editor. That leaves Monaco's own change
event, which means it looks like a paste or a programmatic `executeEdits`.
`reportSuspiciousEdit` in CodeEditor.tsx logs any insertion over 200 characters that arrives while
the editor is NOT focused, with a stack trace taken at that moment. That is the missing evidence:
whoever calls it will be named in the trace. Delete it once the cause is known — it is a trap, not a
feature.
One thing to rule out first, because it is mine and it is recent: `2b70a570` gave `<Editor>` a real
`path`, so models are now keyed by file URI globally rather than being per-mount `inmemory://`. Two
panels open on the same file now share one model. That should be an improvement, but it is a change
@@ -28,6 +28,41 @@ type CodeEditorViewProps = {
panelId?: string;
};
/**
* Shout when the buffer changes in a way a person could not have typed.
*
* Background-job output has twice appeared spliced into an open file — the model mutated, the tab marked
* dirty, the file on disk untouched. Two explanations are already ruled out: the end-of-turn refresh
* cannot do it (`reloadFile` clears `isDirty`), and nothing in the app types into the page. That leaves
* Monaco's own change event, which means it looks like a paste or a programmatic edit — and neither can
* be identified from a screenshot after the fact.
*
* So this does not prevent anything. It records the one thing missing: what arrived, how big it was, and
* whether the editor even had focus at the time. A large insertion into an UNFOCUSED editor is not a
* user, and the stack trace attached to it names whatever called it.
*
* Delete this once the cause is known. It is a trap, not a feature.
*/
const SUSPICIOUS_INSERT = 200;
function reportSuspiciousEdit(
editor: MonacoEditor.IStandaloneCodeEditor | null,
file: { path: string; content: string },
next: string,
): void {
const grew = next.length - file.content.length;
if (grew < SUSPICIOUS_INSERT) return;
if (editor?.hasTextFocus()) return;
// eslint-disable-next-line no-console
console.warn(
`[editor] ${grew} characters appeared in ${file.path} while the editor was NOT focused. ` +
`This is the background-job-output bug (TODO.md → Known bugs). First 120 chars:\n` +
next.slice(0, 120),
new Error('stack at the moment of the edit'),
);
}
export const CodeEditorView = ({
className,
theme = 'vs-dark',
@@ -294,7 +329,10 @@ export const CodeEditorView = ({
theme={theme}
language={getLanguage(activeFile.name)}
value={activeFile.content}
onChange={(value) => setContent(activeFile.path, value ?? '')}
onChange={(value) => {
reportSuspiciousEdit(editorRef.current, activeFile, value ?? '');
setContent(activeFile.path, value ?? '');
}}
beforeMount={configureMonacoTypescript}
onMount={handleEditorMount}
options={{