Skip to content

Commit 28f5e50

Browse files
authored
fix(files): stop rejecting ordinary HTML documents at the parser limits (#6446)
The markup-token cap rejected a 30,000-row table export at 3.6 MB. Doubled it to a value measured safe, and left the byte cap where it is - that one is load-bearing, so the reasoning is now recorded alongside it.
1 parent 86ac309 commit 28f5e50

2 files changed

Lines changed: 49 additions & 22 deletions

File tree

apps/sim/lib/file-parsers/html-parser.test.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,39 @@ const parser = new HtmlParser()
1111

1212
describe('HtmlParser', () => {
1313
describe('resource limits', () => {
14+
/**
15+
* Pinned by value: a 64 MB body aborts the process, so raising the cap
16+
* toward the shared upload limit must fail here, not in production.
17+
*/
1418
it('rejects a document above the input byte cap', async () => {
15-
const sparse = Buffer.concat([
16-
Buffer.from('<html><body><p>'),
17-
Buffer.alloc(32 * 1024 * 1024, 0x61),
18-
Buffer.from('</p></body></html>'),
19-
])
20-
21-
await expect(parser.parseBuffer(sparse)).rejects.toThrow(
22-
/above the maximum of 33554432 bytes/
23-
)
19+
const oversized = Buffer.alloc(32 * 1024 * 1024 + 1)
20+
21+
const error = await parser.parseBuffer(oversized).catch((e) => e)
22+
23+
expect(error).toBeInstanceOf(HtmlComplexityError)
24+
expect(error.message).toMatch(/above the maximum of 33554432 bytes/)
2425
})
2526

2627
it('rejects a tag-dense document above the markup-token cap', async () => {
27-
const dense = Buffer.from(`<html><body>${'<p>a</p>'.repeat(300_000)}</body></html>`)
28+
const dense = Buffer.from(`<html><body>${'<p>a</p>'.repeat(600_000)}</body></html>`)
2829

2930
const error = await parser.parseBuffer(dense).catch((e) => e)
3031

3132
expect(error).toBeInstanceOf(HtmlComplexityError)
32-
expect(error.message).toMatch(/exceeds the maximum of 500000 markup tokens/)
33+
expect(error.message).toMatch(/exceeds the maximum of 1000000 markup tokens/)
34+
})
35+
36+
/**
37+
* A 30,000-row by 8-column export is ~540k tokens in 3.6 MB, an ordinary
38+
* document that an earlier, tighter token cap rejected.
39+
*/
40+
it('accepts a realistic large table export', async () => {
41+
const row = `<tr>${'<td>value</td>'.repeat(8)}</tr>`
42+
const buffer = Buffer.from(`<html><body><table>${row.repeat(30_000)}</table></body></html>`)
43+
44+
const result = await parser.parseBuffer(buffer)
45+
46+
expect(result.content).toContain('| value |')
3347
})
3448

3549
it('accepts a byte-heavy document whose markup stays under the token cap', async () => {
@@ -42,13 +56,11 @@ describe('HtmlParser', () => {
4256
})
4357

4458
/**
45-
* Deep nesting overflows the stack inside cheerio's own recursive `.text()`,
46-
* which the pre-parse caps cannot predict. It still has to be classified as
47-
* a resource rejection so callers fail closed rather than fall back to
48-
* storing the document as raw text.
59+
* `parseFile` must not wrap the rejection in a generic error, or the route
60+
* stops recognising it and falls back to storing the document as raw text.
4961
*/
5062
it('preserves the error type through parseFile so callers still fail closed', async () => {
51-
const dense = `<html><body>${'<p>a</p>'.repeat(300_000)}</body></html>`
63+
const dense = `<html><body>${'<p>a</p>'.repeat(600_000)}</body></html>`
5264
const path = join(tmpdir(), `html-parser-limits-${process.pid}.html`)
5365
await writeFile(path, dense)
5466

@@ -59,6 +71,11 @@ describe('HtmlParser', () => {
5971
}
6072
})
6173

74+
/**
75+
* Deep nesting overflows the stack inside cheerio's own recursive `.text()`,
76+
* which the pre-parse caps cannot predict. It still has to be classified as
77+
* a resource rejection so callers fail closed.
78+
*/
6279
it('classifies a deep-nesting stack overflow as a complexity rejection', async () => {
6380
const depth = 15_000
6481
const buffer = Buffer.from(

apps/sim/lib/file-parsers/html-parser.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,25 @@ import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
88
const logger = createLogger('HtmlParser')
99

1010
/**
11-
* `cheerio.load` retains ~530 bytes of DOM per markup token (`<`) — measured on
12-
* cheerio 1.1.2 at 0.2M/1M/2M tokens (101/504/1008 MB), flat across all three —
13-
* so this bounds one document's tree at roughly 256 MB.
11+
* Bounds the DOM tree, which costs ~500 bytes per markup token (`<`) on cheerio
12+
* 1.1.2: measured at 0.2M/1M/2M tokens as 101/504/1008 MB retained, linear
13+
* across all three.
14+
*
15+
* A 50,000-row by 8-column table export is ~900k tokens in only 8.7 MB, so a
16+
* tighter cap rejects ordinary exports; 999k tokens in a 10 MB body parses
17+
* inside a 2 GB heap.
1418
*/
15-
const MAX_HTML_MARKUP_TOKENS = 500_000
19+
const MAX_HTML_MARKUP_TOKENS = 1_000_000
1620

1721
/**
18-
* Backstop for markup sparse enough to pass the token cap: bounds the UTF-16
19-
* copy `buffer.toString` allocates and the text nodes the DOM keeps.
22+
* Bounds the body, which governs peak memory: extraction materialises the text
23+
* several times over (UTF-16 buffer copy, per-node strings, the joined output).
24+
*
25+
* Measured against a 2 GB heap with the token cap saturated: 32 MB parses,
26+
* 48 MB parses, 64 MB aborts the process. Deliberately NOT raised to the
27+
* shared 100 MB document limit — that limit governs what may be uploaded, and
28+
* a 100 MB body aborts here even with few tokens. Any change to this number
29+
* needs the same abort test, not a consistency argument.
2030
*/
2131
const MAX_HTML_INPUT_BYTES = 32 * 1024 * 1024
2232

0 commit comments

Comments
 (0)