Skip to content

Commit 7b96800

Browse files
authored
[Python] Add ASan/LSan coverage for bindings and C++ integration (#1047)
* [Python] Add ASan/LSan coverage for bindings and C++ integration (#1027) Add a coordinated sanitizer build mode, -Dtsfile.sanitizer=<checks>, that instruments libtsfile and every Cython extension with the same checks. The C++ library gets -fsanitize on compile and on the shared-library link and keeps debug info and frame pointers; setup.py builds each extension with -O1 -g -fno-omit-frame-pointer, links the sanitizer runtime, and verifies with nm/readelf that every artifact is instrumented. Sanitizer builds never produce a wheel. The sanitizer compile flags must be re-applied after the bundled third-party build resets CMAKE_CXX_FLAGS; previously they were discarded and only the link step saw them. The C++-only ASan mode keeps its historical static-runtime setup so an instrumented libtsfile cannot mix runtimes with the test binary. Fix ownership defects the new checks expose: - free STRING/TEXT value buffers and use the length-aware binary getter for BLOB so embedded NUL bytes survive; - free the writer-side schema structs, not only their members; - free result-set metadata and schema arrays on every path; - free the property array on the C wrapper's bad_alloc path; - add tsfile_free_buffer so bindings release buffers with the allocating module's CRT. Add subprocess ownership scenarios (tree/table/dataset/Arrow reads, value types and NULLs, early termination, exception cleanup, repeated open/close, reader/result lifetime, file-object adapter), two positive controls, narrow suppressions, and a Linux CI job. Leak reports are attributed by allocation stack so only leaks reaching TsFile modules fail the run. Document build and run commands, runtime preloading (libasan before libstdc++), symbolization, and the suppression policy. * [Python] Fix sanitizer workflow gaps and reader schema-array ownership - Skip the ordinary pytest execution in the with-sanitizer Maven profile: the instrumented extensions abort on import unless the sanitizer runtime is preloaded, which Maven's test execution does not do, so `mvn verify -Dtsfile.sanitizer=address` failed before running a test. - Document the complete ASan/LSan command in python/CLAUDE.md, including TSFILE_SANITIZER (without it test_sanitizer_ownership.py skips silently) and the ASAN/LSAN options used by CI. - Add tsfile_free_table_schema_array()/tsfile_free_device_schema_array() so bindings release reader-owned schema arrays with libtsfile's allocator, and release partial conversion results when a schema conversion raises mid-loop. - Point the C API value-getter docs at tsfile_free_buffer() and document the binary getter's NULL-cell behavior. - Note the nm/binutils wheel requirement and the test/verify limitation in python/SANITIZER.md.
1 parent 931a5d3 commit 7b96800

23 files changed

Lines changed: 1980 additions & 81 deletions

‎.github/workflows/unit-test-python.yml‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,84 @@ jobs:
128128
name: tsfile-${{ runner.os }}-py${{ matrix.python-version }}-whl
129129
path: python/dist/tsfile-*.whl
130130
retention-days: 1
131+
132+
# AddressSanitizer/LeakSanitizer coverage for the complete Python -> Cython
133+
# -> C wrapper -> C++ chain. The build instruments libtsfile and every
134+
# extension with the same compiler and check set; the scenarios run in fresh
135+
# interpreters with leak detection on. See python/SANITIZER.md.
136+
python-sanitizer:
137+
runs-on: ubuntu-24.04
138+
timeout-minutes: 60
139+
steps:
140+
- name: Checkout repository
141+
uses: actions/checkout@v7
142+
143+
- name: Set up Python
144+
uses: actions/setup-python@v7
145+
with:
146+
python-version: "3.11"
147+
148+
- name: Set up JDK 17
149+
uses: actions/setup-java@v6.0.1
150+
with:
151+
distribution: corretto
152+
java-version: 17
153+
154+
- name: Cache Maven packages
155+
uses: actions/cache@v6
156+
with:
157+
path: ~/.m2
158+
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
159+
restore-keys: ${{ runner.os }}-m2-
160+
161+
- name: Install dependencies
162+
shell: bash
163+
run: |
164+
sudo apt-get update
165+
sudo apt-get install -y uuid-dev binutils
166+
167+
- name: Build with ASan/LSan
168+
shell: bash
169+
run: |
170+
# One compiler (and therefore one sanitizer runtime) for CMake,
171+
# distutils and the preload below.
172+
export CC=gcc CXX=g++
173+
./mvnw -P with-python \
174+
-Dtsfile.sanitizer=address \
175+
-Dbuild.type=RelWithDebInfo \
176+
-Dbuild.test=OFF \
177+
-Dspotless.skip=true \
178+
clean test-compile
179+
180+
- name: Verify sanitizer instrumentation
181+
shell: bash
182+
run: |
183+
python/venv/bin/python python/check_sanitizer_build.py python/tsfile
184+
185+
- name: Run ownership scenarios under ASan/LSan
186+
shell: bash
187+
run: |
188+
set -o pipefail
189+
# Suppressions must stay scoped to third-party/interpreter frames.
190+
if grep -v '^#' python/tests/sanitizer/lsan_suppressions.txt \
191+
| grep -Eqi 'tsfile|dataset/_merge|dataset/_index'; then
192+
echo "::error::suppression file must not match TsFile modules"
193+
exit 1
194+
fi
195+
ASAN_LIB="$(gcc -print-file-name=libasan.so)"
196+
LIBSTDCXX="$(gcc -print-file-name=libstdc++.so.6)"
197+
if [ ! -f "$ASAN_LIB" ] || [ ! -f "$LIBSTDCXX" ]; then
198+
echo "::error::AddressSanitizer or libstdc++ runtime not found for gcc"
199+
exit 1
200+
fi
201+
# The pytest runner itself does not load tsfile; each scenario child
202+
# re-enables leak detection (see test_sanitizer_ownership.py).
203+
#
204+
# libasan must stay first (runtime-first check); libstdc++ follows so
205+
# ASan's __cxa_throw interceptor can resolve the real symbol. Without
206+
# it, any C++ exception aborts with "CHECK failed ... __cxa_throw".
207+
export TSFILE_SANITIZER=address
208+
export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:halt_on_error=1"
209+
export LSAN_OPTIONS="suppressions=$PWD/python/tests/sanitizer/lsan_suppressions.txt"
210+
LD_PRELOAD="$ASAN_LIB:$LIBSTDCXX" python/venv/bin/python -m pytest \
211+
python/tests/test_sanitizer_ownership.py -v

‎cpp/CLAUDE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ bash build.sh
3333
bash build.sh -t=Debug
3434
bash build.sh -t=RelWithDebInfo
3535
bash build.sh -a=ON # Enable AddressSanitizer
36+
bash build.sh -a=ON --sanitizer-shared-runtime=ON \
37+
--sanitizer-checks=address # For the Python bindings (see python/SANITIZER.md)
3638
bash build.sh -c=ON # Enable code coverage
3739

3840
# Disable optional compression libraries
@@ -69,6 +71,8 @@ All `ON` by default unless noted:
6971
| `ENABLE_LZMA2` | LZMA2 compression (`OFF` by default; bundled XZ requires CMake 3.20+) |
7072
| `ENABLE_THREADS` | Multi-threaded read/write via pthreads |
7173
| `ENABLE_ASAN` | AddressSanitizer (`OFF` by default) |
74+
| `TSFILE_SANITIZER_CHECKS` | `-fsanitize=` list used by `ENABLE_ASAN` (default `address,undefined`) |
75+
| `TSFILE_SANITIZER_SHARED_RUNTIME` | Link the sanitizer runtime dynamically into shared libraries; required when Python bindings preload it (`OFF` by default) |
7276
| `ENABLE_SIMD` | SIMD acceleration |
7377

7478
## Source Structure

‎cpp/CMakeLists.txt‎

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,20 @@ if (ENABLE_MEM_STAT)
155155
endif ()
156156

157157

158+
# Sanitizer configuration. Declared before the build-type block because the
159+
# Release flags below must know whether LTO may be used.
160+
option(ENABLE_ASAN "Enable Address Sanitizer" OFF)
161+
set(TSFILE_SANITIZER_CHECKS "address,undefined"
162+
CACHE STRING
163+
"Sanitizer checks passed to -fsanitize= when ENABLE_ASAN is ON (for example 'address' or 'address,undefined')")
164+
# Python bindings preload the sanitizer runtime, so instrumented shared
165+
# libraries must not embed a private copy of it. Keep this OFF for the C++ test
166+
# builds that use -static-libasan, and turn it ON for the coordinated
167+
# C++/Python binding build.
168+
option(TSFILE_SANITIZER_SHARED_RUNTIME
169+
"Link the sanitizer runtime dynamically into shared libraries (for preloaded Python bindings)"
170+
OFF)
171+
158172
if (NOT CMAKE_BUILD_TYPE)
159173
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose the type of build." FORCE)
160174
endif ()
@@ -212,7 +226,14 @@ else ()
212226
# (see TSFILE_ENABLE_NATIVE_ARCH above): the default target for the
213227
# deployment platform is the portable baseline, and on macOS arm64
214228
# it still includes +crc so snappy's fast path stays available.
215-
if (MINGW OR WIN32)
229+
if (ENABLE_ASAN AND TSFILE_SANITIZER_SHARED_RUNTIME)
230+
# LTO loses the stack frames sanitizer reports need, and its
231+
# additional optimization pipeline complicates symbolization.
232+
# Only the binding mode changes optimization: the C++ test mode
233+
# keeps its historical flags so its static runtime stays in
234+
# sync with how libtsfile is linked.
235+
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3")
236+
elseif (MINGW OR WIN32)
216237
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3")
217238
elseif (TSFILE_ENABLE_NATIVE_ARCH)
218239
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3 -march=native -flto")
@@ -229,11 +250,9 @@ else ()
229250
endif ()
230251
message("CMAKE DEBUG: CMAKE_CXX_FLAGS=${CMAKE_CXX_FLAGS}")
231252

232-
# disable asan by default.
233-
option(ENABLE_ASAN "Enable Address Sanitizer" OFF)
234-
235253
if (ENABLE_ASAN)
236-
message("Address Sanitizer is enabled.")
254+
message("Address Sanitizer is enabled: checks=${TSFILE_SANITIZER_CHECKS}, "
255+
"shared_runtime=${TSFILE_SANITIZER_SHARED_RUNTIME}")
237256
if (MSVC)
238257
# MSVC ships AddressSanitizer; it requires Visual Studio 2019 16.9 or
239258
# newer (MSVC_VERSION >= 1928). Only the address sanitizer is available
@@ -259,13 +278,32 @@ if (ENABLE_ASAN)
259278
endforeach ()
260279
add_link_options(/INCREMENTAL:NO /DEBUG)
261280
elseif (NOT WIN32)
262-
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address,undefined -fno-omit-frame-pointer")
263-
264-
if (NOT APPLE)
281+
# Compile flags here only survive until add_subdirectory(third_party)
282+
# resets CMAKE_CXX_FLAGS. The binding mode re-applies them after that
283+
# call; the C++ test mode keeps its historical behavior. Link flags set
284+
# here persist.
285+
set(CMAKE_CXX_FLAGS
286+
"${CMAKE_CXX_FLAGS} -fsanitize=${TSFILE_SANITIZER_CHECKS} -fno-omit-frame-pointer")
287+
288+
if (TSFILE_SANITIZER_SHARED_RUNTIME)
289+
# The runtime stays dynamic so one preloaded copy serves Python, the
290+
# extensions and libtsfile. Debug info is required for useful
291+
# reports in every build type.
292+
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g")
293+
set(CMAKE_SHARED_LINKER_FLAGS
294+
"${CMAKE_SHARED_LINKER_FLAGS} -fsanitize=${TSFILE_SANITIZER_CHECKS}")
295+
set(CMAKE_EXE_LINKER_FLAGS
296+
"${CMAKE_EXE_LINKER_FLAGS} -fsanitize=${TSFILE_SANITIZER_CHECKS}")
297+
elseif (NOT APPLE)
265298
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -static-libasan")
266-
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address,undefined -static-libasan -static-libubsan")
299+
set(CMAKE_EXE_LINKER_FLAGS
300+
"${CMAKE_EXE_LINKER_FLAGS} -fsanitize=${TSFILE_SANITIZER_CHECKS} -static-libasan")
301+
if (TSFILE_SANITIZER_CHECKS MATCHES "undefined")
302+
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static-libubsan")
303+
endif ()
267304
else ()
268-
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address,undefined")
305+
set(CMAKE_EXE_LINKER_FLAGS
306+
"${CMAKE_EXE_LINKER_FLAGS} -fsanitize=${TSFILE_SANITIZER_CHECKS}")
269307
endif ()
270308
else ()
271309
message(WARNING
@@ -397,6 +435,16 @@ else ()
397435
endif ()
398436
add_subdirectory(third_party)
399437

438+
# third_party intentionally rebuilds with a minimal CMAKE_CXX_FLAGS so bundled
439+
# projects do not inherit TsFile's warning flags. The binding mode re-applies
440+
# the sanitizer instrumentation for the TsFile targets that follow. The C++
441+
# test mode keeps its historical static-runtime setup, so re-applying here
442+
# would mix a dynamic runtime in libtsfile with the executable's static one.
443+
if (ENABLE_ASAN AND TSFILE_SANITIZER_SHARED_RUNTIME AND NOT MSVC AND NOT WIN32)
444+
set(CMAKE_CXX_FLAGS
445+
"${CMAKE_CXX_FLAGS} -fsanitize=${TSFILE_SANITIZER_CHECKS} -fno-omit-frame-pointer -g")
446+
endif ()
447+
400448
# CMake 3.11 cannot call target_link_libraries() on object-library targets, but
401449
# directory-level link_libraries() initializes both their usage requirements
402450
# and the final library's link dependencies. Apply every enabled project

‎cpp/build.sh‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ enable_cov=0
2828
debug_se=0
2929
run_cov_only=0
3030
enable_antlr4=ON
31+
enable_asan=OFF
32+
sanitizer_checks=address,undefined
33+
sanitizer_shared_runtime=OFF
3134

3235
enable_snappy=ON
3336
enable_lz4=ON
@@ -56,6 +59,13 @@ Commands:
5659
Options:
5760
-t=<type>, -t <type> Build type: Debug, Release, RelWithDebInfo, MinSizeRel.
5861
-a=<ON|OFF> Enable or disable AddressSanitizer.
62+
--sanitizer-checks=<list>
63+
Checks passed to -fsanitize= when ASan is enabled
64+
(default: address,undefined).
65+
--sanitizer-shared-runtime=<ON|OFF>
66+
Link the sanitizer runtime dynamically into shared
67+
libraries; required when building the Python bindings,
68+
which preload libasan (default: OFF).
5969
-c=<ON|OFF> Enable or disable code coverage.
6070
--build-shared=<ON|OFF>
6171
Build libtsfile as a shared library (default: ON).
@@ -89,6 +99,8 @@ function print_config()
8999
echo "use_cpp11=$use_cpp11"
90100
echo "enable_cov=$enable_cov"
91101
echo "enable_asan=$enable_asan"
102+
echo "sanitizer_checks=$sanitizer_checks"
103+
echo "sanitizer_shared_runtime=$sanitizer_shared_runtime"
92104
echo "enable_antlr4=$enable_antlr4"
93105
echo "enable_snappy=$enable_snappy"
94106
echo "enable_lz4=$enable_lz4"
@@ -125,6 +137,10 @@ parse_options()
125137
-a)
126138
shift
127139
enable_asan=$(get_key_value "$1");;
140+
--sanitizer-checks=*)
141+
sanitizer_checks=$(get_key_value "$1");;
142+
--sanitizer-shared-runtime=*)
143+
sanitizer_shared_runtime=$(get_key_value "$1");;
128144
-c=*)
129145
enable_cov=$(get_key_value "$1");;
130146
-c)
@@ -223,6 +239,9 @@ cmake ../../ \
223239
-DCMAKE_BUILD_TYPE=$build_type \
224240
-DUSE_CPP11=$use_cpp11 \
225241
-DENABLE_COV=$enable_cov \
242+
-DENABLE_ASAN=$enable_asan \
243+
-DTSFILE_SANITIZER_CHECKS=$sanitizer_checks \
244+
-DTSFILE_SANITIZER_SHARED_RUNTIME=$sanitizer_shared_runtime \
226245
-DDEBUG_SE=$debug_se \
227246
-DENABLE_ANTLR4=$enable_antlr4 \
228247
-DBUILD_TSFILE_ONLY=$build_tsfile_only \

‎cpp/pom.xml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@
3434
<coverage.enabled>OFF</coverage.enabled>
3535
<build.type>Release</build.type>
3636
<enable.asan>OFF</enable.asan>
37+
<!--
38+
Sanitizer details used when enable.asan is ON. The binding build
39+
(-Dtsfile.sanitizer=address) activates the with-sanitizer profile,
40+
which selects the address-only check set and a dynamically linked
41+
runtime that Python can preload.
42+
-->
43+
<tsfile.sanitizer.checks>address,undefined</tsfile.sanitizer.checks>
44+
<tsfile.sanitizer.shared.runtime>OFF</tsfile.sanitizer.shared.runtime>
3745
<build.test>ON</build.test>
3846
<tsfile.build.shared>ON</tsfile.build.shared>
3947
<tsfile.dependency.source>BUNDLED</tsfile.dependency.source>
@@ -110,6 +118,8 @@
110118
<option>-DCOV_ENABLED=${coverage.enabled}</option>
111119
<option>-DCMAKE_BUILD_TYPE=${build.type}</option>
112120
<option>-DENABLE_ASAN=${enable.asan}</option>
121+
<option>-DTSFILE_SANITIZER_CHECKS=${tsfile.sanitizer.checks}</option>
122+
<option>-DTSFILE_SANITIZER_SHARED_RUNTIME=${tsfile.sanitizer.shared.runtime}</option>
113123
<option>-DBUILD_TEST=${build.test}</option>
114124
<option>-DTSFILE_BUILD_SHARED=${tsfile.build.shared}</option>
115125
<option>-DTSFILE_DEPENDENCY_SOURCE=${tsfile.dependency.source}</option>
@@ -291,6 +301,25 @@
291301
</plugins>
292302
</build>
293303
</profile>
304+
<profile>
305+
<id>with-sanitizer</id>
306+
<!--
307+
Activated by -Dtsfile.sanitizer=<checks> (for example
308+
-Dtsfile.sanitizer=address). Selects ASan, keeps the sanitizer
309+
runtime dynamic, and lets setup.py instrument every Python
310+
extension with the same check set.
311+
-->
312+
<activation>
313+
<property>
314+
<name>tsfile.sanitizer</name>
315+
</property>
316+
</activation>
317+
<properties>
318+
<enable.asan>ON</enable.asan>
319+
<tsfile.sanitizer.checks>${tsfile.sanitizer}</tsfile.sanitizer.checks>
320+
<tsfile.sanitizer.shared.runtime>ON</tsfile.sanitizer.shared.runtime>
321+
</properties>
322+
</profile>
294323
<profile>
295324
<id>with-code-coverage</id>
296325
<properties>

‎cpp/src/cwrapper/tsfile_cwrapper.cc‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,14 @@ static void free_device_schema_array(DeviceSchema* schemas, size_t size) {
11731173
free(schemas);
11741174
}
11751175

1176+
void tsfile_free_table_schema_array(TableSchema* schemas, uint32_t length) {
1177+
free_table_schema_array(schemas, length);
1178+
}
1179+
1180+
void tsfile_free_device_schema_array(DeviceSchema* schemas, uint32_t length) {
1181+
free_device_schema_array(schemas, length);
1182+
}
1183+
11761184
ERRNO tsfile_reader_get_table_schema_checked(TsFileReader reader,
11771185
const char* table_name,
11781186
TableSchema* out_schema) {
@@ -2170,6 +2178,8 @@ ERRNO tsfile_reader_get_tsfile_properties(TsFileReader reader,
21702178
return common::E_INVALID_ARG;
21712179
}
21722180

2181+
TsFileProperty* properties = nullptr;
2182+
uint32_t property_index = 0;
21732183
try {
21742184
auto* r = static_cast<storage::TsFileReader*>(reader);
21752185
storage::TsFileProperties cpp_properties = r->get_tsfile_properties();
@@ -2183,14 +2193,13 @@ ERRNO tsfile_reader_get_tsfile_properties(TsFileReader reader,
21832193
return common::E_OK;
21842194
}
21852195

2186-
auto* properties = static_cast<TsFileProperty*>(
2196+
properties = static_cast<TsFileProperty*>(
21872197
malloc(sizeof(TsFileProperty) * cpp_properties.size()));
21882198
if (properties == nullptr) {
21892199
return common::E_OOM;
21902200
}
21912201
memset(properties, 0, sizeof(TsFileProperty) * cpp_properties.size());
21922202

2193-
uint32_t property_index = 0;
21942203
for (const auto& cpp_property : cpp_properties) {
21952204
TsFileProperty& property = properties[property_index];
21962205
if (cpp_property.first.size() >
@@ -2241,6 +2250,8 @@ ERRNO tsfile_reader_get_tsfile_properties(TsFileReader reader,
22412250
*out_length = static_cast<uint32_t>(cpp_properties.size());
22422251
return common::E_OK;
22432252
} catch (const std::bad_alloc&) {
2253+
// get_tsfile_properties() can throw after the array was allocated.
2254+
tsfile_free_tsfile_properties(properties, property_index + 1);
22442255
return common::E_OOM;
22452256
}
22462257
}
@@ -2296,6 +2307,8 @@ void free_table_schema(TableSchema schema) {
22962307
}
22972308
void free_column_schema(ColumnSchema schema) { free(schema.column_name); }
22982309

2310+
void tsfile_free_buffer(void* buffer) { free(buffer); }
2311+
22992312
void free_write_file(WriteFile* write_file) {
23002313
auto f = static_cast<storage::WriteFile*>(*write_file);
23012314
delete f;

0 commit comments

Comments
 (0)