Skip to content

Commit 88abe99

Browse files
DaanV2claude
andauthored
fix(molang): prevent NaN diagnostic ranges from crashing the client (#588)
A degenerate molang node (e.g. an empty `{}` produced by the parser for particle component objects) has an undefined `position`. The Marker/default case in the molang syntax diagnoser passed that undefined position straight to `diagnoser.add`, where `GetRange` resolved it via `doc.positionAt(undefined)` to a `character: NaN`. That NaN serializes to `null` over JSON-RPC, making the client's `asDiagnostics` reject the entire diagnostic batch with 'Invalid arguments' and drop all diagnostics for the file. Fixes, in defense-in-depth layers: - Guard the degenerate node's position with a finite fallback in the molang syntax diagnoser. - Clamp non-finite/out-of-bounds offsets to the document bounds in GetRange and GetPosition so no caller can produce a NaN range. Adds regression tests for both layers. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5cafa16 commit 88abe99

4 files changed

Lines changed: 88 additions & 6 deletions

File tree

ide/base/server/src/util/document-location.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { GetRange } from './document-location';
1+
import { GetRange, GetPosition } from './document-location';
22
import * as vstd from 'vscode-languageserver-textdocument';
33

4+
const isFinitePosition = (p: vstd.Position) => Number.isFinite(p.line) && Number.isFinite(p.character);
5+
46
describe('GetRange', () => {
57
test('returns a valid zero-length range at end of document', () => {
68
const doc = vstd.TextDocument.create('file:///test.json', 'json', 1, '{\n}');
@@ -11,4 +13,38 @@ describe('GetRange', () => {
1113
expect(range.start).toEqual(doc.positionAt(offsetAtEof));
1214
expect(range.end).toEqual(doc.positionAt(offsetAtEof));
1315
});
16+
17+
// Regression: a degenerate molang node (e.g. empty `{}`) has an undefined position.
18+
// Without clamping this produced a NaN character that serializes to `null` over
19+
// JSON-RPC, causing the client to drop the entire diagnostic batch.
20+
test.each([undefined, NaN, Infinity, -Infinity, -5])(
21+
'produces a finite range for non-finite/out-of-bounds offset %p',
22+
(offset) => {
23+
const doc = vstd.TextDocument.create('file:///test.json', 'json', 1, '{\n}');
24+
25+
const range = GetRange(offset as unknown as number, doc);
26+
27+
expect(isFinitePosition(range.start)).toBe(true);
28+
expect(isFinitePosition(range.end)).toBe(true);
29+
},
30+
);
31+
32+
test('produces a finite range for an OffsetWord with a non-finite offset', () => {
33+
const doc = vstd.TextDocument.create('file:///test.json', 'json', 1, '{\n}');
34+
35+
const range = GetRange({ offset: NaN, text: '{}' } as any, doc);
36+
37+
expect(isFinitePosition(range.start)).toBe(true);
38+
expect(isFinitePosition(range.end)).toBe(true);
39+
});
40+
});
41+
42+
describe('GetPosition', () => {
43+
test.each([undefined, NaN, Infinity, -5])('produces a finite position for offset %p', (offset) => {
44+
const doc = vstd.TextDocument.create('file:///test.json', 'json', 1, '{\n}');
45+
46+
const position = GetPosition(offset as unknown as number, doc);
47+
48+
expect(isFinitePosition(position)).toBe(true);
49+
});
1450
});

ide/base/server/src/util/document-location.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@ import { Range } from 'vscode-languageserver';
33
import * as vstd from 'vscode-languageserver-textdocument';
44
import { Character } from './character';
55

6+
/**
7+
* Clamps an offset into the valid `[0, text.length]` range for the given document.
8+
*
9+
* A non-finite offset (`undefined`, `NaN`, `Infinity`) would otherwise flow into
10+
* `doc.positionAt(...)` and produce a `character: NaN`, which serializes to `null`
11+
* over JSON-RPC and makes the client reject the entire diagnostic batch.
12+
* @param offset The raw offset to clamp
13+
* @param doc The document the offset refers to
14+
* @returns A finite offset within the document's bounds
15+
*/
16+
function clampOffset(offset: number, doc: vstd.TextDocument): number {
17+
if (!Number.isFinite(offset)) return 0;
18+
const max = doc.getText().length;
19+
if (offset < 0) return 0;
20+
if (offset > max) return max;
21+
return offset;
22+
}
23+
624
/**
725
*
826
* @param position
@@ -23,11 +41,14 @@ export function GetRange(position: DocumentLocation, doc: vstd.TextDocument): Ra
2341
position = doc.offsetAt(position);
2442
//If document location is already an offset, then grab the start position
2543
} else if (OffsetWord.is(position)) {
26-
Start = doc.positionAt(position.offset);
27-
End = doc.positionAt(position.text.length + position.offset);
44+
const offset = clampOffset(position.offset, doc);
45+
const length = Number.isFinite(position.text.length) ? Math.max(0, position.text.length) : 0;
46+
Start = doc.positionAt(offset);
47+
End = doc.positionAt(clampOffset(offset + length, doc));
2848

2949
return { start: Start, end: End };
3050
} else {
51+
position = clampOffset(position, doc);
3152
Start = doc.positionAt(position);
3253
}
3354

@@ -64,9 +85,9 @@ export function GetRange(position: DocumentLocation, doc: vstd.TextDocument): Ra
6485
export function GetPosition(position: DocumentLocation, doc: vstd.TextDocument): vstd.Position {
6586
if (Position.is(position)) return position;
6687
if (JsonPath.is(position)) return resolveJsonPath(position, doc).start;
67-
if (OffsetWord.is(position)) return doc.positionAt(position.offset);
88+
if (OffsetWord.is(position)) return doc.positionAt(clampOffset(position.offset, doc));
6889

69-
return doc.positionAt(position);
90+
return doc.positionAt(clampOffset(position, doc));
7091
}
7192

7293
/**Resolves a json path to a range

packages/bedrock-diagnoser/src/diagnostics/molang/expressions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,12 @@ export function diagnose_molang_syntax(expression: ExpressionNode, diagnoser: Di
173173

174174
case NodeType.Marker:
175175
default:
176+
// A degenerate node (e.g. an empty `{}` produced by the parser) can have an
177+
// undefined position. Falling through to diagnoser.add with `undefined` makes
178+
// the range resolve to a NaN character, which serializes to `null` over
179+
// JSON-RPC and causes the client to drop the whole diagnostic batch.
176180
diagnoser.add(
177-
n.position,
181+
n.position ?? 0,
178182
`unknown piece of molang syntax: ${JSON.stringify(n)}`,
179183
DiagnosticSeverity.error,
180184
'molang.diagnoser.syntax',
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { diagnose_molang_syntax } from '../../../../src/diagnostics/molang';
2+
import { TestDiagnoser } from '../../../diagnoser';
3+
4+
describe('Molang degenerate node', () => {
5+
// Regression: a degenerate node (e.g. an empty `{}` produced by the parser) has no
6+
// `position`. Reporting it with `position === undefined` made the downstream range
7+
// resolve to a NaN character, which serializes to `null` over JSON-RPC and caused the
8+
// client to drop the entire diagnostic batch.
9+
test('reports an unknown-syntax diagnostic with a finite position for an empty `{}` node', () => {
10+
const diagnoser = new TestDiagnoser();
11+
12+
// An empty object stands in for the degenerate node observed in the wild.
13+
diagnose_molang_syntax({} as any, diagnoser);
14+
15+
expect(diagnoser.items).toHaveLength(1);
16+
const item = diagnoser.items[0];
17+
expect(item.code).toBe('molang.diagnoser.syntax');
18+
expect(typeof item.position).toBe('number');
19+
expect(Number.isFinite(item.position as number)).toBe(true);
20+
});
21+
});

0 commit comments

Comments
 (0)