Skip to content

fix(scanner): avoid GCC -O2 strict-aliasing miscompile in scan_start_tag#2

Open
swaits wants to merge 1 commit into
themixednuts:mainfrom
swaits:fix/o2-strict-aliasing-segfault
Open

fix(scanner): avoid GCC -O2 strict-aliasing miscompile in scan_start_tag#2
swaits wants to merge 1 commit into
themixednuts:mainfrom
swaits:fix/o2-strict-aliasing-segfault

Conversation

@swaits
Copy link
Copy Markdown

@swaits swaits commented May 22, 2026

Context: This segfaults for me when loading in Nvim v0.12.2. I discovered it while trying to incorporate this into Arborist.nvim, for arborist-ts/arborist.nvim#19. I tossed it at Claude to debug the crash and, despite my protests, this is what it comes up with. Seems far fetched to me, but at the same time, the fix does resolve the segfault for me. /shrug

❯ uname -a
Linux swaits-fw13 7.0.5-zen1-1-zen #1 ZEN SMP PREEMPT_DYNAMIC Fri, 08 May 2026 09:29:10 +0000 x86_64 GNU/Linux

❯ cc --version
cc (GCC) 16.1.1 20260430
Copyright (C) 2026 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Symptom

Parsing any document that contains an HTML tag — even <div></div> — segfaults when the scanner is compiled with GCC at -O2. -O2 is the optimization level tree-sitter build uses and the level the parsers shipped to editors are built at, so in practice every real .svelte file hard-crashes the editor. (Found via Neovim: opening any .svelte file → SIGSEGV.)

Debug (-O0) builds are unaffected, which is why test suites don't catch it.

Minimal reproduction

mwe.c — parse <div></div>:

#include <tree_sitter/api.h>
#include <stdio.h>
#include <string.h>

const TSLanguage *tree_sitter_svelte(void);

int main(void) {
  TSParser *parser = ts_parser_new();
  ts_parser_set_language(parser, tree_sitter_svelte());
  const char *src = "<div></div>";
  TSTree *tree = ts_parser_parse_string(parser, NULL, src, (uint32_t)strlen(src));
  printf("OK: %s\n", ts_node_string(ts_tree_root_node(tree)));
  return 0;
}

Build it against parser.c + scanner.c + the tree-sitter runtime, varying only how scanner.c is compiled:

git clone --depth 1 -b v0.26.8 https://github.com/tree-sitter/tree-sitter ts

cc -O2 -c ts/lib/src/lib.c                       -o rt.o     -Its/lib/include -Its/lib/src
cc -O2 -c mwe.c                                  -o mwe.o    -Its/lib/include
cc -O2 -c crates/tree-sitter-svelte/src/parser.c -o parser.o -Icrates/tree-sitter-svelte/src

# vary ONLY the flags on this line:
cc -O2 -c crates/tree-sitter-svelte/src/scanner.c -o sc.o    -Icrates/tree-sitter-svelte/src

cc mwe.o parser.o sc.o rt.o -o mwe && ./mwe

Results — same parser.c, same runtime, same input, only scanner.c's compile flags change:

scanner.c compiled with parsing <div></div>
gcc -O2 SIGSEGV — crash
gcc -O0 ok — (document (element (start_tag …) (end_tag …)))
gcc -O2 -fno-strict-aliasing ok — (document (element (start_tag …) (end_tag …)))
gcc -O2 with this PR ok — (document (element (start_tag …) (end_tag …)))

What the table proves

  • Row 1 vs row 2 — byte-identical source; the only difference is the optimizer. Turning it off removes the crash ⇒ the crash is optimizer-triggered, i.e. undefined behavior the optimizer is entitled to exploit.
  • Row 1 vs row 3 — identical optimizer level (-O2); the only difference is -fno-strict-aliasing. Adding exactly the flag that disables type-based alias analysis removes the crash ⇒ the UB is specifically a strict-aliasing violation. (This is mechanical — the flag named for the cause toggles the crash.)
  • Row 4 — the fix makes an ordinary -O2 build correct.

Root cause

scan_start_tag() appends to the HTML scanner's tag stack:

array_push(&state->html->tags, tag);

array_push() (tree-sitter array.h) does two things:

  1. reallocates the backing buffer through the generic Array * type_array__grow / _array__reserve take Array *, whose contents member is void *;
  2. writes the new element through the concrete Array(Tag) * type(self)->contents[(self)->size++] = element, whose contents member is Tag *.

Array and the Array(Tag) anonymous struct are not compatible types. At -O2, GCC's strict-aliasing analysis concludes that the realloc's store to …->contents (via Array *) and the element store's load of …->contents (via Array(Tag) *) cannot alias. It keeps the pre-realloc contents value in a register and never reloads it after the grow — so the first tag of the document is written through the stale NULL pointer the array was created with.

Confirmed at the instruction level from a core dump: on the no-grow path the build reloads contents; on the grow path it does not, and writes the element through a register still holding NULL.

This is the only array_push in the scanner whose array is reached through a pointer field (state->html->tags) rather than a direct struct member — consistent with it being the one call site that miscompiles.

Fix

Do the grow + append explicitly in scan_start_tag(), keeping every access on the concrete Array(Tag) type so there is no Array * type-pun for the optimizer to reason around. Applied to the canonical tree-sitter-htmlx scanner and to its committed vendored copy under tree-sitter-svelte/src/htmlx/ (the file tree-sitter build actually compiles for the Svelte grammar).

With the fix, <div></div> and full Svelte 5 files (<script lang="ts">, {#if}, {#each}, event handlers) parse cleanly at -O2.


Unrelated side note: tree-sitter build also warns that the external scanner exports non-static helper functions (html_create, htmlx_create, html_scanner_scan, scan, …). Those can collide at load time when an editor loads this grammar next to another that vendors the same HTML scanner — worth marking static in a follow-up.

Parsing any document containing an HTML tag (even `<div></div>`) segfaults when the scanner is compiled with GCC at -O2 — the default for `tree-sitter build` and for the parsers editors such as Neovim ship. Debug (-O0) builds are unaffected, so test suites miss it.

scan_start_tag() appends to the tag stack with `array_push(&state->html->tags, tag)`. array_push() (tree-sitter array.h) reallocates the backing buffer through the generic `Array *` type, then writes the new element back through the concrete `Array(Tag) *` type. Because the array is reached through the `state->html` pointer, GCC's -O2 strict-aliasing analysis treats the realloc's `contents` store and the element store as non-aliasing, elides the reload of `contents`, and writes the element through a stale NULL pointer.

Do the grow + append explicitly, keeping every access on the concrete `Array(Tag)` type so there is no `Array *` type-pun for the optimizer to break. Applied to the canonical tree-sitter-htmlx scanner and its vendored copy in tree-sitter-svelte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant