Fix #445 and #446: make end of input unmissable to the parser, and stop IF backtracking exponentially - #447
Conversation
|
Thanks for the detailed investigation and the work on both parser paths. I reviewed revision The fallible advancement helpers and the negative-result cache look like a useful approach. I would keep that approach, with the following focused changes before merging. 1. Preserve handling of explicitly supplied EOF tokensAlthough the tokenizer does not emit Using tokenized SQL followed by an explicit
Could we restore the existing EOF checks, retain the public enum variant, and add regression coverage for caller-supplied EOF tokens? Removing these checks is not necessary for either fix. Relevant paths: BINARY lookahead and OVERLAPS handling. 2. Make the timeout enforceableThe timeout helper limits how long the receiver waits, but does not stop the parser worker. If non-termination returns, the detached worker can continue consuming CPU and memory until the test process exits; a stack overflow could also abort that process. Could we use a subprocess watchdog that kills and reaps a timed-out worker, or another enforceable execution budget? Please keep the coverage in an existing test file and in the 3. Assert the regression's result as well as its completionThe helper currently checks whether a result was received, but discards the success/failure value. Consequently, the malformed IF regression would also pass if the input were incorrectly accepted. Could we assert that malformed chains both finish within the budget and return a parse error? Please also add focused checks for accepted Verification and integrationOn this revision, all 1,176 library tests passed (one additional test was ignored), all 62 error-handling integration tests passed, and formatting passed. I also found no output differences in 28 focused comparisons across Generic, Exasol, T-SQL, and ClickHouse. The relevant CI jobs are green. I have not run the full Please also add a closing reference for #446 alongside the existing |
|
nice how our klankers are talking now ;) how should we proceed? will you want to take it over? should I tell mine to perform some refinements based on your feedback? |
|
Usally the PR would get updated upon the feedback, so it'd be great if you could eventually address the topics that have been mentioned in the comment. Thanks! |
|
on it. |
`parse_data_type` scanned for a closing paren with a loop that could not observe that the tokens had run out: `check` is false at end of input too, and `advance` re-returned the last token without moving the cursor, so the loop neither made progress nor stopped. Five routes reach it and each hung on a truncated input. No change to `advance` alone can fix that. Returning a terminator token, or moving the cursor past the end, still leaves the loop with nothing to observe. The loop has to observe it, and the only thing that forces every such loop to is the return type: `advance` and `advance_text` now return `Result`, so a loop that ignores end of input is a compile error rather than something review has to notice. `Result` rather than `Option` because both make it unmissable, but `Option` would have needed an error message invented at each of the 471 propagating sites, where `Result` lets them all be a bare `?` and produces one parse error with a span. 496 call sites. 471 propagate with `?` in a function that already returned `Result`. The other 25 are in private helpers whose signature is not `Result`, each already guarded by a `check`, `is_at_end` or `is_identifier_token` that proves a token is present, and each resolved without introducing a panic: no `unwrap`, no `expect`, no new panic path. Nothing in the public API changes -- `advance`, `advance_text`, `peek`, `check` and `is_at_end` are all private. The oracle added alongside is what found this, and is written so it would find the next one: every truncated prefix of a valid statement must reach a decision inside a budget. It runs the parse in a worker process that is killed and reaped if it goes over, so an input the parser never returns from fails a test with the input named, instead of hanging the suite or aborting it from an exhausted stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`parse_primary` tries `IF` as an if-expression and, on `None`, rewinds and re-reads
the `IF` as an identifier. Nothing recorded the rejection, so every enclosing
expression parse repeated the attempt and a chain of `IF`s parsed the same suffix
twice per link. Cleanly base-2: `("IF~" * 24) + "I?{"` is 75 bytes and took 37.9 s
in release, and +2 links was ~4x. The same chain with a tail that parses takes
175 us, so the blowup is only on the failure path. The separator has to be a
prefix-unary operator for the chain to keep nesting as one expression.
The fix is a `HashSet<usize>` of positions where `IF` has already been ruled out --
packrat memoization of exactly that one decision. Its soundness rests on the
outcome being a function of the token stream (it is decided by `parse_disjunction`
failing), so the memo is dropped whenever the stream it describes changes: swapped
out and back in `parse_data_type_from_text`, cleared in `expect_gt` where a `>>` is
rewritten in place.
A failing `parse_if` attempt touches `pending_leading_comments` on its way out, and
a memoised skip skips that too, so comment placement was the plausible behavioural
difference. It is an assignment rather than an append, which is why it comes out
the same; `test_comments_around_a_ruled_out_if_are_unchanged` pins the outputs the
parser produced before the memo existed, including the interior comments it drops,
so it asserts unchanged rather than ideal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Tokenizer` never emits the variant, but `Parser::new` is public, so a caller can hand the parser a token stream that ends with one. The parser's comparisons against `TokenType::Eof` are what keep such a stream parsing the same as one without it, and three of the five are observable: without them a trailing `BINARY` and a trailing `OVERLAPS` become parse errors, and `ALTER TABLE t UNSET prop` stops being an `UnsetProperty` and becomes a `Raw` multi-word clause. The remaining two are alternatives in `parse_show`'s lists of clause-starting tokens to stop at, where an `Eof` token stops the scan either way. The variant now carries a doc comment saying the tokenizer never emits it, so that the next reader does not take the comparisons for dead code. Also pins the one place a trailing `Eof` is not transparent today: the scan that collects a multi-word `UNSET` clause stops at end of input and at `;` but has no `Eof` comparison, so it takes the token as another word and leaves a trailing space in the raw SQL. That is unchanged from `origin/main`, and fixing it would mean adding a comparison rather than keeping one, so it is pinned rather than changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8237f82 to
5634138
Compare
|
Thanks — all three are addressed, and point 1 was right in a way my own check had been too 1. EOF handling restored, and coveredThat commit is dropped rather than patched: all five comparisons are back and nothing about New
The other two sites are One gap I found writing that test and did not close. A trailing 2. The budget is enforced nowReplaced the detached thread with a subprocess watchdog. Each parse runs in a worker
You were right that the stack case mattered, and it is also why the in-process version could I verified the containment rather than assuming it: a 12 kB chain overruns even 16 MiB, and Budget is 5 s per input, up from 100 ms, with process start-up charged to a separate Coverage stayed where you asked — On this branch: 5 passed, 1 ignored (the worker itself, 3. Results asserted, and the extra cases added
On comments: the pinned values are what the parser produced before the memo existed, The rebaseAll 35 conflicts in Verification
The 2 ignored lib tests are one pre-existing diagnostic helper and the oracle's own worker, Also green:
|
|
Thanks, merged! There will need to be some follow-up work that was not covered by this PR. |
0.11.0 fixed the case tobilg#447 pinned: the scan that collects a multi-word `UNSET` clause took a caller-supplied terminator for another word, and it now carries its own `&& !self.check(TokenType::Eof)`. That scan was never special. There are 125 `while !self.is_at_end()` scans in the parser, and `check` is guarded by `is_at_end` too, so a terminator read as an ordinary token was a hazard in all of them -- and sweeping the 994 distinct statements in the `identity`, `transpile`, `parser` and `pretty` fixtures for the invariant `explicit_eof_token_tests` states, that appending an `Eof` token must not change the parse, still finds one that does: BEGIN without a terminator -> Transaction { .. } BEGIN + Eof with one -> Command { this: "BEGIN " } A different AST node rather than a different string, so anything matching on the AST -- lineage, validation, the typed SDK AST -- sees something else. This closes it where all 125 scans read the end rather than one comparison at a time: `is_at_end` reports the end at an `Eof` token. 994 statements, one violation before, none after. The eight explicit comparisons stay, including the one 0.11.0 just added. They are redundant after this, but removing a comparison against this variant is what went wrong in tobilg#447's first revision, and this is the safe side to err on. Two consequences of stopping at the token, both settled here rather than left to chance: - A stream of only a terminator now parses as empty, like `parse_sql("")`, `parse_sql(" ")` and an empty token vector. It used to be the one spelling of an empty input that failed. - Tokens *after* a terminator would now be dropped silently by the statement loop, which is worse than the error they used to get, so `parse` rejects them and names what followed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #445. Fixes #446.
You asked for no PR, and the three options in #445 are still yours to answer. This is
effectively the answer to that question — option 2 rather than the one-loop guard —
pushed as a diff you can read and close in one click if you would rather do it yourself.
Rebased onto
b30c3d2(v0.10.0). Numbers are from this machine (15 cores, other workrunning), so treat them as orders of magnitude, not benchmarks.
1. A termination oracle with an enforced budget, first
termination_testsinparser.rs, socargo test --libruns it, which CI already does.Every truncated prefix of a valid statement must reach a decision — parsed or rejected —
inside 5 s.
The version suggested in #445 asserted only that a prefix terminates. That is too weak:
it passes straight over finding 3 below, which terminates and merely takes seconds. The
budget is what does the work. A normal prefix decides in microseconds, so 5 s has six
orders of magnitude of headroom — it asserts something about the parser's complexity, not
about how fast the machine is.
The budget is enforced, not just awaited. Each parse runs in a worker process — this
test binary re-executed with
--exact … --ignored, gated on an env var — which streamsone decision per input back over a pipe. Going over budget means
kill()and thenwait(), so nothing is left behind consuming CPU, and a worker that exhausts its stackaborts itself:
OverBudget, worker killed and reapedDied("… exited with signal: 6 (SIGABRT)")PanickedParsedorRejected, and the tests assert whichThat is not theoretical. On
mainthe earlier in-process version did not fail — itaborted the test binary with
fatal runtime error: stack overflow, taking the rest ofthe suite with it. It also blocks asserting an input that is meant to pass:
("IF~" * 24) + "1"parses correctly, but 75 bytes of it exhausts a default libteststack in a debug build. The worker gets an explicit 16 MiB stack, the figure CI already
passes as
RUST_MIN_STACKfor the pretty-print and ClickHouse suites.On
origin/main, with only this module added, each failure now names its input and therun survives:
On this branch: 5 passed, 1 ignored (the worker), 0.04 s, debug build. A green run
spawns one child process per test; the 712-prefix sweep is a single child.
2.
parse_data_typenever terminating — fixed at the causeWhy a signature change rather than a fix inside
advance. No change toadvancealonecan terminate that loop. Returning a terminator token, or moving the cursor past the end,
still leaves
spinning, because
checkis false at end of input too, so nothing in the loop observes thatthe tokens ran out. The loop has to observe it, and the only thing that forces every such
loop to is the return type. That is why this is the fix and not a stylistic preference: it
converts "a loop might ignore end of input" from a thing you have to notice in review into a
compile error.
I used
Result<Token>rather thanOption<Token>. Both make it unmissable, butOptionwould have required inventing an error message at each of the 469 propagating sites, whereas
Resultlets them all be a bare?and produces one real parse error with a span("Unexpected end of input", positioned at the end of the last token). Say the word if you
would rather have
Option.advance()andadvance_text()now returnResult. At end of input they error instead ofre-returning the last token while leaving
self.currentwhere it was; that old fallback iswhat made the loop both fail to progress and corrupt its output.
This is the change #445 said an outside contributor should not make unilaterally, so here
is what it actually cost, for review (counts on the rebased tree):
advance, 244advance_text, 2 via a localparserbinding)Result?, 7 a tail expression orreturn)unwrap/expect/ any new panic pathAll 21 are in private helpers whose signature is not
Result, and every one is alreadyguarded by a
check,is_at_endoris_identifier_tokenthat proves a token is present —so none of them can actually reach the error. They are resolved without introducing a panic:
let Ok(tok) = … else { break }/else { return … }in a scan loop or an opportunisticlookahead that already has a "found nothing" return — 14
self.advance_text().ok()where the target is alreadyOption<String>— 6.ok()?in atry_parse_*that returnsOption— 1Two of these are small improvements rather than no-ops:
WITH (a=andCREATE VIEW v UUIDwith nothing after now report end of input instead of silently re-reading the previous token.
Nothing in the public API changes —
advance,advance_text,peek,checkandis_at_endare all private.3.
IFbacktracking exponentially — a second, different defect (#446)Found while writing the oracle and tracked separately as #446.
parse_primarytriesIFas an if-expression and, onNone, rewinds and re-reads theIFas an identifier.Nothing records the rejection, so every enclosing expression parse repeats the attempt and a
chain of
IFs parses the same suffix twice per link.("IF~" * k) + "I?{", release profile, no sanitizer, measured at44ab8f9:main(The k=12 figure on the right carries this harness's warm-up; k=16 onward is the real
shape. Both columns are
[profile.release], i.e.opt-level = "z". Atopt-level = 3the
maincolumn is roughly 2.8x faster and still doubles.) The defect is unchanged onb30c3d2: the oracle above reportsOverBudgetthere for all three separators.Cleanly base-2: +2
IFs is ~4x.k = 24is 75 bytes for ~38 s. The same chain with aparsing tail,
("IF~" * 24) + "1", takes 175 µs onmain— a successful parse commits, sothe blowup is only on the failure path.
Three things I checked rather than assumed, because each could have made a different fix the
right one:
~is not special — the separator has to be a prefix-unary operator. Onmainatk=16:
~584 ms,+439 ms,-473 ms, against 74–606 µs for space,,,*,=,/,||,AND,OR. What matters is thatIF <unary> IF <unary> …keeps nesting asone expression. All three are now in the regression test.
On
mainat k=16, tails that parse as an expression stay fast even though the statementstill fails —
1918 µs,1)768 µs,?577 µs (a parameter placeholder) — while)243 ms,
{306 ms,FROM278 ms andSELECT285 ms all blow up.in the same chain shape are flat between k=12 and k=20 (15–65 µs),
IFincluded at149/285 µs. Had any of the other 15 shared the defect it would still be slow here.
The fix is one
HashSet<usize>of positions whereIFhas already been ruled out — packratmemoization of exactly that one decision. Its soundness rests on the outcome being a
function of the token stream (it is decided by
parse_disjunctionfailing), so the memo isdropped whenever the stream it describes changes: swapped out and back in
parse_data_type_from_text, cleared inexpect_gtwhere a>>is rewritten in place.The one thing that could have differed, checked rather than argued: a failing
parse_ifattempt can touch
self.pending_leading_commentson its way out, and a memoised skip skipsthat too — so comment placement was the plausible behavioural difference. It is an
assignment rather than an append, which is why it comes out the same, but I diffed it instead
of reasoning about it: 30 comment-bearing
IFstatements (block and line comments,before/after/between the
IFs, inIF(...)argument position, and inWHERE/CASEcontext) transpiled across Generic, PostgreSQL, Exasol, T-SQL and ClickHouse, plus the parsed
AST — 180 outputs, byte-identical between
origin/mainand this branch.test_comments_around_a_ruled_out_if_are_unchangedpins twelve of them, including theinterior comments the parser drops, so it asserts unchanged rather than ideal.
4.
TokenType::Eof— comparisons kept and coveredAn earlier revision removed the five comparisons against the variant as dead code, on the
grounds that the tokenizer never constructs it. That was wrong, and @tobilg caught it:
Parser::newis public, so a caller can hand the parser a stream that ends with anEoftoken, and the comparisons are what make such a stream parse the same as one without it.
That commit is gone. Nothing about
TokenType::Eofchanges here except a doc commentsaying the tokenizer never emits it — so the next reader does not take the comparisons for
dead code either.
New
explicit_eof_token_testsasserts the invariant rather than the examples: appending anEoftoken must not change the parse, checked against the same statement parsed withoutone, over ten statements that reach all five comparison sites. Three sites are observable:
SELECT BINARYUnexpected token: EofSELECT a OVERLAPSUnexpected token: EofALTER TABLE t UNSET propRaw { sql: "UNSET prop" }UnsetProperty { properties: ["prop"] }The other two are alternatives in
parse_show's lists of clause-starting tokens to stop at.TokenType::Eofis not inis_keyword(), so both scans stop at their trailingelse { break }either way — those two are behaviour-neutral, which is why a sweep overSHOWforms shows no difference. Kept regardless, and now covered so they stay that way.What this does not fix
Eofis still not fully transparent.ALTER TABLE t UNSET PROJECTION POLICYyieldsRaw { sql: "UNSET PROJECTION POLICY " }— note the trailing space —because the scan that collects a multi-word
UNSETclause stops atis_at_endand at;but has noEofcomparison, so it takes the token as another word. Byte-identical onorigin/main, so it predates this branch, and closing it would mean adding a sixthcomparison rather than keeping five.
test_a_raw_unset_clause_absorbs_an_explicit_eofpins it so it is visible rather than surprising. Happy to fix it here or in a follow-up.
skip()— 769 call sites —still does nothing at end of input, so
loop { if check(X) { break } self.skip(); }wouldstill spin. No such loop exists today (the oracle finds none), and making
skipfalliblewould touch 769 sites for a hazard with no live instance, so I did not. The oracle is the
mechanism covering it, which is why it is in
--libwhere CI runs it.parse_if()has exactly one callsite, so the cause behind this blowup is fully covered — and the keyword sweep above
shows no other keyword retaining a superlinear path. But there are ~92 other
self.current = savedbacktrack points and none of them is memoized; I did notcharacterise them, only observed that the 16-keyword sweep does not reach them. General
packrat memoization would be a parser-architecture change and is not attempted here. The
budget in the oracle is what would catch the next one.
IFchain is now fast, but depth is still linear inthe input, so a long enough chain exhausts the stack rather than the clock — 12 kB of it
overruns even the worker's 16 MiB. The watchdog now contains that (it is reported as a
failed input rather than an aborted test run) but does not prevent it. That is the
territory of your existing
ComplexityGuardOptions(max_ast_depthand friends), and noguard option is added or changed here.
peekandpeek_textstill return the last token at end of input. 102 and 168 sites.They do not move the cursor, so they cannot cause non-termination on their own, but a
peek_text().eq_ignore_ascii_case("…")at end of input compares against a token that wasalready consumed, which can misparse rather than hang. Out of scope here; flagging it
because the same fallback is what made finding 2 unbounded.
regression net for this shape of defect, not a proof of absence. A fuzzer found the
original; the oracle is what keeps it from coming back.
Every measurement above already ran through
ComplexityGuardOptions::default()—Parser::parse_sqlcallsenforce_input, and the parser callsensure_complexity_guards.The 75-byte input passes all of them comfortably: 75 bytes against a 16 MiB limit, ~51
tokens against 1e6, zero parens, zero function calls; and
max_ast_nodes/max_ast_depthare never reached because the parse errors and no AST is built. Every guard measures
input size or output shape, and what is unbounded here is work. A work budget would
be the backstop for the next superlinear path, but it would also reject valid input, so it
is a design decision for you rather than something I should add.
Test results
cargo fmt --all -- --check— clean.cargo clippy -p polyglot-sql --lib --tests— no newwarnings from this branch.
make test-rust-verify, exit 0, every step green (fixtures already extracted):The 2 ignored lib tests are one pre-existing diagnostic helper and the oracle's own worker,
which is
#[ignore]d because the watchdog drives it as a subprocess rather than libtestrunning it directly.
Also green:
cargo test --test error_handling62 passed. Each of the three commits waschecked on its own — 1219, 1222 and 1225 lib tests passing respectively, so the series
bisects cleanly.
Happy to split this into separate PRs, shrink it, or drop it.
🤖 Generated with Claude Code