From d3b7a96836e3125dc82cdac07028110794735f81 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Tue, 25 Aug 2026 12:43:58 -0300 Subject: [PATCH 1/7] Add process self-inspection primitives to `src/lang/process` Signed-off-by: Juan Cruz Viotti --- config.cmake.in | 2 + src/lang/process/CMakeLists.txt | 9 +- .../process/include/sourcemeta/core/process.h | 1 + .../include/sourcemeta/core/process_usage.h | 90 ++++ src/lang/process/usage.cc | 486 ++++++++++++++++++ test/process/CMakeLists.txt | 9 +- test/process/process_descriptors_test.cc | 8 + test/process/process_descriptors_test_unix.cc | 33 ++ .../process_descriptors_test_windows.cc | 7 + test/process/process_start_time_test.cc | 31 ++ test/process/process_usage_test.cc | 46 ++ test/process/process_usage_test_unix.cc | 7 + 12 files changed, 725 insertions(+), 4 deletions(-) create mode 100644 src/lang/process/include/sourcemeta/core/process_usage.h create mode 100644 src/lang/process/usage.cc create mode 100644 test/process/process_descriptors_test.cc create mode 100644 test/process/process_descriptors_test_unix.cc create mode 100644 test/process/process_descriptors_test_windows.cc create mode 100644 test/process/process_start_time_test.cc create mode 100644 test/process/process_usage_test.cc create mode 100644 test/process/process_usage_test_unix.cc diff --git a/config.cmake.in b/config.cmake.in index 81cf401dd..aa12b9892 100644 --- a/config.cmake.in +++ b/config.cmake.in @@ -59,6 +59,8 @@ foreach(component ${SOURCEMETA_CORE_COMPONENTS}) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_io.cmake") elseif(component STREQUAL "process") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_numeric.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_process.cmake") elseif(component STREQUAL "parallel") find_dependency(Threads) diff --git a/src/lang/process/CMakeLists.txt b/src/lang/process/CMakeLists.txt index 58ea5c1e0..f1b82d7e6 100644 --- a/src/lang/process/CMakeLists.txt +++ b/src/lang/process/CMakeLists.txt @@ -1,9 +1,14 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME process - PRIVATE_HEADERS error.h - SOURCES spawn.cc command_line.h) + PRIVATE_HEADERS error.h usage.h + SOURCES spawn.cc usage.cc command_line.h) if(SOURCEMETA_CORE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME process) endif() target_link_libraries(sourcemeta_core_process PRIVATE sourcemeta::core::text) +target_link_libraries(sourcemeta_core_process PRIVATE sourcemeta::core::numeric) + +if(WIN32) + target_link_libraries(sourcemeta_core_process PRIVATE psapi) +endif() diff --git a/src/lang/process/include/sourcemeta/core/process.h b/src/lang/process/include/sourcemeta/core/process.h index ac7935928..64cf2c8d0 100644 --- a/src/lang/process/include/sourcemeta/core/process.h +++ b/src/lang/process/include/sourcemeta/core/process.h @@ -7,6 +7,7 @@ // NOLINTBEGIN(misc-include-cleaner) #include +#include // NOLINTEND(misc-include-cleaner) #include // std::filesystem diff --git a/src/lang/process/include/sourcemeta/core/process_usage.h b/src/lang/process/include/sourcemeta/core/process_usage.h new file mode 100644 index 000000000..b464556df --- /dev/null +++ b/src/lang/process/include/sourcemeta/core/process_usage.h @@ -0,0 +1,90 @@ +#ifndef SOURCEMETA_CORE_PROCESS_USAGE_H_ +#define SOURCEMETA_CORE_PROCESS_USAGE_H_ + +#ifndef SOURCEMETA_CORE_PROCESS_EXPORT +#include +#endif + +#include // std::chrono::system_clock +#include // std::uint64_t +#include // std::optional + +namespace sourcemeta::core { + +/// @ingroup process +/// What the running process has consumed so far. +/// +/// Every member carries no value where the platform cannot cheaply answer, so +/// that a caller reports nothing rather than a zero it cannot tell apart from +/// a measurement. +struct ProcessUsage { + /// Combined user and system processor time, in seconds + std::optional cpu_seconds{std::nullopt}; + /// Physical memory currently held, in bytes + std::optional resident_bytes{std::nullopt}; + /// Address space currently mapped, in bytes + std::optional virtual_bytes{std::nullopt}; +}; + +/// @ingroup process +/// What the running process currently holds open, and how much it may. +/// +/// Every member carries no value where the platform cannot cheaply answer, so +/// that a caller reports nothing rather than a zero it cannot tell apart from +/// a measurement. +struct ProcessDescriptors { + /// Open file descriptors, or open handles on platforms that count those + std::optional open{std::nullopt}; + /// The ceiling the platform enforces, where there is one and it is finite + std::optional maximum{std::nullopt}; +}; + +/// @ingroup process +/// +/// Read what the running process has consumed so far. +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto usage{sourcemeta::core::process_usage()}; +/// assert(usage.cpu_seconds.value() >= 0.0); +/// ``` +SOURCEMETA_CORE_PROCESS_EXPORT +auto process_usage() noexcept -> ProcessUsage; + +/// @ingroup process +/// +/// Read what the running process currently holds open. This costs a directory +/// scan on some platforms, so it is not read along with anything else. +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto descriptors{sourcemeta::core::process_descriptors()}; +/// assert(descriptors.open.value() > 0); +/// ``` +SOURCEMETA_CORE_PROCESS_EXPORT +auto process_descriptors() noexcept -> ProcessDescriptors; + +/// @ingroup process +/// +/// Read when the running process began. The answer cannot change, so read it +/// once rather than on every sample. +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto started{sourcemeta::core::process_start_time()}; +/// assert(started.value() <= std::chrono::system_clock::now()); +/// ``` +SOURCEMETA_CORE_PROCESS_EXPORT +auto process_start_time() noexcept + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/src/lang/process/usage.cc b/src/lang/process/usage.cc new file mode 100644 index 000000000..f02ba817c --- /dev/null +++ b/src/lang/process/usage.cc @@ -0,0 +1,486 @@ +#include + +#include // std::chrono::duration, std::chrono::duration_cast, std::chrono::system_clock +#include // std::uint64_t +#include // std::optional + +#if defined(_WIN32) && !defined(__MSYS__) && !defined(__CYGWIN__) && \ + !defined(__MINGW32__) && !defined(__MINGW64__) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include // GetCurrentProcess, GetProcessTimes, GetProcessHandleCount, FILETIME, ULARGE_INTEGER, DWORD + +// Declared in terms of what the header above brings in, so it stays in a block +// of its own rather than being sorted ahead of it +#include // GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS +#elif defined(__APPLE__) +#include // std::size_t +#include // std::malloc, std::free + +#include // proc_pidinfo, proc_bsdinfo, PROC_PIDLISTFDS, PROC_PIDLISTFD_SIZE, PROC_PIDTBSDINFO, PROC_PIDTBSDINFO_SIZE +#include // mach_task_self, task_info, task_info_t, mach_task_basic_info, mach_msg_type_number_t, MACH_TASK_BASIC_INFO, MACH_TASK_BASIC_INFO_COUNT, KERN_SUCCESS +#include // getrusage, getrlimit, rusage, rlimit, RUSAGE_SELF, RLIMIT_NOFILE, RLIM_INFINITY +#include // getpid +#elif defined(__linux__) +#include + +#include // std::array +#include // EINTR, errno +#include // std::size_t +#include // std::string_view + +#include // opendir, readdir, closedir, DIR, dirent +#include // open, O_RDONLY, O_CLOEXEC +#include // getrlimit, rlimit, RLIMIT_NOFILE, RLIM_INFINITY +#include // read, close, sysconf, _SC_CLK_TCK, _SC_PAGESIZE +#endif + +#if defined(_WIN32) && !defined(__MSYS__) && !defined(__CYGWIN__) && \ + !defined(__MINGW32__) && !defined(__MINGW64__) + +namespace { + +// The platform counts time in hundreds of nanoseconds +constexpr double UNITS_PER_SECOND{10000000.0}; + +// The platform counts from the start of the year 1601, which is this many of +// its own units before the Unix epoch +constexpr std::uint64_t EPOCH_OFFSET_UNITS{11644473600ULL * 10000000ULL}; + +auto to_units(const FILETIME &value) noexcept -> std::uint64_t { + ULARGE_INTEGER converted{}; + converted.LowPart = value.dwLowDateTime; + converted.HighPart = value.dwHighDateTime; + return converted.QuadPart; +} + +} // namespace + +namespace sourcemeta::core { + +auto process_usage() noexcept -> ProcessUsage { + ProcessUsage result; + + FILETIME creation{}; + FILETIME termination{}; + FILETIME kernel{}; + FILETIME user{}; + if (GetProcessTimes(GetCurrentProcess(), &creation, &termination, &kernel, + &user) != 0) { + result.cpu_seconds = + static_cast(to_units(kernel) + to_units(user)) / + UNITS_PER_SECOND; + } + + PROCESS_MEMORY_COUNTERS counters{}; + counters.cb = sizeof(counters); + if (GetProcessMemoryInfo(GetCurrentProcess(), &counters, sizeof(counters)) != + 0) { + result.resident_bytes = counters.WorkingSetSize; + result.virtual_bytes = counters.PagefileUsage; + } + + return result; +} + +auto process_descriptors() noexcept -> ProcessDescriptors { + ProcessDescriptors result; + + DWORD handles{0}; + if (GetProcessHandleCount(GetCurrentProcess(), &handles) != 0) { + result.open = handles; + } + + // The platform enforces no per-process ceiling comparable to the POSIX one, + // so there is nothing true to say about the maximum + return result; +} + +auto process_start_time() noexcept + -> std::optional { + FILETIME creation{}; + FILETIME termination{}; + FILETIME kernel{}; + FILETIME user{}; + if (GetProcessTimes(GetCurrentProcess(), &creation, &termination, &kernel, + &user) == 0) { + return std::nullopt; + } + + const auto units{to_units(creation)}; + if (units < EPOCH_OFFSET_UNITS) { + return std::nullopt; + } + + const std::chrono::duration since_epoch{ + static_cast(units - EPOCH_OFFSET_UNITS) / UNITS_PER_SECOND}; + return std::chrono::system_clock::time_point{ + std::chrono::duration_cast( + since_epoch)}; +} + +} // namespace sourcemeta::core + +#elif defined(__APPLE__) + +namespace { + +auto descriptor_ceiling() noexcept -> std::optional { + rlimit limit{}; + if (getrlimit(RLIMIT_NOFILE, &limit) != 0 || + limit.rlim_cur == RLIM_INFINITY) { + return std::nullopt; + } + + return static_cast(limit.rlim_cur); +} + +} // namespace + +namespace sourcemeta::core { + +auto process_usage() noexcept -> ProcessUsage { + ProcessUsage result; + + rusage consumed{}; + if (getrusage(RUSAGE_SELF, &consumed) == 0) { + result.cpu_seconds = + static_cast(consumed.ru_utime.tv_sec) + + static_cast(consumed.ru_utime.tv_usec) / 1000000.0 + + static_cast(consumed.ru_stime.tv_sec) + + static_cast(consumed.ru_stime.tv_usec) / 1000000.0; + } + + mach_task_basic_info information{}; + mach_msg_type_number_t count{MACH_TASK_BASIC_INFO_COUNT}; + if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, + reinterpret_cast(&information), + &count) == KERN_SUCCESS) { + result.resident_bytes = information.resident_size; + result.virtual_bytes = information.virtual_size; + } + + return result; +} + +auto process_descriptors() noexcept -> ProcessDescriptors { + ProcessDescriptors result; + + result.maximum = descriptor_ceiling(); + + // Asking for no bytes answers with room for the highest descriptor in use + // plus a margin, which is not a count of what is open, so the listing has to + // be taken and measured by what it actually fills. Taking it opens nothing, + // so there is no entry of its own to discount + const auto capacity{proc_pidinfo(getpid(), PROC_PIDLISTFDS, 0, nullptr, 0)}; + if (capacity <= 0) { + return result; + } + + // The only platform where anything here has to reach for the heap, and it + // is done without the standard containers so that running out of memory + // stays a returned absence rather than an exception this cannot throw + auto *listing{std::malloc(static_cast(capacity))}; + if (listing == nullptr) { + return result; + } + + const auto written{ + proc_pidinfo(getpid(), PROC_PIDLISTFDS, 0, listing, capacity)}; + std::free(listing); + if (written > 0) { + result.open = static_cast(written) / PROC_PIDLISTFD_SIZE; + } + + return result; +} + +auto process_start_time() noexcept + -> std::optional { + proc_bsdinfo information{}; + if (proc_pidinfo(getpid(), PROC_PIDTBSDINFO, 0, &information, + PROC_PIDTBSDINFO_SIZE) != PROC_PIDTBSDINFO_SIZE) { + return std::nullopt; + } + + const std::chrono::duration since_epoch{ + static_cast(information.pbi_start_tvsec) + + static_cast(information.pbi_start_tvusec) / 1000000.0}; + return std::chrono::system_clock::time_point{ + std::chrono::duration_cast( + since_epoch)}; +} + +} // namespace sourcemeta::core + +#elif defined(__linux__) + +namespace { + +// Bounded by what the kernel writes, which is a handful of fields wider than +// the last one anything here reads +constexpr std::size_t STAT_BUFFER_SIZE{1024}; + +// One past the highest index this reads, being the resident set size +constexpr std::size_t STAT_FIELD_COUNT{22}; + +// As wide as an unsigned 64-bit value ever spells out to +constexpr std::size_t DIGIT_BUFFER_SIZE{20}; + +auto read_bounded(const char *path, char *buffer, + const std::size_t size) noexcept + -> std::optional { + const auto descriptor{open(path, O_RDONLY | O_CLOEXEC)}; + if (descriptor < 0) { + return std::nullopt; + } + + std::size_t total{0}; + while (total < size) { + const auto count{read(descriptor, buffer + total, size - total)}; + if (count < 0) { + if (errno == EINTR) { + continue; + } + + close(descriptor); + return std::nullopt; + } + + if (count == 0) { + break; + } + + total += static_cast(count); + } + + close(descriptor); + return std::string_view{buffer, total}; +} + +// The second field is a command name in parentheses that may itself contain +// spaces, so what follows it is found from the last parenthesis rather than by +// counting separators from the beginning. See proc(5) +auto stat_fields(const std::string_view line) noexcept + -> std::array { + std::array result{}; + const auto command{line.rfind(')')}; + if (command == std::string_view::npos) { + return result; + } + + auto cursor{command + 1}; + std::size_t index{0}; + while (cursor < line.size() && index < result.size()) { + while (cursor < line.size() && line[cursor] == ' ') { + cursor += 1; + } + + const auto start{cursor}; + while (cursor < line.size() && line[cursor] != ' ' && + line[cursor] != '\n') { + cursor += 1; + } + + if (cursor == start) { + break; + } + + result[index] = line.substr(start, cursor - start); + index += 1; + } + + return result; +} + +// The file is too wide to hold in a bounded buffer on a machine with many +// interrupt sources, so the boot time is picked out of it as it streams past +auto boot_time_seconds() noexcept -> std::optional { + static constexpr std::string_view PREFIX{"btime "}; + const auto descriptor{open("/proc/stat", O_RDONLY | O_CLOEXEC)}; + if (descriptor < 0) { + return std::nullopt; + } + + std::array buffer{}; + std::array digits{}; + std::size_t length{0}; + std::size_t matched{0}; + bool skipping{false}; + bool collecting{false}; + bool done{false}; + + while (!done) { + const auto count{read(descriptor, buffer.data(), buffer.size())}; + if (count < 0) { + if (errno == EINTR) { + continue; + } + + close(descriptor); + return std::nullopt; + } + + if (count == 0) { + break; + } + + for (std::size_t index = 0; index < static_cast(count); + index++) { + const auto character{buffer[index]}; + if (collecting) { + if (character < '0' || character > '9' || length == digits.size()) { + done = true; + break; + } + + digits[length] = character; + length += 1; + } else if (skipping) { + skipping = character != '\n'; + } else if (character == '\n') { + matched = 0; + } else if (character == PREFIX[matched]) { + matched += 1; + collecting = matched == PREFIX.size(); + } else { + skipping = true; + matched = 0; + } + } + } + + close(descriptor); + if (length == 0) { + return std::nullopt; + } + + return sourcemeta::core::to_uint64_t(std::string_view{digits.data(), length}); +} + +auto descriptor_ceiling() noexcept -> std::optional { + rlimit limit{}; + if (getrlimit(RLIMIT_NOFILE, &limit) != 0 || + limit.rlim_cur == RLIM_INFINITY) { + return std::nullopt; + } + + return static_cast(limit.rlim_cur); +} + +} // namespace + +namespace sourcemeta::core { + +auto process_usage() noexcept -> ProcessUsage { + ProcessUsage result; + + std::array buffer{}; + const auto line{ + read_bounded("/proc/self/stat", buffer.data(), buffer.size())}; + if (!line.has_value()) { + return result; + } + + const auto fields{stat_fields(line.value())}; + + const auto ticks{sysconf(_SC_CLK_TCK)}; + const auto user{to_uint64_t(fields[11])}; + const auto system{to_uint64_t(fields[12])}; + if (ticks > 0 && user.has_value() && system.has_value()) { + result.cpu_seconds = static_cast(user.value() + system.value()) / + static_cast(ticks); + } + + result.virtual_bytes = to_uint64_t(fields[20]); + + const auto pages{to_uint64_t(fields[21])}; + const auto page_size{sysconf(_SC_PAGESIZE)}; + if (pages.has_value() && page_size > 0) { + result.resident_bytes = + pages.value() * static_cast(page_size); + } + + return result; +} + +auto process_descriptors() noexcept -> ProcessDescriptors { + ProcessDescriptors result; + + DIR *listing{opendir("/proc/self/fd")}; + if (listing != nullptr) { + std::uint64_t entries{0}; + for (const dirent *entry{readdir(listing)}; entry != nullptr; + entry = readdir(listing)) { + const std::string_view name{entry->d_name}; + if (name != "." && name != "..") { + entries += 1; + } + } + + closedir(listing); + // Reading the list takes a descriptor of its own, which the list then + // includes, so what is counted is one more than what was open + result.open = entries > 0 ? entries - 1 : 0; + } + + result.maximum = descriptor_ceiling(); + return result; +} + +auto process_start_time() noexcept + -> std::optional { + const auto boot{boot_time_seconds()}; + if (!boot.has_value()) { + return std::nullopt; + } + + const auto ticks{sysconf(_SC_CLK_TCK)}; + if (ticks <= 0) { + return std::nullopt; + } + + std::array buffer{}; + const auto line{ + read_bounded("/proc/self/stat", buffer.data(), buffer.size())}; + if (!line.has_value()) { + return std::nullopt; + } + + const auto fields{stat_fields(line.value())}; + const auto started{to_uint64_t(fields[19])}; + if (!started.has_value()) { + return std::nullopt; + } + + const std::chrono::duration since_epoch{ + static_cast(boot.value()) + + static_cast(started.value()) / static_cast(ticks)}; + return std::chrono::system_clock::time_point{ + std::chrono::duration_cast( + since_epoch)}; +} + +} // namespace sourcemeta::core + +#else + +namespace sourcemeta::core { + +auto process_usage() noexcept -> ProcessUsage { return {}; } + +auto process_descriptors() noexcept -> ProcessDescriptors { return {}; } + +auto process_start_time() noexcept + -> std::optional { + return std::nullopt; +} + +} // namespace sourcemeta::core + +#endif diff --git a/test/process/CMakeLists.txt b/test/process/CMakeLists.txt index 335280a3e..776b68161 100644 --- a/test/process/CMakeLists.txt +++ b/test/process/CMakeLists.txt @@ -5,14 +5,19 @@ if(WIN32) "$") sourcemeta_test(NAMESPACE sourcemeta PROJECT core NAME process SOURCES process_error_test.cc process_spawn_test_windows.cc - process_spawn_and_capture_test.cc process_spawn_input_test.cc) + process_spawn_and_capture_test.cc process_spawn_input_test.cc + process_usage_test.cc process_descriptors_test.cc + process_descriptors_test_windows.cc process_start_time_test.cc) else() add_test(NAME core.process.spawn.e2e COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/process_spawn_test.sh" "$") sourcemeta_test(NAMESPACE sourcemeta PROJECT core NAME process SOURCES process_error_test.cc process_spawn_test_unix.cc - process_spawn_and_capture_test.cc process_spawn_input_test.cc) + process_spawn_and_capture_test.cc process_spawn_input_test.cc + process_usage_test.cc process_usage_test_unix.cc + process_descriptors_test.cc process_descriptors_test_unix.cc + process_start_time_test.cc) endif() target_link_libraries(sourcemeta_core_process_unit PRIVATE sourcemeta::core::process) diff --git a/test/process/process_descriptors_test.cc b/test/process/process_descriptors_test.cc new file mode 100644 index 000000000..6dfda7bd2 --- /dev/null +++ b/test/process/process_descriptors_test.cc @@ -0,0 +1,8 @@ +#include +#include + +TEST(what_is_open_is_reported) { + const auto descriptors{sourcemeta::core::process_descriptors()}; + EXPECT_TRUE(descriptors.open.has_value()); + EXPECT_GT(descriptors.open.value(), 0U); +} diff --git a/test/process/process_descriptors_test_unix.cc b/test/process/process_descriptors_test_unix.cc new file mode 100644 index 000000000..42a706805 --- /dev/null +++ b/test/process/process_descriptors_test_unix.cc @@ -0,0 +1,33 @@ +#include +#include + +#include // open, O_RDONLY +#include // close + +TEST(the_maximum_is_reported_and_finite) { + const auto descriptors{sourcemeta::core::process_descriptors()}; + EXPECT_TRUE(descriptors.maximum.has_value()); + EXPECT_GT(descriptors.maximum.value(), 0U); +} + +TEST(what_is_open_stays_within_the_maximum) { + const auto descriptors{sourcemeta::core::process_descriptors()}; + EXPECT_LE(descriptors.open.value(), descriptors.maximum.value()); +} + +TEST(what_is_open_grows_by_exactly_what_was_opened) { + const auto before{sourcemeta::core::process_descriptors()}; + const auto first{open("/dev/null", O_RDONLY)}; + const auto second{open("/dev/null", O_RDONLY)}; + const auto third{open("/dev/null", O_RDONLY)}; + const auto after{sourcemeta::core::process_descriptors()}; + close(first); + close(second); + close(third); + const auto restored{sourcemeta::core::process_descriptors()}; + EXPECT_GE(first, 0); + EXPECT_GE(second, 0); + EXPECT_GE(third, 0); + EXPECT_EQ(after.open.value(), before.open.value() + 3); + EXPECT_EQ(restored.open.value(), before.open.value()); +} diff --git a/test/process/process_descriptors_test_windows.cc b/test/process/process_descriptors_test_windows.cc new file mode 100644 index 000000000..a75e78484 --- /dev/null +++ b/test/process/process_descriptors_test_windows.cc @@ -0,0 +1,7 @@ +#include +#include + +TEST(the_maximum_is_absent_where_the_platform_enforces_none) { + const auto descriptors{sourcemeta::core::process_descriptors()}; + EXPECT_FALSE(descriptors.maximum.has_value()); +} diff --git a/test/process/process_start_time_test.cc b/test/process/process_start_time_test.cc new file mode 100644 index 000000000..fd123f450 --- /dev/null +++ b/test/process/process_start_time_test.cc @@ -0,0 +1,31 @@ +#include +#include + +#include // std::chrono::hours, std::chrono::system_clock + +TEST(the_start_time_is_reported) { + const auto started{sourcemeta::core::process_start_time()}; + EXPECT_TRUE(started.has_value()); +} + +TEST(the_start_time_is_the_same_on_every_call) { + const auto first{sourcemeta::core::process_start_time()}; + const auto second{sourcemeta::core::process_start_time()}; + EXPECT_EQ(first.value(), second.value()); +} + +TEST(the_start_time_is_after_the_unix_epoch) { + const auto started{sourcemeta::core::process_start_time()}; + EXPECT_GT(started.value(), std::chrono::system_clock::from_time_t(0)); +} + +TEST(the_start_time_is_not_in_the_future) { + const auto started{sourcemeta::core::process_start_time()}; + EXPECT_LE(started.value(), std::chrono::system_clock::now()); +} + +TEST(the_start_time_is_within_the_hour_this_test_began) { + const auto started{sourcemeta::core::process_start_time()}; + EXPECT_GT(started.value(), + std::chrono::system_clock::now() - std::chrono::hours{1}); +} diff --git a/test/process/process_usage_test.cc b/test/process/process_usage_test.cc new file mode 100644 index 000000000..c37f93ae5 --- /dev/null +++ b/test/process/process_usage_test.cc @@ -0,0 +1,46 @@ +#include +#include + +#include // std::chrono::milliseconds, std::chrono::steady_clock +#include // std::size_t + +// Burn enough processor time that the coarsest counter any supported platform +// keeps, which advances every ten milliseconds, cannot fail to have moved +static auto burn_processor_time() -> double { + const auto deadline{std::chrono::steady_clock::now() + + std::chrono::milliseconds{200}}; + double total{0}; + std::size_t index{0}; + while (std::chrono::steady_clock::now() < deadline) { + total += static_cast(index); + index += 1; + } + + return total; +} + +TEST(processor_time_is_reported) { + const auto usage{sourcemeta::core::process_usage()}; + EXPECT_TRUE(usage.cpu_seconds.has_value()); + EXPECT_GE(usage.cpu_seconds.value(), 0.0); +} + +TEST(processor_time_advances_while_the_processor_is_busy) { + const auto before{sourcemeta::core::process_usage()}; + const auto burned{burn_processor_time()}; + const auto after{sourcemeta::core::process_usage()}; + EXPECT_GT(burned, 0.0); + EXPECT_GT(after.cpu_seconds.value(), before.cpu_seconds.value()); +} + +TEST(resident_memory_is_reported) { + const auto usage{sourcemeta::core::process_usage()}; + EXPECT_TRUE(usage.resident_bytes.has_value()); + EXPECT_GT(usage.resident_bytes.value(), 0U); +} + +TEST(virtual_memory_is_reported) { + const auto usage{sourcemeta::core::process_usage()}; + EXPECT_TRUE(usage.virtual_bytes.has_value()); + EXPECT_GT(usage.virtual_bytes.value(), 0U); +} diff --git a/test/process/process_usage_test_unix.cc b/test/process/process_usage_test_unix.cc new file mode 100644 index 000000000..e828ae80d --- /dev/null +++ b/test/process/process_usage_test_unix.cc @@ -0,0 +1,7 @@ +#include +#include + +TEST(virtual_memory_covers_at_least_the_resident_set) { + const auto usage{sourcemeta::core::process_usage()}; + EXPECT_GE(usage.virtual_bytes.value(), usage.resident_bytes.value()); +} From 454200729dcfc0a87ad8bdf5e25babd701ef37e3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Tue, 25 Aug 2026 12:48:11 -0300 Subject: [PATCH 2/7] Simpler Signed-off-by: Juan Cruz Viotti --- .../include/sourcemeta/core/process_usage.h | 8 ++-- test/process/CMakeLists.txt | 5 +-- test/process/process_descriptors_test.cc | 43 +++++++++++++++++++ test/process/process_descriptors_test_unix.cc | 33 -------------- .../process_descriptors_test_windows.cc | 7 --- test/process/process_usage_test.cc | 9 ++++ test/process/process_usage_test_unix.cc | 7 --- 7 files changed, 58 insertions(+), 54 deletions(-) delete mode 100644 test/process/process_descriptors_test_unix.cc delete mode 100644 test/process/process_descriptors_test_windows.cc delete mode 100644 test/process/process_usage_test_unix.cc diff --git a/src/lang/process/include/sourcemeta/core/process_usage.h b/src/lang/process/include/sourcemeta/core/process_usage.h index b464556df..733fddf72 100644 --- a/src/lang/process/include/sourcemeta/core/process_usage.h +++ b/src/lang/process/include/sourcemeta/core/process_usage.h @@ -41,7 +41,7 @@ struct ProcessDescriptors { /// @ingroup process /// -/// Read what the running process has consumed so far. +/// Read what the running process has consumed so far. For example: /// /// ```cpp /// #include @@ -56,7 +56,7 @@ auto process_usage() noexcept -> ProcessUsage; /// @ingroup process /// /// Read what the running process currently holds open. This costs a directory -/// scan on some platforms, so it is not read along with anything else. +/// scan on some platforms. For example: /// /// ```cpp /// #include @@ -70,8 +70,8 @@ auto process_descriptors() noexcept -> ProcessDescriptors; /// @ingroup process /// -/// Read when the running process began. The answer cannot change, so read it -/// once rather than on every sample. +/// Read when the running process began. The answer does not change. For +/// example: /// /// ```cpp /// #include diff --git a/test/process/CMakeLists.txt b/test/process/CMakeLists.txt index 776b68161..d3af144b3 100644 --- a/test/process/CMakeLists.txt +++ b/test/process/CMakeLists.txt @@ -7,7 +7,7 @@ if(WIN32) SOURCES process_error_test.cc process_spawn_test_windows.cc process_spawn_and_capture_test.cc process_spawn_input_test.cc process_usage_test.cc process_descriptors_test.cc - process_descriptors_test_windows.cc process_start_time_test.cc) + process_start_time_test.cc) else() add_test(NAME core.process.spawn.e2e COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/process_spawn_test.sh" @@ -15,8 +15,7 @@ else() sourcemeta_test(NAMESPACE sourcemeta PROJECT core NAME process SOURCES process_error_test.cc process_spawn_test_unix.cc process_spawn_and_capture_test.cc process_spawn_input_test.cc - process_usage_test.cc process_usage_test_unix.cc - process_descriptors_test.cc process_descriptors_test_unix.cc + process_usage_test.cc process_descriptors_test.cc process_start_time_test.cc) endif() diff --git a/test/process/process_descriptors_test.cc b/test/process/process_descriptors_test.cc index 6dfda7bd2..7defc571c 100644 --- a/test/process/process_descriptors_test.cc +++ b/test/process/process_descriptors_test.cc @@ -1,8 +1,51 @@ #include #include +#if !defined(_WIN32) +#include // open, O_RDONLY +#include // close +#endif + TEST(what_is_open_is_reported) { const auto descriptors{sourcemeta::core::process_descriptors()}; EXPECT_TRUE(descriptors.open.has_value()); EXPECT_GT(descriptors.open.value(), 0U); } + +// Windows enforces no per-process handle ceiling comparable to RLIMIT_NOFILE +#if defined(_WIN32) +TEST(the_maximum_is_absent_where_the_platform_enforces_none) { + const auto descriptors{sourcemeta::core::process_descriptors()}; + EXPECT_FALSE(descriptors.maximum.has_value()); +} +#endif + +#if !defined(_WIN32) +TEST(the_maximum_is_reported_and_finite) { + const auto descriptors{sourcemeta::core::process_descriptors()}; + EXPECT_TRUE(descriptors.maximum.has_value()); + EXPECT_GT(descriptors.maximum.value(), 0U); +} + +TEST(what_is_open_stays_within_the_maximum) { + const auto descriptors{sourcemeta::core::process_descriptors()}; + EXPECT_LE(descriptors.open.value(), descriptors.maximum.value()); +} + +TEST(what_is_open_grows_by_exactly_what_was_opened) { + const auto before{sourcemeta::core::process_descriptors()}; + const auto first{open("/dev/null", O_RDONLY)}; + const auto second{open("/dev/null", O_RDONLY)}; + const auto third{open("/dev/null", O_RDONLY)}; + const auto after{sourcemeta::core::process_descriptors()}; + close(first); + close(second); + close(third); + const auto restored{sourcemeta::core::process_descriptors()}; + EXPECT_GE(first, 0); + EXPECT_GE(second, 0); + EXPECT_GE(third, 0); + EXPECT_EQ(after.open.value(), before.open.value() + 3); + EXPECT_EQ(restored.open.value(), before.open.value()); +} +#endif diff --git a/test/process/process_descriptors_test_unix.cc b/test/process/process_descriptors_test_unix.cc deleted file mode 100644 index 42a706805..000000000 --- a/test/process/process_descriptors_test_unix.cc +++ /dev/null @@ -1,33 +0,0 @@ -#include -#include - -#include // open, O_RDONLY -#include // close - -TEST(the_maximum_is_reported_and_finite) { - const auto descriptors{sourcemeta::core::process_descriptors()}; - EXPECT_TRUE(descriptors.maximum.has_value()); - EXPECT_GT(descriptors.maximum.value(), 0U); -} - -TEST(what_is_open_stays_within_the_maximum) { - const auto descriptors{sourcemeta::core::process_descriptors()}; - EXPECT_LE(descriptors.open.value(), descriptors.maximum.value()); -} - -TEST(what_is_open_grows_by_exactly_what_was_opened) { - const auto before{sourcemeta::core::process_descriptors()}; - const auto first{open("/dev/null", O_RDONLY)}; - const auto second{open("/dev/null", O_RDONLY)}; - const auto third{open("/dev/null", O_RDONLY)}; - const auto after{sourcemeta::core::process_descriptors()}; - close(first); - close(second); - close(third); - const auto restored{sourcemeta::core::process_descriptors()}; - EXPECT_GE(first, 0); - EXPECT_GE(second, 0); - EXPECT_GE(third, 0); - EXPECT_EQ(after.open.value(), before.open.value() + 3); - EXPECT_EQ(restored.open.value(), before.open.value()); -} diff --git a/test/process/process_descriptors_test_windows.cc b/test/process/process_descriptors_test_windows.cc deleted file mode 100644 index a75e78484..000000000 --- a/test/process/process_descriptors_test_windows.cc +++ /dev/null @@ -1,7 +0,0 @@ -#include -#include - -TEST(the_maximum_is_absent_where_the_platform_enforces_none) { - const auto descriptors{sourcemeta::core::process_descriptors()}; - EXPECT_FALSE(descriptors.maximum.has_value()); -} diff --git a/test/process/process_usage_test.cc b/test/process/process_usage_test.cc index c37f93ae5..7378e8686 100644 --- a/test/process/process_usage_test.cc +++ b/test/process/process_usage_test.cc @@ -44,3 +44,12 @@ TEST(virtual_memory_is_reported) { EXPECT_TRUE(usage.virtual_bytes.has_value()); EXPECT_GT(usage.virtual_bytes.value(), 0U); } + +// A Windows working set counts shared pages that its private commit does not, +// so there the one may legitimately exceed the other +#if !defined(_WIN32) +TEST(virtual_memory_covers_at_least_the_resident_set) { + const auto usage{sourcemeta::core::process_usage()}; + EXPECT_GE(usage.virtual_bytes.value(), usage.resident_bytes.value()); +} +#endif diff --git a/test/process/process_usage_test_unix.cc b/test/process/process_usage_test_unix.cc deleted file mode 100644 index e828ae80d..000000000 --- a/test/process/process_usage_test_unix.cc +++ /dev/null @@ -1,7 +0,0 @@ -#include -#include - -TEST(virtual_memory_covers_at_least_the_resident_set) { - const auto usage{sourcemeta::core::process_usage()}; - EXPECT_GE(usage.virtual_bytes.value(), usage.resident_bytes.value()); -} From 3e35fec0e57e885e9edb24a687b064b4c70f8a6f Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Tue, 25 Aug 2026 12:52:47 -0300 Subject: [PATCH 3/7] More Signed-off-by: Juan Cruz Viotti --- .../include/sourcemeta/core/process_usage.h | 9 +++--- src/lang/process/usage.cc | 30 +++++++++++-------- test/process/process_usage_test.cc | 8 ++--- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/lang/process/include/sourcemeta/core/process_usage.h b/src/lang/process/include/sourcemeta/core/process_usage.h index 733fddf72..2c5103cd6 100644 --- a/src/lang/process/include/sourcemeta/core/process_usage.h +++ b/src/lang/process/include/sourcemeta/core/process_usage.h @@ -5,7 +5,7 @@ #include #endif -#include // std::chrono::system_clock +#include // std::chrono::nanoseconds, std::chrono::system_clock #include // std::uint64_t #include // std::optional @@ -18,8 +18,8 @@ namespace sourcemeta::core { /// that a caller reports nothing rather than a zero it cannot tell apart from /// a measurement. struct ProcessUsage { - /// Combined user and system processor time, in seconds - std::optional cpu_seconds{std::nullopt}; + /// Combined user and system processor time + std::optional cpu_time{std::nullopt}; /// Physical memory currently held, in bytes std::optional resident_bytes{std::nullopt}; /// Address space currently mapped, in bytes @@ -45,10 +45,11 @@ struct ProcessDescriptors { /// /// ```cpp /// #include +/// #include /// #include /// /// const auto usage{sourcemeta::core::process_usage()}; -/// assert(usage.cpu_seconds.value() >= 0.0); +/// assert(usage.cpu_time.value() >= std::chrono::nanoseconds::zero()); /// ``` SOURCEMETA_CORE_PROCESS_EXPORT auto process_usage() noexcept -> ProcessUsage; diff --git a/src/lang/process/usage.cc b/src/lang/process/usage.cc index f02ba817c..115f661d2 100644 --- a/src/lang/process/usage.cc +++ b/src/lang/process/usage.cc @@ -1,7 +1,7 @@ #include -#include // std::chrono::duration, std::chrono::duration_cast, std::chrono::system_clock -#include // std::uint64_t +#include // std::chrono::duration, std::chrono::duration_cast, std::chrono::microseconds, std::chrono::nanoseconds, std::chrono::seconds, std::chrono::system_clock +#include // std::int64_t, std::uint64_t #include // std::optional #if defined(_WIN32) && !defined(__MSYS__) && !defined(__CYGWIN__) && \ @@ -47,6 +47,7 @@ namespace { // The platform counts time in hundreds of nanoseconds constexpr double UNITS_PER_SECOND{10000000.0}; +constexpr std::int64_t NANOSECONDS_PER_UNIT{100}; // The platform counts from the start of the year 1601, which is this many of // its own units before the Unix epoch @@ -72,9 +73,9 @@ auto process_usage() noexcept -> ProcessUsage { FILETIME user{}; if (GetProcessTimes(GetCurrentProcess(), &creation, &termination, &kernel, &user) != 0) { - result.cpu_seconds = - static_cast(to_units(kernel) + to_units(user)) / - UNITS_PER_SECOND; + result.cpu_time = std::chrono::nanoseconds{ + static_cast(to_units(kernel) + to_units(user)) * + NANOSECONDS_PER_UNIT}; } PROCESS_MEMORY_COUNTERS counters{}; @@ -149,11 +150,11 @@ auto process_usage() noexcept -> ProcessUsage { rusage consumed{}; if (getrusage(RUSAGE_SELF, &consumed) == 0) { - result.cpu_seconds = - static_cast(consumed.ru_utime.tv_sec) + - static_cast(consumed.ru_utime.tv_usec) / 1000000.0 + - static_cast(consumed.ru_stime.tv_sec) + - static_cast(consumed.ru_stime.tv_usec) / 1000000.0; + result.cpu_time = + std::chrono::seconds{static_cast( + consumed.ru_utime.tv_sec + consumed.ru_stime.tv_sec)} + + std::chrono::microseconds{static_cast( + consumed.ru_utime.tv_usec + consumed.ru_stime.tv_usec)}; } mach_task_basic_info information{}; @@ -393,8 +394,13 @@ auto process_usage() noexcept -> ProcessUsage { const auto user{to_uint64_t(fields[11])}; const auto system{to_uint64_t(fields[12])}; if (ticks > 0 && user.has_value() && system.has_value()) { - result.cpu_seconds = static_cast(user.value() + system.value()) / - static_cast(ticks); + // Split rather than scaled whole, so that a rate that does not divide a + // second evenly still converts exactly and nothing overflows on the way + const auto total{static_cast(user.value() + system.value())}; + const auto rate{static_cast(ticks)}; + result.cpu_time = + std::chrono::seconds{total / rate} + + std::chrono::nanoseconds{(total % rate) * 1000000000 / rate}; } result.virtual_bytes = to_uint64_t(fields[20]); diff --git a/test/process/process_usage_test.cc b/test/process/process_usage_test.cc index 7378e8686..3b21bb57d 100644 --- a/test/process/process_usage_test.cc +++ b/test/process/process_usage_test.cc @@ -1,7 +1,7 @@ #include #include -#include // std::chrono::milliseconds, std::chrono::steady_clock +#include // std::chrono::milliseconds, std::chrono::nanoseconds, std::chrono::steady_clock #include // std::size_t // Burn enough processor time that the coarsest counter any supported platform @@ -21,8 +21,8 @@ static auto burn_processor_time() -> double { TEST(processor_time_is_reported) { const auto usage{sourcemeta::core::process_usage()}; - EXPECT_TRUE(usage.cpu_seconds.has_value()); - EXPECT_GE(usage.cpu_seconds.value(), 0.0); + EXPECT_TRUE(usage.cpu_time.has_value()); + EXPECT_GE(usage.cpu_time.value(), std::chrono::nanoseconds::zero()); } TEST(processor_time_advances_while_the_processor_is_busy) { @@ -30,7 +30,7 @@ TEST(processor_time_advances_while_the_processor_is_busy) { const auto burned{burn_processor_time()}; const auto after{sourcemeta::core::process_usage()}; EXPECT_GT(burned, 0.0); - EXPECT_GT(after.cpu_seconds.value(), before.cpu_seconds.value()); + EXPECT_GT(after.cpu_time.value(), before.cpu_time.value()); } TEST(resident_memory_is_reported) { From e7ac10d13a25e30a9bee5c72061b3e45fbc99fd7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Tue, 25 Aug 2026 12:56:47 -0300 Subject: [PATCH 4/7] More Signed-off-by: Juan Cruz Viotti --- test/process/process_descriptors_test.cc | 6 ++++-- test/process/process_usage_test.cc | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test/process/process_descriptors_test.cc b/test/process/process_descriptors_test.cc index 7defc571c..e8df41b00 100644 --- a/test/process/process_descriptors_test.cc +++ b/test/process/process_descriptors_test.cc @@ -1,7 +1,7 @@ #include #include -#if !defined(_WIN32) +#if defined(__linux__) || defined(__APPLE__) #include // open, O_RDONLY #include // close #endif @@ -20,7 +20,9 @@ TEST(the_maximum_is_absent_where_the_platform_enforces_none) { } #endif -#if !defined(_WIN32) +// The two platforms that count descriptors rather than handles, and that +// enforce a ceiling on them +#if defined(__linux__) || defined(__APPLE__) TEST(the_maximum_is_reported_and_finite) { const auto descriptors{sourcemeta::core::process_descriptors()}; EXPECT_TRUE(descriptors.maximum.has_value()); diff --git a/test/process/process_usage_test.cc b/test/process/process_usage_test.cc index 3b21bb57d..78742f658 100644 --- a/test/process/process_usage_test.cc +++ b/test/process/process_usage_test.cc @@ -45,9 +45,11 @@ TEST(virtual_memory_is_reported) { EXPECT_GT(usage.virtual_bytes.value(), 0U); } -// A Windows working set counts shared pages that its private commit does not, -// so there the one may legitimately exceed the other -#if !defined(_WIN32) +// The two platforms where both readings come from the same call and the +// relation between them holds. A Windows working set counts shared pages that +// its private commit does not, so there the one may legitimately exceed the +// other +#if defined(__linux__) || defined(__APPLE__) TEST(virtual_memory_covers_at_least_the_resident_set) { const auto usage{sourcemeta::core::process_usage()}; EXPECT_GE(usage.virtual_bytes.value(), usage.resident_bytes.value()); From e47eb3ee28bb4c0170519639d18026f1e68bd66e Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Tue, 25 Aug 2026 13:12:57 -0300 Subject: [PATCH 5/7] More Signed-off-by: Juan Cruz Viotti --- src/lang/process/CMakeLists.txt | 4 +++- src/lang/process/usage.cc | 5 ++++- test/process/process_usage_test.cc | 16 +++++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/lang/process/CMakeLists.txt b/src/lang/process/CMakeLists.txt index f1b82d7e6..517ba09a5 100644 --- a/src/lang/process/CMakeLists.txt +++ b/src/lang/process/CMakeLists.txt @@ -9,6 +9,8 @@ endif() target_link_libraries(sourcemeta_core_process PRIVATE sourcemeta::core::text) target_link_libraries(sourcemeta_core_process PRIVATE sourcemeta::core::numeric) -if(WIN32) +# Matching the condition the sources compile the Windows implementation under, +# so that a MinGW build does not link what it never calls +if(WIN32 AND NOT MINGW) target_link_libraries(sourcemeta_core_process PRIVATE psapi) endif() diff --git a/src/lang/process/usage.cc b/src/lang/process/usage.cc index 115f661d2..eba674d40 100644 --- a/src/lang/process/usage.cc +++ b/src/lang/process/usage.cc @@ -83,9 +83,12 @@ auto process_usage() noexcept -> ProcessUsage { if (GetProcessMemoryInfo(GetCurrentProcess(), &counters, sizeof(counters)) != 0) { result.resident_bytes = counters.WorkingSetSize; - result.virtual_bytes = counters.PagefileUsage; } + // Nothing this platform answers cheaply is the size of the mapped address + // space. What it offers is the commit charge, which leaves out file backed + // mappings and reserved regions, so publishing it would report a different + // quantity under a name the other platforms already mean something else by return result; } diff --git a/test/process/process_usage_test.cc b/test/process/process_usage_test.cc index 78742f658..879dc1f10 100644 --- a/test/process/process_usage_test.cc +++ b/test/process/process_usage_test.cc @@ -39,19 +39,25 @@ TEST(resident_memory_is_reported) { EXPECT_GT(usage.resident_bytes.value(), 0U); } +// The two platforms that answer with the size of the mapped address space +#if defined(__linux__) || defined(__APPLE__) TEST(virtual_memory_is_reported) { const auto usage{sourcemeta::core::process_usage()}; EXPECT_TRUE(usage.virtual_bytes.has_value()); EXPECT_GT(usage.virtual_bytes.value(), 0U); } -// The two platforms where both readings come from the same call and the -// relation between them holds. A Windows working set counts shared pages that -// its private commit does not, so there the one may legitimately exceed the -// other -#if defined(__linux__) || defined(__APPLE__) TEST(virtual_memory_covers_at_least_the_resident_set) { const auto usage{sourcemeta::core::process_usage()}; EXPECT_GE(usage.virtual_bytes.value(), usage.resident_bytes.value()); } #endif + +// Windows offers commit charge rather than the size of the mapped address +// space, which is a different quantity from what the others answer with +#if defined(_WIN32) +TEST(virtual_memory_is_absent_where_the_platform_measures_something_else) { + const auto usage{sourcemeta::core::process_usage()}; + EXPECT_FALSE(usage.virtual_bytes.has_value()); +} +#endif From 03167d23fc490a485cd47b4edb71cbaaaa26a52c Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Tue, 25 Aug 2026 13:17:40 -0300 Subject: [PATCH 6/7] More Signed-off-by: Juan Cruz Viotti --- src/lang/process/usage.cc | 61 ++++++++++++++++++++++++++++-- test/process/process_usage_test.cc | 12 ------ 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/lang/process/usage.cc b/src/lang/process/usage.cc index eba674d40..5b73654f0 100644 --- a/src/lang/process/usage.cc +++ b/src/lang/process/usage.cc @@ -53,6 +53,31 @@ constexpr std::int64_t NANOSECONDS_PER_UNIT{100}; // its own units before the Unix epoch constexpr std::uint64_t EPOCH_OFFSET_UNITS{11644473600ULL * 10000000ULL}; +// The class of process information that carries the size of the mapped address +// space, which no documented interface answers with +constexpr ULONG VM_COUNTERS_INFORMATION_CLASS{3}; + +// What that class answers with, mirroring the kernel's VM_COUNTERS. It is +// absent from the public headers, so the layout is spelled out here. Only the +// second member is read, and everything before it has held its place since +// Windows NT +struct VirtualMemoryCounters { + SIZE_T peak_virtual_size; + SIZE_T virtual_size; + ULONG page_fault_count; + SIZE_T peak_working_set_size; + SIZE_T working_set_size; + SIZE_T quota_peak_paged_pool_usage; + SIZE_T quota_paged_pool_usage; + SIZE_T quota_peak_non_paged_pool_usage; + SIZE_T quota_non_paged_pool_usage; + SIZE_T pagefile_usage; + SIZE_T peak_pagefile_usage; +}; + +using QueryProcessInformation = LONG(NTAPI *)(HANDLE, ULONG, PVOID, ULONG, + PULONG); + auto to_units(const FILETIME &value) noexcept -> std::uint64_t { ULARGE_INTEGER converted{}; converted.LowPart = value.dwLowDateTime; @@ -60,6 +85,37 @@ auto to_units(const FILETIME &value) noexcept -> std::uint64_t { return converted.QuadPart; } +// Resolved once rather than on every call. Looking a module up takes the +// loader lock, and a reading may be taken on a thread that other work shares. +// The address of an export is fixed for the lifetime of the process, so this +// remembers a constant rather than a measurement +auto query_process_information() noexcept -> QueryProcessInformation { + static const auto entry{ + reinterpret_cast(GetProcAddress( + GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationProcess"))}; + return entry; +} + +// The documented memory interface answers with the commit charge, which leaves +// out file backed mappings and reserved regions and so is a different quantity +// from what the other platforms report. This asks the undocumented interface +// that does answer with the mapped address space, and says nothing at all +// where it is unavailable +auto mapped_address_space() noexcept -> std::optional { + const auto entry{query_process_information()}; + if (entry == nullptr) { + return std::nullopt; + } + + VirtualMemoryCounters counters{}; + if (entry(GetCurrentProcess(), VM_COUNTERS_INFORMATION_CLASS, &counters, + static_cast(sizeof(counters)), nullptr) < 0) { + return std::nullopt; + } + + return counters.virtual_size; +} + } // namespace namespace sourcemeta::core { @@ -85,10 +141,7 @@ auto process_usage() noexcept -> ProcessUsage { result.resident_bytes = counters.WorkingSetSize; } - // Nothing this platform answers cheaply is the size of the mapped address - // space. What it offers is the commit charge, which leaves out file backed - // mappings and reserved regions, so publishing it would report a different - // quantity under a name the other platforms already mean something else by + result.virtual_bytes = mapped_address_space(); return result; } diff --git a/test/process/process_usage_test.cc b/test/process/process_usage_test.cc index 879dc1f10..93940a3d6 100644 --- a/test/process/process_usage_test.cc +++ b/test/process/process_usage_test.cc @@ -39,8 +39,6 @@ TEST(resident_memory_is_reported) { EXPECT_GT(usage.resident_bytes.value(), 0U); } -// The two platforms that answer with the size of the mapped address space -#if defined(__linux__) || defined(__APPLE__) TEST(virtual_memory_is_reported) { const auto usage{sourcemeta::core::process_usage()}; EXPECT_TRUE(usage.virtual_bytes.has_value()); @@ -51,13 +49,3 @@ TEST(virtual_memory_covers_at_least_the_resident_set) { const auto usage{sourcemeta::core::process_usage()}; EXPECT_GE(usage.virtual_bytes.value(), usage.resident_bytes.value()); } -#endif - -// Windows offers commit charge rather than the size of the mapped address -// space, which is a different quantity from what the others answer with -#if defined(_WIN32) -TEST(virtual_memory_is_absent_where_the_platform_measures_something_else) { - const auto usage{sourcemeta::core::process_usage()}; - EXPECT_FALSE(usage.virtual_bytes.has_value()); -} -#endif From 7e01db3845f5898f4dd0f20e6a06f295167a094f Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Tue, 25 Aug 2026 13:27:09 -0300 Subject: [PATCH 7/7] More Signed-off-by: Juan Cruz Viotti --- src/lang/process/usage.cc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lang/process/usage.cc b/src/lang/process/usage.cc index 5b73654f0..e1860930a 100644 --- a/src/lang/process/usage.cc +++ b/src/lang/process/usage.cc @@ -90,9 +90,9 @@ auto to_units(const FILETIME &value) noexcept -> std::uint64_t { // The address of an export is fixed for the lifetime of the process, so this // remembers a constant rather than a measurement auto query_process_information() noexcept -> QueryProcessInformation { - static const auto entry{ - reinterpret_cast(GetProcAddress( - GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationProcess"))}; + static const auto entry{reinterpret_cast( + reinterpret_cast(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), + "NtQueryInformationProcess")))}; return entry; }