Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ jobs:
runVcpkgInstall: true
doNotCache: false
vcpkgJsonGlob: vcpkg.json
vcpkgJsonIgnores: ['**/vcpkg/**']
runVcpkgFormatString: ['install', '--recurse', '--clean-after-build', '--x-install-root', '$[env.VCPKG_INSTALLED_DIR]', '--triplet', 'x64-linux']
# vcpkgJsonIgnores and runVcpkgFormatString are deliberately not set: both
# would restate the action's own defaults, and the format string is what
# broke this workflow file. The action's default already passes
# --x-install-root $[env.VCPKG_INSTALLED_DIR], so the install root is
# unchanged by dropping it.

- name: Debug vcpkg environment
run: |
Expand Down Expand Up @@ -136,8 +139,11 @@ jobs:
runVcpkgInstall: true
doNotCache: false
vcpkgJsonGlob: vcpkg.json
vcpkgJsonIgnores: ['**/vcpkg/**']
runVcpkgFormatString: ['install', '--recurse', '--clean-after-build', '--x-install-root', '$[env.VCPKG_INSTALLED_DIR]', '--triplet', 'x64-linux']
# vcpkgJsonIgnores and runVcpkgFormatString are deliberately not set: both
# would restate the action's own defaults, and the format string is what
# broke this workflow file. The action's default already passes
# --x-install-root $[env.VCPKG_INSTALLED_DIR], so the install root is
# unchanged by dropping it.

- name: Configure CMake with Coverage
run: cmake --preset=coverage
Expand Down Expand Up @@ -207,8 +213,11 @@ jobs:
runVcpkgInstall: true
doNotCache: false
vcpkgJsonGlob: vcpkg.json
vcpkgJsonIgnores: ['**/vcpkg/**']
runVcpkgFormatString: ['install', '--recurse', '--clean-after-build', '--x-install-root', '$[env.VCPKG_INSTALLED_DIR]', '--triplet', 'x64-linux']
# vcpkgJsonIgnores and runVcpkgFormatString are deliberately not set: both
# would restate the action's own defaults, and the format string is what
# broke this workflow file. The action's default already passes
# --x-install-root $[env.VCPKG_INSTALLED_DIR], so the install root is
# unchanged by dropping it.

- name: Debug vcpkg environment
run: |
Expand Down
113 changes: 113 additions & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,119 @@ jobs:
env:
TSAN_OPTIONS: halt_on_error=0

# Fuzzing (libFuzzer). Separate from the test jobs because it needs clang's
# fuzzer runtime and runs on a time budget rather than to completion.
fuzz:
name: Fuzzing (libFuzzer)
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
cmake \
clang \
git \
pkg-config \
libgoogle-perftools-dev

- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v7
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');

- name: Cache vcpkg packages
id: cache-vcpkg
uses: actions/cache@v4
with:
path: |
vcpkg
~/.cache/vcpkg/archives
vcpkg_installed
key: ${{ runner.os }}-vcpkg-fuzz-${{ hashFiles('vcpkg.json') }}

- name: Install vcpkg
run: |
if [ ! -f "vcpkg/vcpkg" ]; then
echo "vcpkg not found or incomplete, cloning..."
rm -rf vcpkg
git clone https://github.com/Microsoft/vcpkg.git
cd vcpkg
./bootstrap-vcpkg.sh
else
echo "Using cached vcpkg"
fi

- name: Install vcpkg dependencies
env:
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
run: |
./vcpkg/vcpkg install --triplet=x64-linux-release

- name: Configure with fuzz targets
run: |
mkdir build
cd build
cmake .. \
-DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_TOOLCHAIN_FILE=../vcpkg/scripts/buildsystems/vcpkg.cmake \
-DVCPKG_TARGET_TRIPLET=x64-linux-release \
-DREMOTE_PROFILER_BUILD_FUZZERS=ON \
-DREMOTE_PROFILER_ENABLE_WEB=OFF \
-DREMOTE_PROFILER_BUILD_TESTS=OFF \
-DREMOTE_PROFILER_BUILD_EXAMPLES=OFF

- name: Build fuzz targets
run: |
cd build
make -j$(nproc)

# The per-target tests replay the committed corpus (one run each), so this
# fails if a corpus entry stops behaving. The smoke test then fuzzes past the
# corpus for a bounded time.
#
# Both need the same environment the ASan job uses. tcmalloc's own
# MallocExtension::Initialize() allocation is reported by LSan as a leak unless
# lsan.supp is applied -- the first run of these steps failed on that 45-byte
# allocation, not on anything the targets did.
- name: Replay corpora
working-directory: build
run: |
ctest -L fuzz --output-on-failure -E FuzzSmoke
env:
ASAN_OPTIONS: detect_leaks=1:halt_on_error=1
LSAN_OPTIONS: suppressions=../lsan.supp

- name: Fuzz smoke test
working-directory: build
run: |
ctest -L fuzz --output-on-failure -R FuzzSmoke
env:
ASAN_OPTIONS: detect_leaks=1:halt_on_error=1
LSAN_OPTIONS: suppressions=../lsan.supp

# A crash leaves a reproducer behind; upload it so the failure is actionable
# without re-running the job.
- name: Upload crash reproducers
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: |
build/crash-*
build/leak-*
build/timeout-*
build/oom-*
if-no-files-found: ignore

# Static Analysis with clang-tidy
static-analysis:
name: Static Analysis
Expand Down
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,19 @@ flamegraph.pl

# Out-of-source build trees and vendored sources
build-tsan/

# A build tree was once configured in the repository root under this name; it is
# 1.1GB of CMake output and must never be committed.
myfuck/
vcpkg_installed/
gperftools-src/

# libFuzzer writes newly discovered inputs into the corpus directory next to the
# hand-written seeds, named by content hash. Those are run artifacts, not fixtures:
# keep the descriptive seeds in version control and ignore the rest. A newly found
# crashing input belongs in the corpus under a descriptive name.
tests/fuzz/corpus/*/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]
tests/fuzz/crash-*
tests/fuzz/leak-*
tests/fuzz/timeout-*
tests/fuzz/oom-*
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]
### Added
- libFuzzer targets for the parsers, behind `-DREMOTE_PROFILER_BUILD_FUZZERS=ON`
(clang only, off by default): `fuzz_renderer_output`, `fuzz_result_parsing`,
`fuzz_http_params`, with a committed seed corpus each. `ctest -L fuzz` replays the
corpora and then runs a bounded smoke pass; a new CI job builds and runs them, and
uploads crash reproducers on failure.
- The parsing these targets cover moved into `internal/renderer_output_parsing.h` and
is now exercised by unit tests as well. Three copies of the same logic were
involved: the flamegraph "ERROR:" extraction existed twice (HTTP layer and core),
and the pprof symbolz body splitting was inline in the handler. Each had drifted
slightly, so a fix to one would not have reached the others.
- `internal::parseSymbolRequest()` strips a trailing `\r`, so CRLF bodies from a
client now resolve their addresses instead of passing a stray `\r` to stoull().


- `/api/thread/stacks` (and `getThreadCallStacks()`) print each thread's **name** next to its tid, read from `/proc/<tid>/comm`. A bare tid is not identifiable when reading the output afterwards; the name is what tells you that thread 1234 is `DrogonIoLoop` and 1235 is a worker parked on a futex.
Expand Down Expand Up @@ -102,6 +114,7 @@ Verified in a real browser (headless Chrome driven over CDP) rather than by insp
- Installation layout made consistent across `lib`/`lib64` hosts so `find_package()` works (GNUInstallDirs is now included before the install rules)

### Fixed
- `ci.yml` was rejected by GitHub with "A sequence was not expected" and had been since the pipeline was introduced, so build-gcc, coverage and build-clang never ran -- silently, because a workflow that does not parse produces no jobs to fail. The cause was two run-vcpkg inputs written as YAML flow sequences (`vcpkgJsonIgnores`, `runVcpkgFormatString`); `with:` values must be scalars. Both were restating the action's own defaults, so neither is set now -- the install root still comes from the action's default, not from our copy of the string.
- `stopHeapProfiler()` and `getRawHeapProfileSample()` no longer call `GetHeapProfile()`. They write the window's snapshot with `HeapProfilerDump()` and read that file back, so the profile memory belongs to us: nothing needs freeing, and the `leak:GetHeapProfile` suppression is gone. This replaces the previous workaround of retaining the buffer, which existed only because `GetHeapProfile()`'s return value has no portable deallocator (see ROADMAP). Note the dump lands beside the requested output path under a derived name -- gperftools appends `.<sequence>.heap` to the prefix -- so `stopHeapProfiler()` still writes the caller's path, from the dump's contents.
- Reverted the `free()` added to `stopHeapProfiler()` and `getRawHeapProfileSample()`: it aborted ASan's job with "attempting free on address which was not malloc()-ed". `GetHeapProfile()`'s header says the caller should free the buffer, and upstream's own test does exactly that, but the buffer has no portable deallocator in practice -- with a statically linked tcmalloc it comes from the library's own allocator, which the process's malloc interposer never saw (so `free()` aborts), `tc_free()` only exists from gperftools 2.16.90, and gperftools 2.18 additionally routes its intermediate chunks through an internal arena whose `Free` lives in a private header. The buffer is roughly 11KB per stop, so it is copied out and released to the OS instead; `lsan.supp` carries a matching suppression. The earlier "fix" traded a benign leak for a crash.
- The ASan job now also covers a statically linked tcmalloc build, which is what vcpkg produces and what the crash above required; the shared-library configuration used locally cannot reproduce it.
Expand Down
78 changes: 78 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ option(ENABLE_COVERAGE "Enable code coverage reporting" OFF)

option(BUILD_DOCS "Build API documentation" OFF)

# Fuzz targets (libFuzzer). Off by default: they need a clang with the fuzzer
# runtime and are run deliberately, not as part of a normal build.
option(REMOTE_PROFILER_BUILD_FUZZERS "Build libFuzzer targets" OFF)
# A cache string, not option(): option() would type it BOOL, and its numeric value
# would be dropped on any re-configure.
set(REMOTE_PROFILER_FUZZ_SECONDS 20 CACHE STRING "Seconds each fuzz target runs in the smoke test")

message(STATUS "Build options:")
message(STATUS " BUILD_SHARED_LIBS: ${BUILD_SHARED_LIBS}")
message(STATUS " REMOTE_PROFILER_INSTALL: ${REMOTE_PROFILER_INSTALL}")
Expand All @@ -31,6 +38,7 @@ message(STATUS " REMOTE_PROFILER_BUILD_TESTS: ${REMOTE_PROFILER_BUILD_TESTS}")
message(STATUS " REMOTE_PROFILER_ENABLE_WEB: ${REMOTE_PROFILER_ENABLE_WEB}")
message(STATUS " ENABLE_COVERAGE: ${ENABLE_COVERAGE}")
message(STATUS " BUILD docs: ${BUILD_DOCS}")
message(STATUS " BUILD fuzzers: ${REMOTE_PROFILER_BUILD_FUZZERS}")

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
Expand Down Expand Up @@ -276,6 +284,19 @@ if(REMOTE_PROFILER_BUILD_TESTS)
)
add_test(NAME ResultParsingTest COMMAND test_result_parsing)

# Renderer-output and request-body parsing (core only). The fuzz targets in
# tests/fuzz/ explore the same functions; these pin their documented behaviour.
add_executable(test_renderer_parsing tests/test_renderer_parsing.cpp)
target_link_libraries(test_renderer_parsing
PRIVATE
profiler_core
GTest::gtest
GTest::gtest_main
pthread
)
target_include_directories(test_renderer_parsing PRIVATE ${PROJECT_SOURCE_DIR}/src)
add_test(NAME RendererParsingTest COMMAND test_renderer_parsing)

# Thread stack capture (core only -- works without Drogon)
add_executable(test_thread_stacks tests/test_thread_stacks.cpp)
target_link_libraries(test_thread_stacks
Expand All @@ -292,6 +313,63 @@ else()
message(STATUS "Tests disabled")
endif()

# ============================================================================
# Fuzz targets (libFuzzer)
# ============================================================================
# Kept separate from the GoogleTest targets: these need -fsanitize=fuzzer and are
# long-running by nature. Each one links only profiler_core, so a fuzz build does
# not drag in Drogon.
if(REMOTE_PROFILER_BUILD_FUZZERS)
# enable_testing() otherwise happens only inside the GoogleTest block, so a
# fuzz-only build (tests off) would build the targets but register nothing.
if(NOT REMOTE_PROFILER_BUILD_TESTS)
enable_testing()
endif()

if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang")
message(FATAL_ERROR
"REMOTE_PROFILER_BUILD_FUZZERS requires clang (libFuzzer is part of the "
"clang runtime); the current compiler is ${CMAKE_CXX_COMPILER_ID}.")
endif()

# Fuzzing wants coverage instrumentation plus a sanitizer to catch the memory
# errors the coverage alone would miss.
set(FUZZ_SANITIZERS "fuzzer,address,undefined")

# Shared flags so all three targets behave identically.
function(add_fuzz_target name source)
add_executable(${name} tests/fuzz/${source})
target_link_libraries(${name} PRIVATE profiler_core pthread)
# The targets under test live in src/internal/, which profiler_core exposes
# only to itself; fuzzing the parsing helpers is exactly the point here.
target_include_directories(${name} PRIVATE ${PROJECT_SOURCE_DIR}/src)
target_compile_options(${name} PRIVATE -fsanitize=${FUZZ_SANITIZERS} -fno-omit-frame-pointer -g)
target_link_options(${name} PRIVATE -fsanitize=${FUZZ_SANITIZERS})
# A corpus directory per target, seeded from tests/fuzz/corpus/<name>.
set(corpus_dir "${CMAKE_CURRENT_SOURCE_DIR}/tests/fuzz/corpus/${name}")
file(MAKE_DIRECTORY "${corpus_dir}")
add_test(NAME ${name}
COMMAND ${name} -runs=1 -seed=1 "${corpus_dir}")
set_tests_properties(${name} PROPERTIES LABELS "fuzz")
endfunction()

add_fuzz_target(fuzz_renderer_output fuzz_renderer_output.cpp)
add_fuzz_target(fuzz_result_parsing fuzz_result_parsing.cpp)
add_fuzz_target(fuzz_http_params fuzz_http_params.cpp)

# Smoke test: run each target for a bounded time so CI exercises more than the
# corpus. Excluded from the default test run because it takes minutes.
add_test(NAME FuzzSmoke
COMMAND ${CMAKE_COMMAND}
-DFUZZ_SECONDS=${REMOTE_PROFILER_FUZZ_SECONDS}
-DFUZZ_BINARIES=$<TARGET_FILE:fuzz_renderer_output>,$<TARGET_FILE:fuzz_result_parsing>,$<TARGET_FILE:fuzz_http_params>
-DCORPUS_ROOT=${CMAKE_CURRENT_SOURCE_DIR}/tests/fuzz/corpus
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/run-fuzz-smoke.cmake)
set_tests_properties(FuzzSmoke PROPERTIES LABELS "fuzz" TIMEOUT 900)

message(STATUS "Fuzz targets will be built (smoke test: ${REMOTE_PROFILER_FUZZ_SECONDS}s each)")
endif()

# Note: Web files are now embedded in C++ code (src/web_resources.cpp)
# No need to copy web directory to build directory

Expand Down
26 changes: 25 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,16 +287,26 @@ ctest -R <test_name> --output-on-failure

### 提交前的附加检查

`ctest` 通过**不足以**覆盖两类问题,仓库里有两个脚本专门补这个缺口:
`ctest` 通过**不足以**覆盖三类问题,仓库里另有三个手段补这个缺口:

```bash
# 1) Sanitizer:本地复刻 CI 的 ASan/UBSan/TSan 配置
scripts/check-sanitizers.sh asan # 默认只跑 ASan
scripts/check-sanitizers.sh all # ASan + UBSan + TSan
CTEST_JOBS=6 scripts/check-sanitizers.sh asan # 同时验证并行稳定性

# 2) Web UI:用无头 Chrome 驱动真实控制面板,断言每个动作都发出正确的请求
# 并真正落盘(需要先启动 profiler_example,以及 puppeteer-core)
scripts/verify-web-ui.mjs http://localhost:8080

# 3) Fuzz:libFuzzer 驱动的解析器
cmake -S . -B build/fuzz -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake \
-DVCPKG_TARGET_TRIPLET=x64-linux-release \
-DREMOTE_PROFILER_BUILD_FUZZERS=ON -DREMOTE_PROFILER_BUILD_TESTS=OFF
cmake --build build/fuzz -j$(nproc)
ctest --test-dir build/fuzz -L fuzz # 语料回归 + 短时 fuzz
build/fuzz/fuzz_renderer_output -max_total_time=300 tests/fuzz/corpus/fuzz_renderer_output
```

为什么需要它们:
Expand All @@ -307,6 +317,20 @@ scripts/verify-web-ui.mjs http://localhost:8080
- **Web UI**:`curl` **无法执行页面 JavaScript**,因此看不到"按钮点击报
ReferenceError""按钮卡在 disabled""下载其实没开始"这类问题。前端改动必须用
真实浏览器验证后才算完成。
- **Fuzz**:解析器的输入空间靠手写用例覆盖不全。三个目标都只依赖 `profiler_core`,
不拖 Drogon,跑起来很轻。

### 写 fuzz 目标时的两条经验

1. **只断言实现真正保证的性质。** 第一个版本的 `fuzz_result_parsing` 断言"错误消息非空",
听起来天经地义,实际是错的——`{"error":""}` 会得到空消息;`fuzz_renderer_output`
断言"token 不含换行",同样错——当请求体含 `+` 时按 `+` 切分,换行会**合法地**留在
token 内。两个目标随即各报了一个"崩溃",其实都是断言自己的错。
**假阳性比漏报更糟**:它会让人不再相信这个目标。
2. **每条断言都问"如果它失败,是谁的 bug?"** 答不上来就别加。内存安全问题交给
ASan/UBSan——它们不会冤枉代码。

崩溃样本由 libFuzzer 写在当前目录(`crash-*`)。CI 会把它们作为 artifact 上传。

> 改前端(`src/web_resources.cpp` 里的 HTML/JS 字符串)时尤其注意:那段 JS 是
> C++ 字符串字面量,**编译器不会检查它**——函数名写错、参数漏传都能编译通过。
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ cd build && ./profiler_example
| `REMOTE_PROFILER_ENABLE_WEB` | `ON` | 编译 Web 层(需 Drogon),`OFF` 则完全不依赖 Drogon |
| `ENABLE_COVERAGE` | `OFF` | 生成覆盖率报告 |
| `BUILD_DOCS` | `OFF` | 生成 Doxygen API 文档(输出到 `build/docs/html/`) |
| `REMOTE_PROFILER_BUILD_FUZZERS` | `OFF` | 构建 libFuzzer 模糊测试目标(需 clang,见 CONTRIBUTING) |

常见场景:

Expand Down Expand Up @@ -515,7 +516,9 @@ cpp-remote-profiler/
│ ├── symbolize.cpp # 符号化引擎(absl → dladdr → backward-cpp)
│ ├── web_resources.cpp # 内置 Web 资源
│ └── internal/ # 内部实现:日志、符号化、内嵌 pprof / flamegraph.pl
├── example/ tests/ # 示例与 GoogleTest 测试
├── example/ # 示例程序(含命名线程,便于验证线程栈)
├── tests/ # GoogleTest 单元测试
│ └── fuzz/ # libFuzzer 目标与种子语料(REMOTE_PROFILER_BUILD_FUZZERS=ON)
├── cmake/
│ ├── cpp-remote-profiler-config.cmake.in
│ └── examples/ # find_package / FetchContent 集成验证
Expand Down
Loading
Loading