diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c20ff88..87152589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: - client-sdk-rust - client-sdk-rust/** - cmake/** + - scripts/memory-regression.sh - scripts/set-test-tokens.sh - scripts/run-with-backtrace.sh - CMakeLists.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 398aa0f0..0732de1e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -357,7 +357,11 @@ jobs: --gtest_output="xml:${{ env.BUILD_DIR }}\unit-test-results.xml" - name: Start livekit-server - if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) + if: >- + matrix.e2e-testing && + (inputs.integration_repeat > 0 || + inputs.run_stress_tests || + (matrix.name == 'ubuntu-24.04-x64' && inputs.build_type == 'release')) id: livekit_server uses: livekit/dev-server-action@5d4d5337a875e2d1afd37bed03c601d159dab002 # v1.1.1 with: @@ -368,7 +372,11 @@ jobs: # Needed by token helper script - name: Install livekit-cli - if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) + if: >- + matrix.e2e-testing && + (inputs.integration_repeat > 0 || + inputs.run_stress_tests || + (matrix.name == 'ubuntu-24.04-x64' && inputs.build_type == 'release')) shell: bash env: # Windows installs lk via `gh api` / `gh release download`, which need this env var @@ -425,6 +433,21 @@ jobs: --gtest_recreate_environments_when_repeating=1 \ --gtest_output=xml:${{ env.BUILD_DIR }}/integration-test-results.xml + - name: Run memory regression tests + if: matrix.name == 'ubuntu-24.04-x64' && inputs.build_type == 'release' + timeout-minutes: 10 + shell: bash + run: | + set -euo pipefail + source scripts/set-test-tokens.sh + scripts/memory-regression.sh \ + --build-dir "${{ env.BUILD_DIR }}" \ + --scenario all \ + --iterations 100 \ + --warmup 20 \ + --max-rss-growth-mib 8 \ + --max-thread-growth 0 + - name: Run stress tests if: matrix.e2e-testing && inputs.run_stress_tests timeout-minutes: ${{ inputs.stress_timeout_minutes }} diff --git a/docs/testing.md b/docs/testing.md index 33919c0e..20b7c673 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -44,6 +44,7 @@ __Note:__ The tests require tokens and a running LiveKit server. See the section | `livekit_unit_tests` | Pure unit tests (no server required) | | `livekit_integration_tests` | Quick tests (~1-2 minutes) for SDK functionality | | `livekit_stress_tests` | Long-running tests (configurable, default 1 hour) | +| `livekit_memory_lifecycle_tester` | Linux lifecycle RSS and thread regression scenarios | ## Running a local LiveKit server for tests @@ -106,7 +107,64 @@ export LIVEKIT_TOKEN_B="$(lk token create --api-key devkey --api-secret secret - - **RPC**: round-trip calls, max payload (15 KB), timeouts, errors, concurrent calls. - **Stress**: high throughput, bidirectional RPC, memory pressure. -## Memory checks (valgrind) +## Memory lifecycle tester + +> Note: Linux only + +The memory lifecycle tester binary catches resource leak regressions in an +automated fashion, and is invoked in CI. This single binary runs individual +or groups of scenarios: + +- `audio-track` repeatedly initializes the SDK, connects, publishes an audio + track, captures synthetic PCM frames, unpublishes, disconnects, and shuts down. +- `video-track` repeatedly initializes the SDK, connects, publishes a video + track, captures synthetic I420 frames, unpublishes, disconnects, and shuts down. +- `data-track` repeatedly connects, publishes a data track, pushes synthetic + payloads, unpublishes it, and disconnects. +- `room-reuse` repeatedly connects and disconnects the same room object. +- `room-client-leave` repeatedly connects, disconnects, and destroys a room. +- `room-server-delete` repeatedly connects and uses `lk` to delete the local + test room, then verifies the disconnect and end-of-stream callbacks. + +Each scenario reports process RSS and thread count at cycle 0, after warmup +cycle 20, and after final cycle 100. The gate applies to warmup-to-final growth +and fails on thread growth or more than the configured amount of RSS growth. + +The binary can be run directly, or via a helper script at `scripts/memory-regression.sh`, +which invokes the binary but also has GitHub actions support for a results summary table. +The helper defaults `MALLOC_ARENA_MAX` to `1` when launching each tester process. +This limits glibc's allocator arenas, reducing Linux thread-churn RSS noise; it is a +controlled CI signal, not evidence that the default allocator returns all +freed pages to the OS. + +Start `livekit-server --dev`, install the `lk` CLI, and source the local test +tokens before running any scenario: + +```bash +./build.sh release-tests +source scripts/set-test-tokens.sh +scripts/memory-regression.sh --scenario audio-track \ + --iterations 100 --warmup 20 \ + --max-rss-growth-mib 8 --max-thread-growth 0 +scripts/memory-regression.sh --scenario video-track \ + --iterations 100 --warmup 20 \ + --max-rss-growth-mib 8 --max-thread-growth 0 +``` + +Use `--scenario all` to run all six scenarios, each in a fresh tester process: + +```bash +scripts/memory-regression.sh --scenario data-track +scripts/memory-regression.sh --scenario room-reuse +scripts/memory-regression.sh --scenario room-client-leave +scripts/memory-regression.sh --scenario room-server-delete +scripts/memory-regression.sh --scenario all +``` + +The limits are command-line options so a longer local run can use different +budgets. + +## Additional memory checks (valgrind) Run `valgrind` against the test binaries to check for memory leaks and other issues. See [tools.md](tools.md) for the recipe. diff --git a/include/livekit/local_participant.h b/include/livekit/local_participant.h index 28ba83ef..b24e3be2 100644 --- a/include/livekit/local_participant.h +++ b/include/livekit/local_participant.h @@ -296,6 +296,7 @@ class LIVEKIT_API LocalParticipant : public Participant { /// Publication SID → local track (@ref unpublishTrack clears the track’s /// cached publication). @c mutable so @ref trackPublications() const can /// prune expired @c weak_ptr entries. + mutable std::mutex published_tracks_mutex_; mutable TrackMap published_tracks_by_sid_; std::unordered_map rpc_handlers_; diff --git a/include/livekit/local_track_publication.h b/include/livekit/local_track_publication.h index 412b4fb9..bae23d94 100644 --- a/include/livekit/local_track_publication.h +++ b/include/livekit/local_track_publication.h @@ -31,6 +31,9 @@ class LIVEKIT_API LocalTrackPublication : public TrackPublication { /// Note, this LocalTrackPublication is constructed internally only; /// safe to accept proto::OwnedTrackPublication. explicit LocalTrackPublication(const proto::OwnedTrackPublication& owned); + +private: + friend class Room; }; } // namespace livekit diff --git a/scripts/memory-regression.sh b/scripts/memory-regression.sh new file mode 100755 index 00000000..cfee8beb --- /dev/null +++ b/scripts/memory-regression.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# Copyright 2026 LiveKit +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: memory-regression.sh [OPTIONS] + +Run the Linux memory lifecycle regression tester and append a table to the +GitHub step summary when GITHUB_STEP_SUMMARY is set. + +Options: + --build-dir PATH Build directory (default: build-release) + --scenario NAME audio-track, video-track, data-track, + room-reuse, room-client-leave, + room-server-delete, or all (default: audio-track) + --iterations N Lifecycle cycles (default: 100) + --warmup N Baseline cycle (default: 20) + --max-rss-growth-mib N Allowed RSS growth (default: 8) + --max-thread-growth N Allowed thread growth (default: 0) + -h, --help Show this help + +Environment: + MEMORY_REGRESSION_BUILD_DIR, MEMORY_REGRESSION_SCENARIO, + MEMORY_REGRESSION_ITERATIONS, + MEMORY_REGRESSION_WARMUP, MEMORY_REGRESSION_MAX_RSS_GROWTH_MIB, + MEMORY_REGRESSION_MAX_THREAD_GROWTH, MALLOC_ARENA_MAX. + All scenarios require LIVEKIT_URL and LIVEKIT_TOKEN_A. +EOF +} + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +build_dir="${MEMORY_REGRESSION_BUILD_DIR:-build-release}" +scenario="${MEMORY_REGRESSION_SCENARIO:-audio-track}" +iterations="${MEMORY_REGRESSION_ITERATIONS:-100}" +warmup="${MEMORY_REGRESSION_WARMUP:-20}" +max_rss_growth_mib="${MEMORY_REGRESSION_MAX_RSS_GROWTH_MIB:-8}" +max_thread_growth="${MEMORY_REGRESSION_MAX_THREAD_GROWTH:-0}" + +while (($#)); do + case "$1" in + --build-dir) build_dir="$2"; shift 2 ;; + --scenario) scenario="$2"; shift 2 ;; + --iterations) iterations="$2"; shift 2 ;; + --warmup) warmup="$2"; shift 2 ;; + --max-rss-growth-mib) max_rss_growth_mib="$2"; shift 2 ;; + --max-thread-growth) max_thread_growth="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ "${build_dir}" != /* ]]; then + build_dir="${repo_root}/${build_dir}" +fi +tester="${build_dir}/bin/livekit_memory_lifecycle_tester" +if [[ ! -x "${tester}" ]]; then + echo "ERROR: ${tester} not found. Run ./build.sh release-tests first." >&2 + exit 1 +fi + +case "${scenario}" in + audio-track|video-track|data-track|room-reuse|room-client-leave|room-server-delete) + scenarios=("${scenario}") + ;; + all) + scenarios=(audio-track video-track data-track room-reuse room-client-leave room-server-delete) + ;; + *) + echo "ERROR: invalid scenario: ${scenario}" >&2 + usage >&2 + exit 2 + ;; +esac + +for current_scenario in "${scenarios[@]}"; do + if [[ -z "${LIVEKIT_URL:-}" || -z "${LIVEKIT_TOKEN_A:-}" ]]; then + echo "ERROR: ${current_scenario} requires LIVEKIT_URL and LIVEKIT_TOKEN_A." >&2 + echo "Run: source scripts/set-test-tokens.sh" >&2 + exit 1 + fi +done + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "## Memory regression results" + echo + echo "Iterations: ${iterations}" + echo "Warmup: ${warmup}" + echo "Threshold: RSS growth ≤ ${max_rss_growth_mib} MiB; thread growth ≤ ${max_thread_growth}." + echo + echo "| Scenario | RSS 0→${warmup} | RSS ${warmup}→${iterations} | Threads 0→${warmup} | Threads ${warmup}→${iterations} | Status |" + echo "|---|---:|---:|---:|---:|---|" + } >> "${GITHUB_STEP_SUMMARY}" +fi + +report_regex='memory lifecycle: scenario=([a-z-]+), RSS 0 ([0-9]+\.[0-9]+) -> warmup ([0-9]+\.[0-9]+) MiB \((-?[0-9]+\.[0-9]+) MiB\) -> final ([0-9]+\.[0-9]+) MiB \((-?[0-9]+\.[0-9]+) MiB\), threads 0 ([0-9]+) -> warmup ([0-9]+) \((-?[0-9]+)\) -> final ([0-9]+) \((-?[0-9]+)\), verdict=(PASS|FAIL)' + +format_growth() { + if [[ "$1" == "0" || "$1" == "0.00" || "$1" == -* ]]; then + printf '%s' "$1" + else + printf '+%s' "$1" + fi +} + +overall_status=0 +for current_scenario in "${scenarios[@]}"; do + set +e + output="$( + MALLOC_ARENA_MAX="${MALLOC_ARENA_MAX:-1}" "${tester}" \ + --scenario "${current_scenario}" \ + --iterations "${iterations}" \ + --warmup "${warmup}" \ + --max-rss-growth-mib "${max_rss_growth_mib}" \ + --max-thread-growth "${max_thread_growth}" 2>&1 + )" + status=$? + set -e + printf '%s\n' "${output}" + + if [[ "${output}" =~ ${report_regex} ]]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + if [[ "${BASH_REMATCH[12]}" == "PASS" ]]; then + verdict=":white_check_mark: PASS" + else + verdict=":x: FAIL" + fi + rss_warmup_growth="$(format_growth "${BASH_REMATCH[4]}")" + rss_final_growth="$(format_growth "${BASH_REMATCH[6]}")" + thread_warmup_growth="$(format_growth "${BASH_REMATCH[9]}")" + thread_final_growth="$(format_growth "${BASH_REMATCH[11]}")" + echo "| ${BASH_REMATCH[1]} | ${rss_warmup_growth} MiB | ${rss_final_growth} MiB | ${thread_warmup_growth} | ${thread_final_growth} | ${verdict} |" \ + >> "${GITHUB_STEP_SUMMARY}" + fi + else + echo "ERROR: ${current_scenario} did not produce a memory report." >&2 + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + echo "| ${current_scenario} | — | — | — | — | :x: ERROR |" >> "${GITHUB_STEP_SUMMARY}" + fi + status=1 + fi + + if [[ "${status}" -ne 0 ]]; then + overall_status=1 + fi +done + +exit "${overall_status}" diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 6c924560..2a3eba73 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -103,6 +104,8 @@ std::optional ExtractAsyncId(const proto::FfiEvent& event) { return event.get_stats().async_id(); case E::kGetSessionStats: return event.get_session_stats().async_id(); + case E::kSimulateScenario: + return event.simulate_scenario().async_id(); case E::kPublishSipDtmf: return event.publish_sip_dtmf().async_id(); case E::kChatMessage: @@ -655,10 +658,46 @@ std::future FfiClient::getSessionStatsAsync(uintptr_t room_handle) return fut; } +std::future FfiClient::simulateScenarioAsync(uintptr_t room_handle, int scenario) { + const AsyncId async_id = generateAsyncId(); + + auto fut = registerAsync( + async_id, + [async_id](const proto::FfiEvent& event) { + return event.has_simulate_scenario() && event.simulate_scenario().async_id() == async_id; + }, + [](const proto::FfiEvent& event, std::promise& pr) { + const auto& cb = event.simulate_scenario(); + if (cb.has_error() && !cb.error().empty()) { + pr.set_exception(std::make_exception_ptr(std::runtime_error(cb.error()))); + return; + } + pr.set_value(); + }); + + proto::FfiRequest req; + auto* msg = req.mutable_simulate_scenario(); + msg->set_room_handle(room_handle); + msg->set_scenario(static_cast(scenario)); + msg->set_request_async_id(async_id); + + try { + const proto::FfiResponse resp = sendRequest(req); + if (!resp.has_simulate_scenario()) { + logAndThrow("FfiResponse missing simulate_scenario"); + } + } catch (...) { + cancelPendingByAsyncId(async_id); + throw; + } + + return fut; +} + // Participant APIs Implementation -std::future FfiClient::publishTrackAsync(std::uint64_t local_participant_handle, - std::uint64_t track_handle, - const TrackPublishOptions& options) { +std::future FfiClient::publishTrackAsync( + std::uint64_t local_participant_handle, std::uint64_t track_handle, const TrackPublishOptions& options, + std::function on_success) { // Generate client-side async_id first const AsyncId async_id = generateAsyncId(); @@ -670,7 +709,8 @@ std::future FfiClient::publishTrackAsync(std::uint return event.has_publish_track() && event.publish_track().async_id() == async_id; }, // Handler: resolve with publication or throw error - [](const proto::FfiEvent& event, std::promise& pr) { + [on_success = std::move(on_success)](const proto::FfiEvent& event, + std::promise& pr) { const auto& cb = event.publish_track(); // Oneof message { string error = 2; OwnedTrackPublication publication = @@ -685,7 +725,14 @@ std::future FfiClient::publishTrackAsync(std::uint } const proto::OwnedTrackPublication& pub = cb.publication(); - pr.set_value(pub); + try { + if (on_success) { + on_success(pub); + } + pr.set_value(pub); + } catch (...) { + pr.set_exception(std::current_exception()); + } }); // Build and send the request diff --git a/src/ffi_client.h b/src/ffi_client.h index c0835027..4f10b1fa 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -106,10 +106,12 @@ class LIVEKIT_INTERNAL_API FfiClient { std::future getSessionStatsAsync(uintptr_t room_handle); + std::future simulateScenarioAsync(uintptr_t room_handle, int scenario); + // Participant APIs - std::future publishTrackAsync(std::uint64_t local_participant_handle, - std::uint64_t track_handle, - const TrackPublishOptions& options); + std::future publishTrackAsync( + std::uint64_t local_participant_handle, std::uint64_t track_handle, const TrackPublishOptions& options, + std::function on_success = {}); std::future unpublishTrackAsync(std::uint64_t local_participant_handle, const std::string& track_sid, bool stop_on_unpublish); std::future publishDataAsync(std::uint64_t local_participant_handle, const std::uint8_t* data_ptr, diff --git a/src/local_participant.cpp b/src/local_participant.cpp index 5203ca83..c915b223 100644 --- a/src/local_participant.cpp +++ b/src/local_participant.cpp @@ -190,19 +190,18 @@ void LocalParticipant::publishTrack(const std::shared_ptr& track, const T if (track_handle == 0) { throw std::runtime_error("LocalParticipant::publishTrack: invalid track FFI handle"); } - auto fut = FfiClient::instance().publishTrackAsync(static_cast(participant_handle), - static_cast(track_handle), options); - - // Will throw if the async op fails (error in callback). - const proto::OwnedTrackPublication owned_pub = fut.get(); - - // Construct a LocalTrackPublication from the proto publication. - auto publication = std::make_shared(owned_pub); - - const std::string sid = publication->sid(); - published_tracks_by_sid_[sid] = std::weak_ptr(track); - - track->setPublication(publication); + auto fut = FfiClient::instance().publishTrackAsync( + static_cast(participant_handle), static_cast(track_handle), options, + [this, track](const proto::OwnedTrackPublication& owned_pub) { + auto publication = std::make_shared(owned_pub); + const std::scoped_lock guard(published_tracks_mutex_); + published_tracks_by_sid_[publication->sid()] = track; + track->setPublication(publication); + }); + + // Publication state is installed on the FFI thread before this future is + // completed and before Rust can emit LocalTrackPublished. + (void)fut.get(); } std::shared_ptr LocalParticipant::publishVideoTrack(const std::string& name, @@ -248,6 +247,7 @@ void LocalParticipant::unpublishTrack(const std::string& track_sid) { fut.get(); + const std::scoped_lock guard(published_tracks_mutex_); if (auto it = published_tracks_by_sid_.find(track_sid); it != published_tracks_by_sid_.end()) { if (auto t = it->second.lock()) { t->setPublication(nullptr); @@ -257,6 +257,7 @@ void LocalParticipant::unpublishTrack(const std::string& track_sid) { } LocalParticipant::PublicationMap LocalParticipant::trackPublications() const { + const std::scoped_lock guard(published_tracks_mutex_); PublicationMap out; for (auto it = published_tracks_by_sid_.begin(); it != published_tracks_by_sid_.end();) { auto t = it->second.lock(); @@ -494,6 +495,7 @@ void LocalParticipant::handleRpcMethodInvocation(uint64_t invocation_id, const s } std::shared_ptr LocalParticipant::findTrackPublication(const std::string& sid) const { + const std::scoped_lock guard(published_tracks_mutex_); auto it = published_tracks_by_sid_.find(sid); if (it == published_tracks_by_sid_.end()) { return nullptr; diff --git a/src/room.cpp b/src/room.cpp index f37c5642..1e597ac3 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -20,7 +20,10 @@ #include "ffi_client.h" #include "livekit/audio_stream.h" #include "livekit/e2ee.h" +#include "livekit/local_audio_track.h" #include "livekit/local_participant.h" +#include "livekit/local_track_publication.h" +#include "livekit/local_video_track.h" #include "livekit/remote_audio_track.h" #include "livekit/remote_data_track.h" #include "livekit/remote_participant.h" @@ -47,6 +50,19 @@ using proto::FfiResponse; namespace { +std::shared_ptr localTrackPublication(const std::shared_ptr& track) { + if (!track) { + return nullptr; + } + if (auto video = std::dynamic_pointer_cast(track)) { + return video->publication(); + } + if (auto audio = std::dynamic_pointer_cast(track)) { + return audio->publication(); + } + return nullptr; +} + std::shared_ptr createRemoteParticipant(const proto::OwnedParticipant& owned) { const auto& pinfo = owned.info(); std::unordered_map attrs; @@ -593,6 +609,50 @@ void Room::onEvent(const FfiEvent& event) { } break; } + case proto::RoomEvent::kLocalTrackRepublished: { + const std::scoped_lock guard(lock_); + if (!local_participant_) { + LK_LOG_ERROR("kLocalTrackRepublished: local_participant_ is nullptr"); + break; + } + const auto& ltr = re.local_track_republished(); + const std::string& previous_sid = ltr.previous_sid(); + + const std::scoped_lock publications_guard(local_participant_->published_tracks_mutex_); + auto& published = local_participant_->published_tracks_by_sid_; + auto it = published.find(previous_sid); + if (it == published.end()) { + LK_LOG_WARN("local_track_republished for unknown previous sid: {}", previous_sid); + break; + } + auto track = it->second.lock(); + if (!track) { + published.erase(it); + LK_LOG_WARN("local_track_republished for expired previous sid: {}", previous_sid); + break; + } + auto publication = localTrackPublication(track); + if (!publication) { + LK_LOG_WARN("local_track_republished missing publication for sid: {}", previous_sid); + break; + } + const auto& info = ltr.info(); + publication->sid_ = info.sid(); + publication->name_ = info.name(); + publication->kind_ = fromProto(info.kind()); + publication->source_ = fromProto(info.source()); + publication->simulcasted_ = info.simulcasted(); + publication->width_ = info.width(); + publication->height_ = info.height(); + publication->mime_type_ = info.mime_type(); + publication->muted_ = info.muted(); + publication->encryption_type_ = static_cast(info.encryption_type()); + publication->audio_features_ = convertAudioFeatures(info.audio_features()); + published.erase(it); + published[publication->sid()] = track; + track->setPublication(publication); + break; + } case proto::RoomEvent::kLocalTrackSubscribed: { LocalTrackSubscribedEvent ev; { diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 9583af73..1204193c 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -348,3 +348,22 @@ endif() if(TARGET livekit_stress_tests) add_dependencies(run_all_tests livekit_stress_tests) endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(livekit_memory_lifecycle_tester + "${CMAKE_CURRENT_SOURCE_DIR}/memory/lifecycle_tester.cpp" + ) + target_link_libraries(livekit_memory_lifecycle_tester PRIVATE livekit) + target_include_directories(livekit_memory_lifecycle_tester PRIVATE + ${LIVEKIT_ROOT_DIR}/include + ) + add_custom_command(TARGET livekit_memory_lifecycle_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.so" + $ + COMMENT "Copying shared libraries next to livekit_memory_lifecycle_tester" + ) +endif() diff --git a/src/tests/common/room_test_access.h b/src/tests/common/room_test_access.h new file mode 100644 index 00000000..9509b00c --- /dev/null +++ b/src/tests/common/room_test_access.h @@ -0,0 +1,49 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include +#include + +#include "ffi_client.h" +#include "room.pb.h" + +namespace livekit { + +struct RoomTestAccess { + static int listenerId(const Room& room) { + const std::scoped_lock guard(room.lock_); + return room.listener_id_; + } + + static void simulateScenario(Room& room, proto::SimulateScenarioKind scenario) { + std::shared_ptr handle; + { + const std::scoped_lock guard(room.lock_); + handle = room.room_handle_; + } + if (!handle) { + throw std::runtime_error("cannot simulate scenario for a disconnected room"); + } + FfiClient::instance().simulateScenarioAsync(handle->get(), static_cast(scenario)).get(); + } +}; + +} // namespace livekit diff --git a/src/tests/integration/test_local_track_publish_sid.cpp b/src/tests/integration/test_local_track_publish_sid.cpp index 91fbf33b..21707f4b 100644 --- a/src/tests/integration/test_local_track_publish_sid.cpp +++ b/src/tests/integration/test_local_track_publish_sid.cpp @@ -16,15 +16,48 @@ #include +#include +#include #include +#include #include +#include +#include #include "../common/audio_utils.h" +#include "../common/room_test_access.h" #include "../common/test_common.h" +#include "room.pb.h" namespace livekit::test { namespace { +using namespace std::chrono_literals; + +class LocalPublicationDelegate final : public RoomDelegate { +public: + void onLocalTrackPublished(Room&, const LocalTrackPublishedEvent& event) override { + if (!event.publication) { + return; + } + { + const std::scoped_lock guard(mutex_); + published_sids_.insert(event.publication->sid()); + } + cv_.notify_all(); + } + + bool waitForPublished(const std::string& sid, std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [&] { return published_sids_.count(sid) != 0; }); + } + +private: + std::mutex mutex_; + std::condition_variable cv_; + std::unordered_set published_sids_; +}; + void expectTrackSidAssigned(const Track& track, const LocalTrackPublication& publication) { const std::string& track_sid = track.sid(); const std::string& publication_sid = publication.sid(); @@ -33,6 +66,17 @@ void expectTrackSidAssigned(const Track& track, const LocalTrackPublication& pub EXPECT_EQ(track_sid, publication_sid); } +bool waitForSidChange(const Track& track, const std::string& previous_sid, std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (track.sid() != previous_sid && track.sid() != "TR_unknown") { + return true; + } + std::this_thread::sleep_for(50ms); + } + return track.sid() != previous_sid && track.sid() != "TR_unknown"; +} + } // namespace class LocalTrackPublishSidTest : public LiveKitTestBase {}; @@ -41,6 +85,8 @@ TEST_F(LocalTrackPublishSidTest, PublishVideoTrackAssignsSid) { failIfNotConfigured(); Room room; + LocalPublicationDelegate delegate; + room.setDelegate(&delegate); const RoomOptions room_options; ASSERT_TRUE(room.connect(config_.url, config_.token_a, room_options)); @@ -52,6 +98,7 @@ TEST_F(LocalTrackPublishSidTest, PublishVideoTrackAssignsSid) { ASSERT_NE(track->publication(), nullptr); expectTrackSidAssigned(*track, *track->publication()); + EXPECT_TRUE(delegate.waitForPublished(track->sid(), 5s)); lockLocalParticipant(room)->unpublishTrack(track->publication()->sid()); } @@ -59,6 +106,8 @@ TEST_F(LocalTrackPublishSidTest, PublishAudioTrackAssignsSid) { failIfNotConfigured(); Room room; + LocalPublicationDelegate delegate; + room.setDelegate(&delegate); const RoomOptions room_options; ASSERT_TRUE(room.connect(config_.url, config_.token_a, room_options)); @@ -70,7 +119,38 @@ TEST_F(LocalTrackPublishSidTest, PublishAudioTrackAssignsSid) { ASSERT_NE(track->publication(), nullptr); expectTrackSidAssigned(*track, *track->publication()); + EXPECT_TRUE(delegate.waitForPublished(track->sid(), 5s)); lockLocalParticipant(room)->unpublishTrack(track->publication()->sid()); } +TEST_F(LocalTrackPublishSidTest, FullReconnectUpdatesPublishedSid) { + failIfNotConfigured(); + + Room room; + const RoomOptions room_options; + ASSERT_TRUE(room.connect(config_.url, config_.token_a, room_options)); + + auto source = std::make_shared(VideoCodec::H264, 16, 16); + std::shared_ptr track; + ASSERT_NO_THROW( + track = lockLocalParticipant(room)->publishVideoTrack("republish-sid-check", source, TrackSource::SOURCE_CAMERA)); + ASSERT_NE(track, nullptr); + ASSERT_NE(track->publication(), nullptr); + expectTrackSidAssigned(*track, *track->publication()); + + const std::string previous_sid = track->sid(); + ASSERT_NO_THROW(RoomTestAccess::simulateScenario(room, proto::SIMULATE_FULL_RECONNECT)); + ASSERT_TRUE(waitForSidChange(*track, previous_sid, 30s)) << "Timed out waiting for republished track SID"; + + ASSERT_NE(track->publication(), nullptr); + expectTrackSidAssigned(*track, *track->publication()); + EXPECT_NE(track->sid(), previous_sid); + + const auto pubs = lockLocalParticipant(room)->trackPublications(); + EXPECT_EQ(pubs.count(previous_sid), 0u); + EXPECT_EQ(pubs.count(track->sid()), 1u); + + lockLocalParticipant(room)->unpublishTrack(track->sid()); +} + } // namespace livekit::test diff --git a/src/tests/integration/test_room.cpp b/src/tests/integration/test_room.cpp index 942d8939..b6f3c80a 100644 --- a/src/tests/integration/test_room.cpp +++ b/src/tests/integration/test_room.cpp @@ -29,21 +29,11 @@ #include #include +#include "../common/room_test_access.h" #include "../common/test_common.h" using namespace std::chrono_literals; -namespace livekit { - -struct RoomTestAccess { - static int listenerId(const Room& room) { - const std::scoped_lock guard(room.lock_); - return room.listener_id_; - } -}; - -} // namespace livekit - namespace livekit::test { // Server-dependent tests - require LIVEKIT_URL and LIVEKIT_TOKEN_A env vars diff --git a/src/tests/memory/lifecycle_tester.cpp b/src/tests/memory/lifecycle_tester.cpp new file mode 100644 index 00000000..b8d279ad --- /dev/null +++ b/src/tests/memory/lifecycle_tester.cpp @@ -0,0 +1,508 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace std::chrono_literals; + +constexpr std::string_view kLocalTestLiveKitUrl = "ws://localhost:7880"; +constexpr int kFramesPerCycle = 3; + +enum class Scenario { + AudioTrack, + VideoTrack, + DataTrack, + RoomReuse, + RoomClientLeave, + RoomServerDelete, +}; + +struct Options { + Scenario scenario{Scenario::AudioTrack}; + std::uint64_t iterations{100}; + std::uint64_t warmup{20}; + std::uint64_t max_rss_growth_mib{8}; + std::uint64_t max_thread_growth{0}; +}; + +struct ProcessSample { + std::uint64_t rss_kib{}; + std::uint64_t threads{}; +}; + +struct RoomCredentials { + std::string url; + std::string token; +}; + +class SdkLifetime { +public: + SdkLifetime() { + if (!livekit::initialize(livekit::LogLevel::Warn)) { + throw std::runtime_error("LiveKit initialization failed"); + } + } + + ~SdkLifetime() { livekit::shutdown(); } + + SdkLifetime(const SdkLifetime&) = delete; + SdkLifetime& operator=(const SdkLifetime&) = delete; +}; + +class DisconnectTrackingDelegate final : public livekit::RoomDelegate { +public: + void onDisconnected(livekit::Room&, const livekit::DisconnectedEvent& event) override { + { + const std::scoped_lock lock(mutex_); + reason_ = event.reason; + ++disconnect_count_; + } + condition_.notify_all(); + } + + void onRoomEos(livekit::Room&, const livekit::RoomEosEvent&) override { + { + const std::scoped_lock lock(mutex_); + ++eos_count_; + } + condition_.notify_all(); + } + + bool waitForTeardown(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return condition_.wait_for(lock, timeout, [this]() { return disconnect_count_ > 0 && eos_count_ > 0; }); + } + + void requireDisconnect(livekit::DisconnectReason expected_reason) const { + const std::scoped_lock lock(mutex_); + if (disconnect_count_ != 1 || reason_ != expected_reason) { + throw std::runtime_error("unexpected room disconnect event"); + } + } + + void requireServerTeardown() const { + const std::scoped_lock lock(mutex_); + if (disconnect_count_ != 1 || eos_count_ != 1 || reason_ != livekit::DisconnectReason::RoomDeleted) { + throw std::runtime_error("unexpected server room teardown events"); + } + } + +private: + mutable std::mutex mutex_; + std::condition_variable condition_; + livekit::DisconnectReason reason_{livekit::DisconnectReason::Unknown}; + int disconnect_count_{0}; + int eos_count_{0}; +}; + +std::string_view scenarioName(Scenario scenario) { + switch (scenario) { + case Scenario::AudioTrack: + return "audio-track"; + case Scenario::VideoTrack: + return "video-track"; + case Scenario::DataTrack: + return "data-track"; + case Scenario::RoomReuse: + return "room-reuse"; + case Scenario::RoomClientLeave: + return "room-client-leave"; + case Scenario::RoomServerDelete: + return "room-server-delete"; + } + throw std::runtime_error("unknown scenario"); +} + +Scenario parseScenario(std::string_view value) { + if (value == "audio-track") { + return Scenario::AudioTrack; + } + if (value == "video-track") { + return Scenario::VideoTrack; + } + if (value == "data-track") { + return Scenario::DataTrack; + } + if (value == "room-reuse") { + return Scenario::RoomReuse; + } + if (value == "room-client-leave") { + return Scenario::RoomClientLeave; + } + if (value == "room-server-delete") { + return Scenario::RoomServerDelete; + } + throw std::runtime_error("invalid value for --scenario"); +} + +std::uint64_t parseUnsigned(std::string_view value, std::string_view option) { + std::uint64_t parsed = 0; + const auto result = std::from_chars(value.data(), value.data() + value.size(), parsed); + if (result.ec != std::errc{} || result.ptr != value.data() + value.size()) { + throw std::runtime_error("invalid value for " + std::string(option)); + } + return parsed; +} + +Options parseOptions(int argc, char* argv[]) { + Options options; + for (int index = 1; index < argc; ++index) { + const std::string_view option(argv[index]); + if (index + 1 >= argc) { + throw std::runtime_error("missing value for " + std::string(option)); + } + const std::string_view value(argv[++index]); + if (option == "--scenario") { + options.scenario = parseScenario(value); + } else if (option == "--iterations") { + options.iterations = parseUnsigned(value, option); + } else if (option == "--warmup") { + options.warmup = parseUnsigned(value, option); + } else if (option == "--max-rss-growth-mib") { + options.max_rss_growth_mib = parseUnsigned(value, option); + } else if (option == "--max-thread-growth") { + options.max_thread_growth = parseUnsigned(value, option); + } else { + throw std::runtime_error("unknown option: " + std::string(option)); + } + } + if (options.warmup == 0 || options.warmup >= options.iterations) { + throw std::runtime_error("--warmup must be between 1 and --iterations - 1"); + } + return options; +} + +std::uint64_t statusValue(std::string_view line) { + const auto separator = line.find(':'); + if (separator == std::string_view::npos) { + throw std::runtime_error("malformed /proc/self/status value"); + } + line.remove_prefix(separator + 1); + while (!line.empty() && std::isspace(static_cast(line.front())) != 0) { + line.remove_prefix(1); + } + const auto end = line.find_first_not_of("0123456789"); + return parseUnsigned(line.substr(0, end), "/proc/self/status"); +} + +ProcessSample sampleProcess() { + ProcessSample sample; + std::ifstream status("/proc/self/status"); + std::string line; + while (std::getline(status, line)) { + if (line.rfind("VmRSS:", 0) == 0) { + sample.rss_kib = statusValue(line); + } else if (line.rfind("Threads:", 0) == 0) { + sample.threads = statusValue(line); + } + } + if (sample.rss_kib == 0 || sample.threads == 0) { + throw std::runtime_error("failed to read RSS or thread count from /proc/self/status"); + } + return sample; +} + +double kibToMib(std::uint64_t kib) { return static_cast(kib) / 1024.0; } + +double kibToMib(std::int64_t kib) { return static_cast(kib) / 1024.0; } + +RoomCredentials roomCredentials() { + const char* url = std::getenv("LIVEKIT_URL"); + const char* token = std::getenv("LIVEKIT_TOKEN_A"); + if (url == nullptr || token == nullptr || *url == '\0' || *token == '\0') { + throw std::runtime_error("memory scenarios require LIVEKIT_URL and LIVEKIT_TOKEN_A"); + } + return {url, token}; +} + +void connectRoom(livekit::Room& room, const RoomCredentials& credentials) { + if (!room.connect(credentials.url, credentials.token, livekit::RoomOptions{})) { + throw std::runtime_error("room connection failed"); + } + if (room.connectionState() != livekit::ConnectionState::Connected || room.localParticipant().expired()) { + throw std::runtime_error("room did not reach a connected state"); + } +} + +// Exercises SDK startup, room connection, audio publication and capture, +// explicit unpublication, client disconnect, and complete process-side teardown. +void runAudioTrackCycle(const RoomCredentials& credentials) { + SdkLifetime sdk; + DisconnectTrackingDelegate delegate; + livekit::Room room; + room.setDelegate(&delegate); + connectRoom(room, credentials); + + auto audio_source = std::make_shared(48'000, 1); + auto audio_track = livekit::LocalAudioTrack::createLocalAudioTrack("memory-audio", audio_source); + if (!audio_track) { + throw std::runtime_error("failed to create local audio track"); + } + + auto participant = room.localParticipant().lock(); + if (!participant) { + throw std::runtime_error("local participant expired before publishing audio track"); + } + livekit::TrackPublishOptions publish_options; + publish_options.source = livekit::TrackSource::SOURCE_MICROPHONE; + participant->publishTrack(audio_track, publish_options); + const auto publication = audio_track->publication(); + if (!publication) { + throw std::runtime_error("failed to publish local audio track"); + } + + auto frame = livekit::AudioFrame::create(48'000, 1, 480); + std::fill(frame.data().begin(), frame.data().end(), 100); + for (int frame_index = 0; frame_index < kFramesPerCycle; ++frame_index) { + audio_source->captureFrame(frame, 1'000); + } + + participant->unpublishTrack(publication->sid()); + if (!room.disconnect()) { + throw std::runtime_error("client room disconnect failed"); + } + delegate.requireDisconnect(livekit::DisconnectReason::ClientInitiated); +} + +// Exercises SDK startup, room connection, video publication and capture, +// encoder/resource teardown, explicit unpublication, and client disconnect. +void runVideoTrackCycle(const RoomCredentials& credentials) { + SdkLifetime sdk; + DisconnectTrackingDelegate delegate; + livekit::Room room; + room.setDelegate(&delegate); + connectRoom(room, credentials); + + auto video_source = std::make_shared(640, 360); + auto video_track = livekit::LocalVideoTrack::createLocalVideoTrack("memory-video", video_source); + if (!video_track) { + throw std::runtime_error("failed to create local video track"); + } + + auto participant = room.localParticipant().lock(); + if (!participant) { + throw std::runtime_error("local participant expired before publishing video track"); + } + livekit::TrackPublishOptions publish_options; + publish_options.source = livekit::TrackSource::SOURCE_CAMERA; + publish_options.simulcast = false; + participant->publishTrack(video_track, publish_options); + const auto publication = video_track->publication(); + if (!publication) { + throw std::runtime_error("failed to publish local video track"); + } + + auto frame = livekit::VideoFrame::create(640, 360, livekit::VideoBufferType::I420); + std::fill(frame.data(), frame.data() + frame.dataSize(), 0x7f); + for (int frame_index = 0; frame_index < kFramesPerCycle; ++frame_index) { + video_source->captureFrame(frame); + } + + participant->unpublishTrack(publication->sid()); + if (!room.disconnect()) { + throw std::runtime_error("client room disconnect failed"); + } + delegate.requireDisconnect(livekit::DisconnectReason::ClientInitiated); +} + +// Exercises data-track publication, payload enqueueing, explicit unpublication, +// and room teardown while the SDK remains initialized across cycles. +void runDataTrackCycle(const RoomCredentials& credentials) { + DisconnectTrackingDelegate delegate; + livekit::Room room; + room.setDelegate(&delegate); + connectRoom(room, credentials); + + auto participant = room.localParticipant().lock(); + if (!participant) { + throw std::runtime_error("local participant expired before publishing data track"); + } + auto publish_result = participant->publishDataTrack("memory-data"); + if (!publish_result) { + throw std::runtime_error("failed to publish local data track"); + } + + const auto& track = publish_result.value(); + livekit::DataTrackFrame frame; + frame.payload.assign(256, 0xFA); + for (int frame_index = 0; frame_index < kFramesPerCycle; ++frame_index) { + frame.user_timestamp = static_cast(frame_index); + if (!track->tryPush(frame)) { + throw std::runtime_error("failed to push local data track frame"); + } + } + + track->unpublishDataTrack(); + if (track->isPublished()) { + throw std::runtime_error("local data track remained published"); + } + if (!room.disconnect()) { + throw std::runtime_error("client room disconnect failed"); + } + delegate.requireDisconnect(livekit::DisconnectReason::ClientInitiated); +} + +// Exercises repeated connect/disconnect cycles on one Room instance to detect +// connection state, participant, listener, or FFI resources retained by reuse. +void runRoomReuseCycle(livekit::Room& room, const RoomCredentials& credentials) { + connectRoom(room, credentials); + if (!room.disconnect()) { + throw std::runtime_error("client room disconnect failed"); + } + if (room.connectionState() != livekit::ConnectionState::Disconnected || !room.localParticipant().expired()) { + throw std::runtime_error("reused room disconnect did not tear down local state"); + } +} + +// Exercises application-initiated disconnect and destruction of a fresh Room, +// including local-participant cleanup and the expected disconnect callback. +void runRoomClientLeaveCycle(const RoomCredentials& credentials) { + DisconnectTrackingDelegate delegate; + livekit::Room room; + room.setDelegate(&delegate); + connectRoom(room, credentials); + + if (!room.disconnect()) { + throw std::runtime_error("client room disconnect failed"); + } + if (room.connectionState() != livekit::ConnectionState::Disconnected || !room.localParticipant().expired()) { + throw std::runtime_error("client room disconnect did not tear down local state"); + } + delegate.requireDisconnect(livekit::DisconnectReason::ClientInitiated); +} + +// Exercises server-initiated room deletion and validates disconnect, event-stream +// completion, local-participant cleanup, and destruction of the torn-down Room. +void runRoomServerDeleteCycle(const RoomCredentials& credentials) { + if (std::string_view(credentials.url) != kLocalTestLiveKitUrl) { + throw std::runtime_error("room-server-delete requires LIVEKIT_URL=ws://localhost:7880"); + } + + DisconnectTrackingDelegate delegate; + livekit::Room room; + room.setDelegate(&delegate); + connectRoom(room, credentials); + + // The room name is fixed rather than interpolating untrusted environment data into the command. + if (std::system("lk --dev room delete --yes cpp_data_track_test") != 0) { + throw std::runtime_error("failed to delete local test room"); + } + if (!delegate.waitForTeardown(10s)) { + throw std::runtime_error("timed out waiting for server room teardown"); + } + if (room.connectionState() != livekit::ConnectionState::Disconnected || !room.localParticipant().expired()) { + throw std::runtime_error("server room deletion did not tear down local state"); + } + delegate.requireServerTeardown(); +} + +void runCycle(Scenario scenario, const RoomCredentials& credentials, livekit::Room* reusable_room) { + switch (scenario) { + case Scenario::AudioTrack: + runAudioTrackCycle(credentials); + return; + case Scenario::VideoTrack: + runVideoTrackCycle(credentials); + return; + case Scenario::DataTrack: + runDataTrackCycle(credentials); + return; + case Scenario::RoomReuse: + if (reusable_room == nullptr) { + throw std::runtime_error("room-reuse scenario is missing its persistent room"); + } + runRoomReuseCycle(*reusable_room, credentials); + return; + case Scenario::RoomClientLeave: + runRoomClientLeaveCycle(credentials); + return; + case Scenario::RoomServerDelete: + runRoomServerDeleteCycle(credentials); + return; + } + throw std::runtime_error("unknown scenario"); +} + +} // namespace + +int main(int argc, char* argv[]) { + try { + const Options options = parseOptions(argc, argv); + const RoomCredentials credentials = roomCredentials(); + const bool per_cycle_sdk = options.scenario == Scenario::AudioTrack || options.scenario == Scenario::VideoTrack; + std::unique_ptr room_sdk; + if (!per_cycle_sdk) { + room_sdk = std::make_unique(); + } + std::unique_ptr reusable_room; + if (options.scenario == Scenario::RoomReuse) { + reusable_room = std::make_unique(); + } + + const ProcessSample initial = sampleProcess(); + ProcessSample warmup; + ProcessSample final; + for (std::uint64_t iteration = 1; iteration <= options.iterations; ++iteration) { + runCycle(options.scenario, credentials, reusable_room.get()); + std::this_thread::sleep_for(100ms); + if (iteration == options.warmup) { + warmup = sampleProcess(); + } else if (iteration == options.iterations) { + final = sampleProcess(); + } + } + + const auto warmup_rss_growth = + static_cast(warmup.rss_kib) - static_cast(initial.rss_kib); + const auto final_rss_growth = static_cast(final.rss_kib) - static_cast(warmup.rss_kib); + const auto warmup_thread_growth = + static_cast(warmup.threads) - static_cast(initial.threads); + const auto final_thread_growth = + static_cast(final.threads) - static_cast(warmup.threads); + const bool passed = kibToMib(final_rss_growth) <= static_cast(options.max_rss_growth_mib) && + final_thread_growth <= static_cast(options.max_thread_growth); + std::cout << std::fixed << std::setprecision(2) << "memory lifecycle: scenario=" << scenarioName(options.scenario) + << ", RSS 0 " << kibToMib(initial.rss_kib) << " -> warmup " << kibToMib(warmup.rss_kib) << " MiB (" + << kibToMib(warmup_rss_growth) << " MiB) -> final " << kibToMib(final.rss_kib) << " MiB (" + << kibToMib(final_rss_growth) << " MiB), threads 0 " << initial.threads << " -> warmup " << warmup.threads + << " (" << warmup_thread_growth << ") -> final " << final.threads << " (" << final_thread_growth + << "), verdict=" << (passed ? "PASS" : "FAIL") << '\n'; + return passed ? 0 : 1; + } catch (const std::exception& error) { + std::cerr << "memory lifecycle tester failed: " << error.what() << '\n'; + return 1; + } +}