23#include <catch2/catch_test_macros.hpp>
51#elif defined(__APPLE__)
72struct stability_config {
73 std::chrono::minutes duration{60};
74 double store_rate{5.0};
75 double query_rate{1.0};
76 size_t store_workers{4};
77 size_t query_workers{2};
78 size_t max_memory_growth_mb{100};
79 size_t max_associations{100};
81 static stability_config from_environment() {
82 stability_config config;
84 if (
const char* duration = std::getenv(
"PACS_STABILITY_TEST_DURATION")) {
85 config.duration = std::chrono::minutes{std::atoi(duration)};
87 if (
const char* store_rate = std::getenv(
"PACS_STABILITY_STORE_RATE")) {
88 config.store_rate = std::atof(store_rate);
90 if (
const char* query_rate = std::getenv(
"PACS_STABILITY_QUERY_RATE")) {
91 config.query_rate = std::atof(query_rate);
93 if (
const char* store_workers = std::getenv(
"PACS_STABILITY_STORE_WORKERS")) {
94 config.store_workers =
static_cast<size_t>(std::atoi(store_workers));
96 if (
const char* query_workers = std::getenv(
"PACS_STABILITY_QUERY_WORKERS")) {
97 config.query_workers =
static_cast<size_t>(std::atoi(query_workers));
108struct stability_metrics {
109 std::atomic<size_t> stores_completed{0};
110 std::atomic<size_t> stores_failed{0};
111 std::atomic<size_t> queries_completed{0};
112 std::atomic<size_t> queries_failed{0};
113 std::atomic<size_t> connections_opened{0};
114 std::atomic<size_t> connections_closed{0};
115 std::atomic<size_t> connection_errors{0};
117 size_t initial_memory_kb{0};
118 std::atomic<size_t> peak_memory_kb{0};
119 std::chrono::steady_clock::time_point start_time;
122 stores_completed = 0;
124 queries_completed = 0;
126 connections_opened = 0;
127 connections_closed = 0;
128 connection_errors = 0;
129 initial_memory_kb = 0;
131 start_time = std::chrono::steady_clock::now();
134 [[nodiscard]] std::chrono::seconds elapsed()
const {
135 return std::chrono::duration_cast<std::chrono::seconds>(
136 std::chrono::steady_clock::now() - start_time);
139 void report(std::ostream& out)
const {
140 auto duration = elapsed();
141 double hours =
static_cast<double>(duration.count()) / 3600.0;
144 out <<
"=================================================\n";
145 out <<
" Stability Test Results\n";
146 out <<
"=================================================\n";
147 out <<
" Duration: " << duration.count() <<
" seconds ("
148 << std::fixed << std::setprecision(2) << hours <<
" hours)\n";
150 out <<
" Store Operations:\n";
151 out <<
" Completed: " << stores_completed.load() <<
"\n";
152 out <<
" Failed: " << stores_failed.load() <<
"\n";
153 out <<
" Rate: " << std::fixed << std::setprecision(2)
154 << (
static_cast<double>(stores_completed.load()) / duration.count()) <<
"/sec\n";
156 out <<
" Query Operations:\n";
157 out <<
" Completed: " << queries_completed.load() <<
"\n";
158 out <<
" Failed: " << queries_failed.load() <<
"\n";
159 out <<
" Rate: " << std::fixed << std::setprecision(2)
160 << (
static_cast<double>(queries_completed.load()) / duration.count()) <<
"/sec\n";
162 out <<
" Connections:\n";
163 out <<
" Opened: " << connections_opened.load() <<
"\n";
164 out <<
" Closed: " << connections_closed.load() <<
"\n";
165 out <<
" Errors: " << connection_errors.load() <<
"\n";
168 out <<
" Initial: " << initial_memory_kb / 1024 <<
" MB\n";
169 out <<
" Peak: " << peak_memory_kb / 1024 <<
" MB\n";
170 out <<
" Growth: " << (peak_memory_kb - initial_memory_kb) / 1024 <<
" MB\n";
171 out <<
"=================================================\n";
174 void save_to_file(
const std::filesystem::path& path)
const {
175 std::ofstream file(path);
186size_t get_process_memory_kb() {
188 std::ifstream
status(
"/proc/self/status");
190 while (std::getline(status,
line)) {
191 if (
line.compare(0, 6,
"VmRSS:") == 0) {
192 std::istringstream iss(
line.substr(6));
199#elif defined(__APPLE__)
200 mach_task_basic_info
info;
201 mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
202 if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO,
203 reinterpret_cast<task_info_t
>(&info), &count) == KERN_SUCCESS) {
204 return info.resident_size / 1024;
208 PROCESS_MEMORY_COUNTERS pmc;
209 if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc,
sizeof(pmc))) {
210 return pmc.WorkingSetSize / 1024;
222class stability_test_server {
224 explicit stability_test_server(uint16_t port,
const std::string& ae_title =
"STABILITY_SCP")
226 , ae_title_(ae_title)
227 , test_dir_(
"stability_test_")
228 , storage_dir_(test_dir_.path() /
"archive")
229 , db_path_(test_dir_.path() /
"index.db") {
231 std::filesystem::create_directories(storage_dir_);
241 server_ = std::make_unique<dicom_server>(config);
245 file_storage_ = std::make_unique<file_storage>(fs_conf);
247 auto db_result = index_database::open(db_path_.string());
248 if (db_result.is_ok()) {
249 database_ = std::move(db_result.value());
254 server_->register_service(std::make_shared<verification_scp>());
256 auto storage_scp_ptr = std::make_shared<storage_scp>();
257 storage_scp_ptr->set_handler([
this](
259 const std::string& calling_ae,
260 const std::string& sop_class_uid,
261 const std::string& sop_instance_uid) {
262 return handle_store(dataset, calling_ae, sop_class_uid, sop_instance_uid);
264 server_->register_service(storage_scp_ptr);
270 auto result = server_->start();
271 if (result.is_ok()) {
272 std::this_thread::sleep_for(std::chrono::milliseconds{200});
282 uint16_t port()
const {
return port_; }
283 const std::string& ae_title()
const {
return ae_title_; }
285 size_t stored_count()
const {
return stored_count_.load(); }
286 size_t error_count()
const {
return error_count_.load(); }
290 std::vector<std::string> stored_instance_uids()
const {
291 std::lock_guard<std::mutex> lock(mutex_);
292 return stored_instance_uids_;
295 bool verify_consistency()
const {
296 std::lock_guard<std::mutex> lock(mutex_);
297 std::set<std::string> unique_uids(
298 stored_instance_uids_.begin(),
299 stored_instance_uids_.end());
300 return unique_uids.size() == stored_count_.load();
308 const std::string& sop_instance_uid) {
313 return storage_status::storage_error;
320 std::lock_guard<std::mutex> lock(mutex_);
321 stored_instance_uids_.push_back(sop_instance_uid);
325 return storage_status::success;
329 std::string ae_title_;
331 std::filesystem::path storage_dir_;
332 std::filesystem::path db_path_;
333 std::unique_ptr<dicom_server> server_;
334 std::unique_ptr<file_storage> file_storage_;
335 std::unique_ptr<index_database> database_;
337 std::atomic<size_t> stored_count_{0};
338 std::atomic<size_t> error_count_{0};
339 mutable std::mutex mutex_;
340 std::vector<std::string> stored_instance_uids_;
348 static std::mt19937 gen(std::random_device{}());
349 std::uniform_int_distribution<int> modality_dist(0, 3);
351 switch (modality_dist(gen)) {
366TEST_CASE(
"Continuous store/query operation",
"[stability][.slow]") {
367 auto config = stability_config::from_environment();
368 stability_metrics metrics;
372 metrics.initial_memory_kb = get_process_memory_kb();
373 metrics.peak_memory_kb = metrics.initial_memory_kb;
375 INFO(
"Starting stability test for " << config.duration.count() <<
" minutes");
378 stability_test_server server(port);
379 REQUIRE(server.initialize());
380 REQUIRE(server.start());
382 std::atomic<bool> running{
true};
383 std::vector<std::thread> workers;
386 for (
size_t i = 0; i < config.store_workers; ++i) {
387 workers.emplace_back([&, i]() {
388 auto interval = std::chrono::milliseconds{
389 static_cast<long>(1000.0 / config.store_rate * config.store_workers)};
391 while (running.load()) {
393 auto ds = generate_random_dataset();
401 ds.get_string(tags::sop_class_uid),
402 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
405 auto assoc_result = association::connect(
406 "127.0.0.1", port, assoc_config, std::chrono::seconds{30});
408 if (assoc_result.is_ok()) {
409 ++metrics.connections_opened;
411 auto& assoc = assoc_result.value();
416 ++metrics.stores_completed;
418 ++metrics.stores_failed;
421 (void)assoc.release(std::chrono::seconds{5});
422 ++metrics.connections_closed;
424 ++metrics.connection_errors;
427 ++metrics.stores_failed;
430 std::this_thread::sleep_for(interval);
436 workers.emplace_back([&]() {
437 while (running.load()) {
438 size_t current = get_process_memory_kb();
439 size_t peak = metrics.peak_memory_kb.load();
440 while (current > peak &&
441 !metrics.peak_memory_kb.compare_exchange_weak(peak, current)) {
444 std::this_thread::sleep_for(std::chrono::seconds{5});
449 std::this_thread::sleep_for(config.duration);
452 for (
auto& w : workers) {
461 metrics.report(std::cout);
464 auto report_path = std::filesystem::temp_directory_path() /
"stability_test_report.txt";
465 metrics.save_to_file(report_path);
466 INFO(
"Report saved to: " << report_path.string());
469 REQUIRE(metrics.stores_failed.load() == 0);
470 REQUIRE(metrics.connection_errors.load() == 0);
471 REQUIRE(server.error_count() == 0);
474 size_t memory_growth = (metrics.peak_memory_kb - metrics.initial_memory_kb) / 1024;
475 REQUIRE(memory_growth < config.max_memory_growth_mb);
478TEST_CASE(
"Memory stability over iterations",
"[stability][memory]") {
480 stability_test_server server(port);
481 REQUIRE(server.initialize());
482 REQUIRE(server.start());
484 size_t initial_memory = get_process_memory_kb();
485 constexpr size_t num_iterations = 100;
486 constexpr size_t max_growth_kb = 50 * 1024;
488 for (
size_t i = 0; i < num_iterations; ++i) {
489 auto ds = generate_random_dataset();
497 ds.get_string(tags::sop_class_uid),
498 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
501 auto assoc_result = association::connect(
502 "127.0.0.1", port, assoc_config, std::chrono::seconds{30});
504 REQUIRE(assoc_result.is_ok());
506 auto& assoc = assoc_result.value();
511 (void)assoc.release(std::chrono::seconds{5});
514 if ((i + 1) % 20 == 0) {
515 size_t current_memory = get_process_memory_kb();
516 size_t growth = current_memory - initial_memory;
518 INFO(
"Iteration " << (i + 1) <<
": Memory growth = "
519 << growth / 1024 <<
" MB");
521 REQUIRE(growth < max_growth_kb);
526 REQUIRE(server.stored_count() == num_iterations);
529TEST_CASE(
"Connection pool exhaustion recovery",
"[stability][network]") {
531 stability_test_server server(port);
532 REQUIRE(server.initialize());
533 REQUIRE(server.start());
535 constexpr size_t max_concurrent = 20;
536 constexpr size_t cycles = 5;
538 for (
size_t cycle = 0; cycle < cycles; ++cycle) {
539 INFO(
"Cycle " << (cycle + 1) <<
" of " << cycles);
541 std::vector<association> associations;
542 associations.reserve(max_concurrent);
545 for (
size_t i = 0; i < max_concurrent; ++i) {
550 assoc_config.
proposed_contexts.push_back({1,
"1.2.840.10008.1.1", {
"1.2.840.10008.1.2"}});
552 auto assoc_result = association::connect(
553 "127.0.0.1", port, assoc_config, std::chrono::seconds{30});
555 REQUIRE(assoc_result.is_ok());
556 associations.push_back(std::move(assoc_result.value()));
560 for (
auto& assoc : associations) {
561 (void)assoc.release(std::chrono::seconds{5});
563 associations.clear();
566 std::this_thread::sleep_for(std::chrono::milliseconds{500});
571 verify_config.called_ae_title = server.ae_title();
572 verify_config.implementation_class_uid =
"1.2.826.0.1.3680043.9.9999.12";
573 verify_config.proposed_contexts.push_back({1,
"1.2.840.10008.1.1", {
"1.2.840.10008.1.2"}});
575 auto test_result = association::connect(
576 "127.0.0.1", port, verify_config, std::chrono::seconds{30});
578 REQUIRE(test_result.is_ok());
579 (void)test_result.value().release(std::chrono::seconds{5});
585TEST_CASE(
"Database integrity under concurrent load",
"[stability][database]") {
587 stability_test_server server(port);
588 REQUIRE(server.initialize());
589 REQUIRE(server.start());
591 constexpr size_t num_workers = 4;
592 constexpr size_t images_per_worker = 25;
593 constexpr size_t total_images = num_workers * images_per_worker;
596 std::atomic<size_t> failed{0};
597 std::vector<std::thread> workers;
598 std::vector<std::string> all_instance_uids;
599 std::mutex uid_mutex;
601 for (
size_t w = 0; w < num_workers; ++w) {
602 workers.emplace_back([&, w]() {
603 for (
size_t i = 0; i < images_per_worker; ++i) {
604 auto ds = generate_random_dataset();
605 std::string instance_uid = ds.get_string(tags::sop_instance_uid);
608 std::lock_guard<std::mutex> lock(uid_mutex);
609 all_instance_uids.push_back(instance_uid);
618 ds.get_string(tags::sop_class_uid),
619 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
622 auto assoc_result = association::connect(
623 "127.0.0.1", port, assoc_config, std::chrono::seconds{30});
625 if (assoc_result.is_ok()) {
626 auto& assoc = assoc_result.value();
636 (void)assoc.release(std::chrono::seconds{5});
644 for (
auto& w : workers) {
651 REQUIRE(
completed.load() == total_images);
652 REQUIRE(failed.load() == 0);
653 REQUIRE(server.stored_count() == total_images);
656 std::set<std::string> unique_uids(all_instance_uids.begin(), all_instance_uids.end());
657 REQUIRE(unique_uids.size() == total_images);
660 REQUIRE(server.verify_consistency());
663TEST_CASE(
"Short stability smoke test",
"[stability][smoke]") {
665 auto config = stability_config::from_environment();
666 config.duration = std::chrono::minutes{0};
668 stability_metrics metrics;
670 metrics.initial_memory_kb = get_process_memory_kb();
673 stability_test_server server(port);
674 REQUIRE(server.initialize());
675 REQUIRE(server.start());
677 std::atomic<bool> running{
true};
678 std::thread store_worker([&]() {
679 while (running.load()) {
688 ds.get_string(tags::sop_class_uid),
689 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
692 auto assoc_result = association::connect(
693 "127.0.0.1", port, assoc_config, std::chrono::seconds{10});
695 if (assoc_result.is_ok()) {
696 auto& assoc = assoc_result.value();
698 if (scu.
store(assoc, ds).is_ok()) {
699 ++metrics.stores_completed;
701 ++metrics.stores_failed;
703 (void)assoc.release(std::chrono::seconds{5});
705 ++metrics.connection_errors;
708 std::this_thread::sleep_for(std::chrono::milliseconds{100});
713 std::this_thread::sleep_for(std::chrono::seconds{10});
720 REQUIRE(metrics.stores_completed.load() > 0);
721 REQUIRE(metrics.stores_failed.load() == 0);
722 REQUIRE(metrics.connection_errors.load() == 0);
724 INFO(
"Smoke test completed: " << metrics.stores_completed.load() <<
" stores in 10 seconds");
static core::dicom_dataset mr(const std::string &study_uid="")
Generate an MR Image dataset.
static core::dicom_dataset ct(const std::string &study_uid="")
Generate a CT Image dataset.
static core::dicom_dataset us(const std::string &study_uid="")
Generate a single-frame US Image dataset.
static core::dicom_dataset xa(const std::string &study_uid="")
Generate a single-frame XA Image dataset.
RAII wrapper for temporary test directory.
network::Result< store_result > store(network::association &assoc, const core::dicom_dataset &dataset)
Store a single DICOM dataset.
DIMSE message encoding and decoding.
Filesystem-based DICOM storage with hierarchical organization.
PACS index database for metadata storage and retrieval.
uint16_t find_available_port(uint16_t start=default_test_port, int max_attempts=200)
Find an available port for testing.
TEST_CASE("test_data_generator::ct generates valid CT dataset", "[data_generator][ct]")
storage_status
Storage operation status codes.
@ completed
Procedure completed successfully.
DICOM Query SCP service (C-FIND handler)
DICOM Storage SCP service (C-STORE handler)
DICOM Storage SCU service (C-STORE sender)
Configuration for SCU association request.
std::string called_ae_title
Remote AE Title (16 chars max)
std::string calling_ae_title
Our AE Title (16 chars max)
std::string implementation_class_uid
std::vector< proposed_presentation_context > proposed_contexts
size_t max_associations
Maximum concurrent associations (0 = unlimited)
std::chrono::seconds idle_timeout
Idle timeout for associations (0 = no timeout)
std::string implementation_version_name
Implementation Version Name.
uint16_t port
Port to listen on (default: 11112, standard alternate DICOM port)
std::string ae_title
Application Entity Title for this server (16 chars max)
std::string implementation_class_uid
Implementation Class UID.
Result of a C-STORE operation.
Configuration for file_storage.
std::filesystem::path root_path
Root directory for storage.
Comprehensive DICOM test data generators for integration testing.
Common test fixtures and utilities for integration tests.
DICOM Verification SCP service (C-ECHO handler)