the trap keeps its evidence, and says where the procedure is
This bug will not be reproduced on demand. It will arrive as somebody's report, hours later, from a browser whose console is long gone — so a `console.warn` is seen by nobody, and "what did you see?" gets an answer built from memory and a screenshot. So the trap now persists. Up to five occurrences in `localStorage['officer:editor-buffer-trap']`, each carrying the stack at the moment of the edit, the first 200 characters that arrived, how many, which file, and when. The question becomes "paste this key", and the answer is complete. Recording a bug must never become one: private mode, a full quota and a disabled storage API all throw at that write, and none of them is worth breaking an edit over. The console line survives either way. `docs/editor-buffer-corruption.md` is the procedure — what it looks like, the four candidates already eliminated and why, the one command to ask a reporter for, how to read each field, what an EMPTY key would mean (either the editor was focused, making it a paste, or the insertion came in chunks under the threshold), and the instruction to delete every piece of this once the cause is known. Linked from TODO.md and from CLAUDE.md's reading list, so somebody handed a report finds it without grepping for it. Stated there rather than left implicit: a fix invented without knowing the cause would hide the symptom on the one screen where it is visible while the mechanism carried on doing whatever else it does. The corruption is never saved and reopening the tab clears it, so waiting is cheap and a wrong fix is not. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Background-job output appears inside an open file
|
||||
|
||||
An open bug, not reproducible on demand, with a trap already set for it. **If someone has just reported
|
||||
this, skip to "When it is reported".**
|
||||
|
||||
Status as of 2026-08-16: cause unknown, two candidates eliminated, instrumentation shipped.
|
||||
|
||||
## What it looks like
|
||||
|
||||
A file is open in the code editor while a background job is producing output — a dev server, a watcher.
|
||||
The output appears **inside the file's buffer**, spliced into the middle of it. Observed once with a
|
||||
`bun dev` job: the JSX of `SigninScreen.tsx` and eight lines of dotenv/POSTGRES_URL output shared the
|
||||
buffer, with Monaco's line numbers continuing straight through (…180, the log lines, then 189 `</Link>`).
|
||||
|
||||
Two details from that report do most of the diagnostic work:
|
||||
|
||||
- **The tab was marked dirty.** So the model really was mutated; this is not a rendering artefact.
|
||||
- **The file on disk was untouched.** The corruption is in the browser and was never saved. Reopening the
|
||||
tab fixes it.
|
||||
|
||||
## What has been ruled out, and why
|
||||
|
||||
Do not re-investigate these without new evidence.
|
||||
|
||||
| Candidate | Why it is out |
|
||||
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
|
||||
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
|
||||
| Two Monaco editors sharing a model | The app's only other mount, `FileViewer/renderers/EditorRenderer.tsx`, passes no `path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`. |
|
||||
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
|
||||
|
||||
That leaves **Monaco's own `onChange`** — the model was edited the way a paste or a programmatic
|
||||
`executeEdits` edits it. Which is why this needs to be caught in the act rather than reasoned about.
|
||||
|
||||
One thing to check first, because it is recent and it is ours: `2b70a570` gave `<Editor>` a real `path`,
|
||||
so models are keyed by file URI globally rather than per mount. Two panels open on the same file now share
|
||||
one model. That should be an improvement, and it is a change in exactly the layer that turns out to be
|
||||
involved.
|
||||
|
||||
## When it is reported
|
||||
|
||||
Ask for one thing. In the browser's console, on the machine where it happened:
|
||||
|
||||
```js
|
||||
JSON.parse(localStorage.getItem('officer:editor-buffer-trap'));
|
||||
```
|
||||
|
||||
That returns up to the last five occurrences, each with:
|
||||
|
||||
| Field | What it tells you |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `stack` | **The answer.** The call stack at the moment of the edit — whatever inserted the text is named in it. |
|
||||
| `inserted` | The first 200 characters that arrived. Confirms it is job output rather than a real paste. |
|
||||
| `charactersAdded` | How much arrived at once. |
|
||||
| `path` | Which file was open. |
|
||||
| `at` | Lines it up against the job's own logs. |
|
||||
| `editorWasFocused` | Always `false` — the trap only fires when it is. |
|
||||
|
||||
It is written by `reportSuspiciousEdit` in `apps/CodeEditor/CodeEditor.tsx`, which fires on any insertion
|
||||
over 200 characters arriving while the editor does **not** have focus. Nobody types 200 characters into an
|
||||
editor they are not focused on.
|
||||
|
||||
If the key is empty but the corruption happened, that is itself informative: either the editor **was**
|
||||
focused (so it plausibly is a paste, and the question becomes what pasted it), or the insertion arrived in
|
||||
chunks under 200 characters — in which case lower `SUSPICIOUS_INSERT` and wait for it again.
|
||||
|
||||
## Why a trap rather than a fix
|
||||
|
||||
Because every fix available without knowing the cause is a guess that would look like a solution. A guard
|
||||
that refuses suspicious writes would hide the symptom on the one screen where it is visible, and the same
|
||||
mechanism would go on doing whatever else it does. The corruption is never saved and reopening the tab
|
||||
clears it, so the cost of waiting is low and the cost of a wrong fix is a bug that stops being reportable.
|
||||
|
||||
## When it is solved
|
||||
|
||||
Delete all of it: `reportSuspiciousEdit`, `EDITOR_TRAP_KEY`, its call in the `onChange` handler, the entry
|
||||
in `TODO.md` → Known bugs, and this file. It is instrumentation for one bug, and instrumentation nobody
|
||||
remembers the reason for is worse than none.
|
||||
Reference in New Issue
Block a user