Skip to content

Commit 66355a6

Browse files
authored
[red-knot] Fix playground crashes when diagnostics are stale (#17165)
## Summary Fixes a crash in the playground where it crashed with an "index out of bounds" error in the `Diagnostic::to_range` call after deleting content at the end of the file. The root cause was that the playground uses `useDeferred` to avoid too frequent `checkFile` calls (to get a smoother UX). However, this has the problem that the rendered `diagnostics` can be stable (from before the last change). Rendering the diagnostics can then fail because the `toRange` call queries the latest content and not the content from when the diagnostics were created. The fix is "easy" in the sense that we now eagerly perform the `toRange` calls. This way, it doesn't matter when the diagnostics are stale for a few ms. This problem can only be observed on examples where Red Knot is "slow" (takes more than ~16ms to check) because only then does `useDeferred` "debounce" the `check` calls.
1 parent 177afab commit 66355a6

File tree

3 files changed

+68
-60
lines changed

3 files changed

+68
-60
lines changed

playground/knot/src/Editor/Chrome.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ import {
1313
Theme,
1414
VerticalResizeHandle,
1515
} from "shared";
16-
import type { Diagnostic, Workspace } from "red_knot_wasm";
16+
import type { Workspace } from "red_knot_wasm";
1717
import { Panel, PanelGroup } from "react-resizable-panels";
1818
import { Files, isPythonFile } from "./Files";
1919
import SecondarySideBar from "./SecondarySideBar";
2020
import SecondaryPanel, {
2121
SecondaryPanelResult,
2222
SecondaryTool,
2323
} from "./SecondaryPanel";
24-
import Diagnostics from "./Diagnostics";
24+
import Diagnostics, { Diagnostic } from "./Diagnostics";
2525
import { FileId, ReadonlyFiles } from "../Playground";
2626
import type { editor } from "monaco-editor";
2727
import type { Monaco } from "@monaco-editor/react";
@@ -168,7 +168,7 @@ export default function Chrome({
168168
workspace={workspace}
169169
onMount={handleEditorMount}
170170
onChange={(content) => onFileChanged(workspace, content)}
171-
onOpenFile={onFileSelected}
171+
onFileOpened={onFileSelected}
172172
/>
173173
{checkResult.error ? (
174174
<div
@@ -192,7 +192,6 @@ export default function Chrome({
192192
>
193193
<Diagnostics
194194
diagnostics={checkResult.diagnostics}
195-
workspace={workspace}
196195
onGoTo={handleGoTo}
197196
theme={theme}
198197
/>
@@ -290,8 +289,18 @@ function useCheckResult(
290289
};
291290
}
292291

292+
// Eagerly convert the diagnostic to avoid out of bound errors
293+
// when the diagnostics are "deferred".
294+
const serializedDiagnostics = diagnostics.map((diagnostic) => ({
295+
id: diagnostic.id(),
296+
message: diagnostic.message(),
297+
severity: diagnostic.severity(),
298+
range: diagnostic.toRange(workspace) ?? null,
299+
textRange: diagnostic.textRange() ?? null,
300+
}));
301+
293302
return {
294-
diagnostics,
303+
diagnostics: serializedDiagnostics,
295304
error: null,
296305
secondary,
297306
};

playground/knot/src/Editor/Diagnostics.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
1-
import { Diagnostic, Workspace } from "red_knot_wasm";
1+
import type { Severity, Range, TextRange } from "red_knot_wasm";
22
import classNames from "classnames";
33
import { Theme } from "shared";
44
import { useMemo } from "react";
55

66
interface Props {
77
diagnostics: Diagnostic[];
8-
workspace: Workspace;
98
theme: Theme;
109

1110
onGoTo(line: number, column: number): void;
1211
}
1312

1413
export default function Diagnostics({
1514
diagnostics: unsorted,
16-
workspace,
1715
theme,
1816
onGoTo,
1917
}: Props) {
2018
const diagnostics = useMemo(() => {
2119
const sorted = [...unsorted];
2220
sorted.sort((a, b) => {
23-
return (a.textRange()?.start ?? 0) - (b.textRange()?.start ?? 0);
21+
return (a.textRange?.start ?? 0) - (b.textRange?.start ?? 0);
2422
});
2523

2624
return sorted;
@@ -43,11 +41,7 @@ export default function Diagnostics({
4341
</div>
4442

4543
<div className="flex grow p-2 overflow-hidden">
46-
<Items
47-
diagnostics={diagnostics}
48-
onGoTo={onGoTo}
49-
workspace={workspace}
50-
/>
44+
<Items diagnostics={diagnostics} onGoTo={onGoTo} />
5145
</div>
5246
</div>
5347
);
@@ -56,10 +50,8 @@ export default function Diagnostics({
5650
function Items({
5751
diagnostics,
5852
onGoTo,
59-
workspace,
6053
}: {
6154
diagnostics: Array<Diagnostic>;
62-
workspace: Workspace;
6355
onGoTo(line: number, column: number): void;
6456
}) {
6557
if (diagnostics.length === 0) {
@@ -73,9 +65,9 @@ function Items({
7365
return (
7466
<ul className="space-y-0.5 grow overflow-y-scroll">
7567
{diagnostics.map((diagnostic, index) => {
76-
const position = diagnostic.toRange(workspace);
68+
const position = diagnostic.range;
7769
const start = position?.start;
78-
const id = diagnostic.id();
70+
const id = diagnostic.id;
7971

8072
const startLine = start?.line ?? 1;
8173
const startColumn = start?.column ?? 1;
@@ -86,7 +78,7 @@ function Items({
8678
onClick={() => onGoTo(startLine, startColumn)}
8779
className="w-full text-start cursor-pointer select-text"
8880
>
89-
{diagnostic.message()}
81+
{diagnostic.message}
9082
<span className="text-gray-500">
9183
{id != null && ` (${id})`} [Ln {startLine}, Col {startColumn}]
9284
</span>
@@ -97,3 +89,11 @@ function Items({
9789
</ul>
9890
);
9991
}
92+
93+
export interface Diagnostic {
94+
id: string;
95+
message: string;
96+
severity: Severity;
97+
range: Range | null;
98+
textRange: TextRange | null;
99+
}

playground/knot/src/Editor/Editor.tsx

Lines changed: 40 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ import {
1717
import { RefObject, useCallback, useEffect, useRef } from "react";
1818
import { Theme } from "shared";
1919
import {
20-
Diagnostic,
2120
Severity,
22-
Workspace,
21+
type Workspace,
2322
Position as KnotPosition,
2423
type Range as KnotRange,
2524
} from "red_knot_wasm";
2625

2726
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
2827
import { FileId, ReadonlyFiles } from "../Playground";
2928
import { isPythonFile } from "./Files";
29+
import { Diagnostic } from "./Diagnostics";
3030

3131
type Props = {
3232
visible: boolean;
@@ -38,7 +38,7 @@ type Props = {
3838
workspace: Workspace;
3939
onChange(content: string): void;
4040
onMount(editor: IStandaloneCodeEditor, monaco: Monaco): void;
41-
onOpenFile(file: FileId): void;
41+
onFileOpened(file: FileId): void;
4242
};
4343

4444
export default function Editor({
@@ -51,7 +51,7 @@ export default function Editor({
5151
workspace,
5252
onChange,
5353
onMount,
54-
onOpenFile,
54+
onFileOpened,
5555
}: Props) {
5656
const disposable = useRef<{
5757
typeDefinition: IDisposable;
@@ -61,14 +61,14 @@ export default function Editor({
6161
monaco: null,
6262
files,
6363
workspace,
64-
onOpenFile,
64+
onFileOpened,
6565
});
6666

6767
playgroundState.current = {
6868
monaco: playgroundState.current.monaco,
6969
files,
7070
workspace,
71-
onOpenFile,
71+
onFileOpened,
7272
};
7373

7474
// Update the diagnostics in the editor.
@@ -79,8 +79,8 @@ export default function Editor({
7979
return;
8080
}
8181

82-
updateMarkers(monaco, workspace, diagnostics);
83-
}, [workspace, diagnostics]);
82+
updateMarkers(monaco, diagnostics);
83+
}, [diagnostics]);
8484

8585
const handleChange = useCallback(
8686
(value: string | undefined) => {
@@ -98,7 +98,7 @@ export default function Editor({
9898

9999
const handleMount: OnMount = useCallback(
100100
(editor, instance) => {
101-
updateMarkers(instance, workspace, diagnostics);
101+
updateMarkers(instance, diagnostics);
102102

103103
const server = new PlaygroundServer(playgroundState);
104104
const typeDefinitionDisposable =
@@ -116,7 +116,7 @@ export default function Editor({
116116
onMount(editor, instance);
117117
},
118118

119-
[onMount, workspace, diagnostics],
119+
[onMount, diagnostics],
120120
);
121121

122122
return (
@@ -141,11 +141,7 @@ export default function Editor({
141141
);
142142
}
143143

144-
function updateMarkers(
145-
monaco: Monaco,
146-
workspace: Workspace,
147-
diagnostics: Array<Diagnostic>,
148-
) {
144+
function updateMarkers(monaco: Monaco, diagnostics: Array<Diagnostic>) {
149145
const editor = monaco.editor;
150146
const model = editor?.getModels()[0];
151147

@@ -170,16 +166,16 @@ function updateMarkers(
170166
}
171167
};
172168

173-
const range = diagnostic.toRange(workspace);
169+
const range = diagnostic.range;
174170

175171
return {
176-
code: diagnostic.id(),
172+
code: diagnostic.id,
177173
startLineNumber: range?.start?.line ?? 0,
178174
startColumn: range?.start?.column ?? 0,
179175
endLineNumber: range?.end?.line ?? 0,
180176
endColumn: range?.end?.column ?? 0,
181-
message: diagnostic.message(),
182-
severity: mapSeverity(diagnostic.severity()),
177+
message: diagnostic.message,
178+
severity: mapSeverity(diagnostic.severity),
183179
tags: [],
184180
};
185181
}),
@@ -191,7 +187,7 @@ interface PlaygroundServerProps {
191187
workspace: Workspace;
192188
files: ReadonlyFiles;
193189

194-
onOpenFile: (file: FileId) => void;
190+
onFileOpened: (file: FileId) => void;
195191
}
196192

197193
class PlaygroundServer
@@ -223,26 +219,29 @@ class PlaygroundServer
223219
new KnotPosition(position.lineNumber, position.column),
224220
);
225221

226-
const locations = links.map((link) => {
227-
const targetSelection =
228-
link.selection_range == null
229-
? undefined
230-
: knotRangeToIRange(link.selection_range);
231-
232-
const originSelection =
233-
link.origin_selection_range == null
234-
? undefined
235-
: knotRangeToIRange(link.origin_selection_range);
236-
237-
return {
238-
uri: Uri.parse(link.path),
239-
range: knotRangeToIRange(link.full_range),
240-
targetSelectionRange: targetSelection,
241-
originSelectionRange: originSelection,
242-
} as languages.LocationLink;
243-
});
244-
245-
return locations;
222+
return (
223+
links
224+
.map((link) => {
225+
const targetSelection =
226+
link.selection_range == null
227+
? undefined
228+
: knotRangeToIRange(link.selection_range);
229+
230+
const originSelection =
231+
link.origin_selection_range == null
232+
? undefined
233+
: knotRangeToIRange(link.origin_selection_range);
234+
235+
return {
236+
uri: Uri.parse(link.path),
237+
range: knotRangeToIRange(link.full_range),
238+
targetSelectionRange: targetSelection,
239+
originSelectionRange: originSelection,
240+
} as languages.LocationLink;
241+
})
242+
// Filter out vendored files because they aren't open in the editor.
243+
.filter((link) => link.uri.scheme !== "vendored")
244+
);
246245
}
247246

248247
openCodeEditor(
@@ -284,7 +283,7 @@ class PlaygroundServer
284283
if (files.selected !== fileId) {
285284
source.setModel(model);
286285

287-
this.props.current.onOpenFile(fileId);
286+
this.props.current.onFileOpened(fileId);
288287
}
289288

290289
if (selectionOrPosition != null) {

0 commit comments

Comments
 (0)