Skip to content

Fix a spurious AST fuzzer abort on a recycled node address - #113114

Merged
PedroTadim merged 3 commits into
masterfrom
fix-query-fuzzer-visited-node-aba
Aug 4, 2026
Merged

Fix a spurious AST fuzzer abort on a recycled node address#113114
PedroTadim merged 3 commits into
masterfrom
fix-query-fuzzer-visited-node-aba

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Aug 3, 2026

Copy link
Copy Markdown
Member

The Stress test job kills the server with Received signal 6 (internal) from inside the AST fuzzer itself:

src/Common/QueryFuzzer.cpp:5955:9: DB::QueryFuzzer::fuzz(...)   <- std::abort()
src/Common/QueryFuzzer.cpp:1682:17: DB::QueryFuzzer::fuzzCreateQuery(DB::ASTCreateQuery&)
src/Common/QueryFuzzer.cpp:8768:5: DB::QueryFuzzer::fuzzMain(...)
DB::executeASTFuzzerQueries(...)

QueryFuzzer::fuzz detects loops in the AST by remembering the address of every node it visits during one fuzzMain call, and calls std::abort when it sees an address twice. But fuzzing legitimately destroys parts of the query it has already visited: wrapping a table into Remote / Distributed / Buffer drops the whole column list, a random constraint or projection is erased, a key clause is removed from a storage definition, a child is replaced with a new node. Once such a node is freed, the allocator is free to hand its address to a node created later, and the check then aborts the server although the AST contains no loop at all.

That is what happened here. The AST the fuzzer dumped before aborting (from stderr.log) contains the reported node 0xff5442bef380 exactly once, so there is no loop:

The AST node '0xff5442bef380' was already visited before. Depth 4, 12 visited nodes, current top AST:
ParallelWithQuery_2_CreateQuery_table1__fuzz_8
-CreateQuery_table1__fuzz_8
--Storage definition
---Function_Remote
----ExpressionList
-----Literal_'127.0.0.1'
-----Function_currentDatabase
------ExpressionList
-----Literal_'table1__fuzz_7'
--Identifier_table1__fuzz_8
-CreateQuery_table2__fuzz_7
--Columns definition
---ExpressionList
----ColumnDeclaration_y
-----DataType_Array, 0x0000ff5442bef380
------ExpressionList
-------DataType_UInt16
...

The corpus query was CREATE TABLE table1(x Int32) ... PARALLEL WITH CREATE TABLE table2(y Int32) ... from 03305_parallel_with.sql. The fuzzer first fuzzed the column list of table1 (visiting its column declaration and data type), then wrapped table1 into Remote(...) and dropped that column list, freeing the nodes it had just visited. Parsing Array(UInt16) for the column of table2 afterwards got one of the freed addresses back.

The fix keeps every visited node alive for the duration of the fuzzMain call, so that its address cannot be recycled and pointer identity remains a valid answer to "have I visited this node before". Real loops are still detected.

No test: the failure depends on the allocator handing back a specific address, so it cannot be pinned down deterministically.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=dd1e53dfe2715c22d4b7e63c2459bc9eec994581&name_0=MasterCI&name_1=Stress%20test%20%28arm_release%29

Changelog category (leave one):

  • CI Fix or Improvement (changelog entry is not required)

Version info

  • Merged into: 26.8.1.777 (included in 26.8 and later)

`QueryFuzzer::fuzz` detects loops in the AST by remembering the address of every
node it visits during one `fuzzMain` call, and calls `std::abort` when it sees an
address twice. But fuzzing legitimately destroys parts of the query it has
already visited: wrapping a table into `Remote` / `Distributed` / `Buffer` drops
the whole column list, a random constraint or projection is erased, a key clause
is removed from a storage definition, a child is replaced with a new node. Once
such a node is freed, the allocator is free to hand its address to a node created
later - for instance to a data type parsed by `fuzzColumnDeclaration` for a
column of another table - and the check then aborts the server although the AST
contains no loop at all.

This is what happened in the report below, where the fuzzer wrapped
`CREATE TABLE table1(x Int32)` into `Remote(...)` (dropping the already visited
column list of `table1`) and then parsed `Array(UInt16)` for the column of
`table2`: the dumped AST contains the reported node exactly once.

Keep every visited node alive for the duration of the `fuzzMain` call, so that
its address cannot be recycled and pointer identity remains a valid answer to
"have I visited this node before".

https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=dd1e53dfe2715c22d4b7e63c2459bc9eec994581&name_0=MasterCI&name_1=Stress%20test%20%28arm_release%29
@clickhouse-gh

clickhouse-gh Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [794880a]

Summary:


AI Review

Summary

This PR changes the AST fuzzer's loop detection from a raw-pointer set to a pointer-to-ASTPtr map so visited nodes stay alive and allocator reuse cannot trigger a spurious abort. The idea is correct, but the current implementation still drops that keepalive in several subtree-rewrite paths, so the same ABA false positive remains reachable in the current code.

Findings

❌ Blockers

  • [src/Common/QueryFuzzer.h:219] The new contract says visited nodes stay alive for the whole fuzzMain call, but several rewrite branches still call debug_visited_nodes.erase(ast.get()) before replacing the node. The concrete ASTDataType rewrite at src/Common/QueryFuzzer.cpp:8127 can still destroy a previously visited type node mid-pass, letting a later sibling allocation recycle the same address and trigger the same spurious loop abort this PR is meant to remove.
    Suggested fix: keep visited-node ownership monotonic for the whole fuzzMain call, or split it into two structures: one container that owns every visited node until fuzzMain finishes, and a separate visited-address set for loop detection.
Final Verdict

Status: ⚠️ Request changes

Minimum required actions:

  • Stop releasing visited-node ownership on subtree replacement, or otherwise separate keepalive ownership from loop-detection bookkeeping so visited addresses cannot be recycled during a single fuzzMain call.

@clickhouse-gh clickhouse-gh Bot added the pr-ci label Aug 3, 2026
@PedroTadim

Copy link
Copy Markdown
Member

@alexey-milovidov let me look at it. It should be from the PR I merged this morning

@PedroTadim PedroTadim self-assigned this Aug 3, 2026
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 The only red check, Stress test (arm_debug) — Logical error: ColumnBLOB should be converted to a regular column before usage — is unrelated to this PR. It is tracked in #113165 and a fix is already in progress: #111997.

Comment thread src/Common/QueryFuzzer.h
// node is destroyed the allocator is free to hand its address to a node created later, which
// would look exactly like a loop. Holding the node alive makes the address unique for the whole
// fuzzMain call, so pointer identity is a valid answer to "have I visited this node before".
std::unordered_map<const IAST *, ASTPtr> debug_visited_nodes;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't actually keep every visited node alive for the full fuzzMain call, because some rewrite branches still erase(ast.get()) before overwriting the ASTPtr. The concrete ASTDataType path at src/Common/QueryFuzzer.cpp:8127 is enough to recreate the original ABA: once the old type node is reparsed, the previous node can be destroyed immediately, and a later sibling parse in the same fuzzMain can recycle that address and trip the loop check again. I think this needs a monotonic keepalive container for the whole fuzzMain (or a separate keepalive container plus the visited-address set), rather than erasing entries on replacement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-ci pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants