llvm / llvm/llvm-project

[Clangd] Clangd hangs when receiving `prepareCallHierarchy` request, which may involve Macro.

Open
#194,861 2 comments 0 reactions 0 assignees View on GitHub
clangd generated by fuzzer
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

`version: 23.0.0, git commit aff5afc48df63615053b2432da198a4932435c3f`
`Environment:Ubuntu-24.04`
A malformed C++ file generated by **fuzzer** plus a single textDocument/prepareCallHierarchy request can trigger a reproducible high-CPU hang in non-ASAN clangd.
It behaves like:
```
PID COMMAND S %CPU TIME+
27338 Worker:main.cpp R 99.9 0:25.21
27324 clangd.main S 0.0 0:00.00
...

```

Here is the request sequence:
```
initialize
initialized
didOpen
prepareCallHierarchy
shutdown
exit
```

And here is the original C++ source code:
```cpp
#include
void f(){
try{
assert(dynamic_cast(_GCC_specific_handler(ms_exc, this_frame, ms_orig_context, ms_disp,__gxx_personality_imp)) == a1.getA1());
"""
```
The prepareCallHierachy request:

```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/prepareCallHierarchy",
"params": {
"textDocument": {"uri": "REPLACED_AT_RUNTIME"},
"position": {"line": 3, "character": 33},
},
}
```

I also used `perf` to observe, the perf log is affiliated below.

[front_single_enum_compact_lines.dwarf.perf.txt](https://github.com/user-attachments/files/27203474/front_single_enum_compact_lines.dwarf.perf.txt)

**TLDR**:
perf shows the clangd worker thread under:
```
prepareCallHierarchy
-> getDeclAtPositionWithRelations
-> SelectionTree::createEach
-> SelectionVisitor::claimRange
```

## reproducer
usage:
```
python3 reproducer.py \
--clangd /path/to/clangd
```

```python
#!/usr/bin/env python3
import argparse
import json
import queue
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from urllib.parse import quote

SOURCE = """#include
void f(){
try{
assert(dynamic_cast(_GCC_specific_handler(ms_exc, this_frame, ms_orig_context, ms_disp,__gxx_personality_imp)) == a1.getA1());
"""

PREPARE_CALL_HIERARCHY = {
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/prepareCallHierarchy",
"params": {
"textDocument": {"uri": "REPLACED_AT_RUNTIME"},
"position": {"line": 3, "character": 33},
},
}

def file_uri(path: Path) -> str:
return "file://" + quote(str(path))

def encode_lsp(msg: dict) -> bytes:
body = json.dumps(msg, separators=(",", ":")).encode("utf-8")
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
return header + body

def read_lsp(stdout, outq: queue.Queue):
try:
while True:
headers = {}
while True:
line = stdout.readline()
if not line:
return
if line in (b"\r\n", b"\n"):
break
text = line.decode("utf-8", "replace").strip()
if ":" in text:
k, v = text.split(":", 1)
headers[k.strip().lower()] = v.strip()
length = int(headers.get("content-length", "0"))
body = stdout.read(length)
if not body:
return
try:
outq.put(json.loads(body.decode("utf-8", "replace")))
except Exception:
outq.put({"_raw": body.decode("utf-8", "replace")})
finally:
outq.put(None)

def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--clangd",
default="/home/ubuntu2404/llvm-project/build/bin/clangd",
help="Path to a non-ASAN clangd binary",
)
parser.add_argument("--timeout", type=float, default=30.0)
parser.add_argument(
"--log-dir",
default=None,
help="Optional directory to keep repro files instead of using a temp dir",
)
args = parser.parse_args()

if args.log_dir:
workdir = Path(args.log_dir)
workdir.mkdir(parents=True, exist_ok=True)
cleanup = False
else:
workdir = Path(tempfile.mkdtemp(prefix="clangd-prepareCallHierarchy-hang-"))
cleanup = True

source_path = workdir / "main.cpp"
source_path.write_text(SOURCE, encoding="utf-8")
uri = file_uri(source_path)

cmd = [args.clangd]
print("Running:", " ".join(cmd), file=sys.stderr)
print("Workspace:", workdir, file=sys.stderr)

proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=False,
)

q: queue.Queue = queue.Queue()
t = threading.Thread(target=read_lsp, args=(proc.stdout, q), daemon=True)
t.start()

prepare = json.loads(json.dumps(PREPARE_CALL_HIERARCHY))
prepare["params"]["textDocument"]["uri"] = uri

messages = [
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"processId": None,
"rootUri": file_uri(workdir),
"capabilities": {},
"workspaceFolders": [{"uri": file_uri(workdir), "name": workdir.name}],
},
},
{"jsonrpc": "2.0", "method": "initialized", "params": {}},
{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": uri,
"languageId": "cpp",
"version": 1,
"text": SOURCE,
}
},
},
prepare,
{"jsonrpc": "2.0", "id": 2, "method": "shutdown", "params": None},
{"jsonrpc": "2.0", "method": "exit", "params": None},
]

assert proc.stdin is not None
for msg in messages:
proc.stdin.write(encode_lsp(msg))
proc.stdin.flush()
time.sleep(0.15)

try:
proc.stdin.close()
except Exception:
pass

deadline = time.time() + args.timeout
while time.time() < deadline:
rc = proc.poll()
if rc is not None:
break
time.sleep(0.1)

killed = False
if proc.poll() is None:
killed = True
proc.kill()

stdout_msgs = []
try:
while True:
item = q.get(timeout=0.2)
if item is None:
break
stdout_msgs.append(item)
except queue.Empty:
pass

try:
stderr = proc.stderr.read().decode("utf-8", "replace") if proc.stderr else ""
except Exception:
stderr = ""

rc = proc.wait(timeout=2)

if stderr:
print("=== clangd stderr ===")
print(stderr, end="" if stderr.endswith("\n") else "\n")

summary = {
"exit_code": rc,
"signal": -rc if rc < 0 else None,
"killed_after_timeout": killed,
"stdout_message_count": len(stdout_msgs),
"stdout_messages": stdout_msgs,
}
print(json.dumps(summary, ensure_ascii=False, indent=2))

if cleanup:
shutil.rmtree(workdir, ignore_errors=True)

return 0 if not killed else 124

if __name__ == "__main__":
raise SystemExit(main())
```

Contributor guide

Open the contributing guide

Research direction

Run the provided reproducer.py with a non-ASAN clangd binary and confirm the timeout on the malformed main.cpp input. Trace textDocument/prepareCallHierarchy through getDeclAtPositionWithRelations, SelectionTree::createEach, and SelectionVisitor::claimRange; done means the request no longer hangs or consumes a worker at high CPU for this reproducer.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, python
Domain
compilers, devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.