From f2025b4bafead29d4544ad3cff83498eaec16463 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:45:01 -0400 Subject: [PATCH 1/2] feat(logging): rotating log files --- docs/configuration.md | 3 +- src/logging.cpp | 5 + src/logging.h | 50 ++++++++- .../assets/web/public/assets/locale/en.json | 2 +- tests/unit/test_logging.cpp | 100 ++++++++++++++++++ tools/sunshinesvc.cpp | 8 +- 6 files changed, 164 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ed503973a4b..e03d9eccedf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1870,7 +1870,8 @@ editing the `conf` file in a text editor. Use the examples as reference. Description - The path where the Sunshine log is stored. + The path where the current Sunshine log is stored. Each time Sunshine starts, up to five previous + logs are retained by appending .1 through .5 to this path. diff --git a/src/logging.cpp b/src/logging.cpp index f5f9eaf7b41..814c168eb3a 100644 --- a/src/logging.cpp +++ b/src/logging.cpp @@ -156,6 +156,11 @@ namespace logging { deinit(); } + const auto log_path = std::filesystem::path {std::u8string {log_file.begin(), log_file.end()}}; + if (const auto rotation_error = rotate_log_file(log_path)) { + std::cerr << "Failed to rotate log file '" << log_file << "': " << rotation_error.message() << '\n'; + } + #ifndef __ANDROID__ setup_av_logging(min_log_level); setup_libdisplaydevice_logging(min_log_level); diff --git a/src/logging.h b/src/logging.h index 7cfa381948c..cb33988b4dd 100644 --- a/src/logging.h +++ b/src/logging.h @@ -4,6 +4,13 @@ */ #pragma once +// standard includes +#include +#include +#include +#include +#include + // lib includes #include #include @@ -30,6 +37,47 @@ extern boost::log::sources::severity_logger tests; * @brief Handles the initialization and deinitialization of the logging system. */ namespace logging { + /** + * @brief The number of previous log files retained during rotation. + */ + inline constexpr std::size_t retained_log_file_count {5}; + + /** + * @brief Rotate a log file while retaining up to five previous logs. + * + * The current log is renamed with a `.1` suffix, existing rotated logs are + * advanced by one generation, and the previous `.5` log is removed. + * + * @param log_file Path to the current log file. + * @return An error code when rotation fails, or a clear error code on success. + */ + inline std::error_code rotate_log_file(const std::filesystem::path &log_file) noexcept { + const auto rotated_log_path = [&log_file](std::size_t generation) { + auto rotated_path = log_file; + rotated_path += std::format(".{}", generation); + return rotated_path; + }; + + try { + std::filesystem::remove(rotated_log_path(retained_log_file_count)); + + for (auto generation = retained_log_file_count; generation > 1; --generation) { + const auto previous_path = rotated_log_path(generation - 1); + if (std::filesystem::exists(previous_path)) { + std::filesystem::rename(previous_path, rotated_log_path(generation)); + } + } + + if (std::filesystem::exists(log_file)) { + std::filesystem::rename(log_file, rotated_log_path(1)); + } + + return {}; + } catch (const std::filesystem::filesystem_error &filesystem_error) { + return filesystem_error.code(); + } + } + /** * @brief RAII helper that runs shutdown cleanup when destroyed. */ @@ -58,7 +106,7 @@ namespace logging { void formatter(const boost::log::record_view &view, boost::log::formatting_ostream &os); /** - * @brief Initialize the logging system. + * @brief Rotate the current log file and initialize the logging system. * @param min_log_level The minimum log level to output. * @param log_file The log file to write to. * @return An object that will deinitialize the logging system when it goes out of scope. diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 8bb1005c7d1..7de1459e7e1 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -278,7 +278,7 @@ "locale": "Locale", "locale_desc": "The locale used for Sunshine's user interface.", "log_path": "Logfile Path", - "log_path_desc": "The file where the current logs of Sunshine are stored.", + "log_path_desc": "The file where the current Sunshine log is stored. At startup, up to five previous logs are retained with .1 through .5 suffixes.", "max_bitrate": "Maximum Bitrate", "max_bitrate_desc": "The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If set to 0, it will always use the bitrate requested by Moonlight.", "minimum_fps_target": "Minimum FPS Target", diff --git a/tests/unit/test_logging.cpp b/tests/unit/test_logging.cpp index e1ffb6adc63..1b11703638f 100644 --- a/tests/unit/test_logging.cpp +++ b/tests/unit/test_logging.cpp @@ -5,9 +5,14 @@ #include "../tests_common.h" #include "../tests_log_checker.h" +#include #include +#include +#include #include #include +#include +#include namespace { std::array log_levels = { @@ -20,8 +25,103 @@ namespace { }; constexpr auto log_file = "test_sunshine.log"; + + /** + * @brief Write test content to a log file. + * + * @param path Path to write. + * @param content Content to write. + */ + void write_log_file(const std::filesystem::path &path, std::string_view content) { + std::ofstream output {path}; + output << content; + } + + /** + * @brief Read all content from a test log file. + * + * @param path Path to read. + * @return File content. + */ + std::string read_log_file(const std::filesystem::path &path) { + std::ifstream input {path}; + return {std::istreambuf_iterator {input}, std::istreambuf_iterator {}}; + } } // namespace +/** + * @brief Test fixture for startup log rotation. + */ +class LogRotationTest: public BaseTest { +protected: + /** + * @brief Create an empty directory for the current test. + */ + void SetUp() override { + BaseTest::SetUp(); + std::filesystem::remove_all(test_directory); + std::filesystem::create_directories(test_directory); + } + + /** + * @brief Remove files created by the current test. + */ + void TearDown() override { + std::filesystem::remove_all(test_directory); + BaseTest::TearDown(); + } + + /** + * @brief Build the path for a rotated test log. + * + * @param generation Rotated log generation number. + * @return Path with the generation suffix appended. + */ + std::filesystem::path rotated_log_path(std::size_t generation) const { + auto path = log_path; + path += std::format(".{}", generation); + return path; + } + + const std::filesystem::path test_directory {std::filesystem::path {SUNSHINE_TEST_BIN_DIR} / "log_rotation_tests"}; ///< Directory containing log rotation test files. + const std::filesystem::path log_path {test_directory / "custom.log"}; ///< Path to the current test log. +}; + +TEST_F(LogRotationTest, RotatesCurrentLogAndRetainsFivePreviousLogs) { + write_log_file(log_path, "current"); + for (std::size_t generation = 1; generation <= logging::retained_log_file_count; ++generation) { + write_log_file(rotated_log_path(generation), std::to_string(generation)); + } + + EXPECT_FALSE(logging::rotate_log_file(log_path)); + + EXPECT_FALSE(std::filesystem::exists(log_path)); + EXPECT_EQ(read_log_file(rotated_log_path(1)), "current"); + EXPECT_EQ(read_log_file(rotated_log_path(2)), "1"); + EXPECT_EQ(read_log_file(rotated_log_path(3)), "2"); + EXPECT_EQ(read_log_file(rotated_log_path(4)), "3"); + EXPECT_EQ(read_log_file(rotated_log_path(5)), "4"); +} + +TEST_F(LogRotationTest, SupportsMissingLogGenerations) { + write_log_file(rotated_log_path(2), "second"); + + EXPECT_FALSE(logging::rotate_log_file(log_path)); + + EXPECT_FALSE(std::filesystem::exists(log_path)); + EXPECT_FALSE(std::filesystem::exists(rotated_log_path(1))); + EXPECT_FALSE(std::filesystem::exists(rotated_log_path(2))); + EXPECT_EQ(read_log_file(rotated_log_path(3)), "second"); +} + +TEST_F(LogRotationTest, ReportsFilesystemErrors) { + std::filesystem::create_directories(rotated_log_path(logging::retained_log_file_count) / "child"); + write_log_file(log_path, "current"); + + EXPECT_TRUE(logging::rotate_log_file(log_path)); + EXPECT_EQ(read_log_file(log_path), "current"); +} + struct LogLevelsTest: BaseTest, testing::WithParamInterface {}; INSTANTIATE_TEST_SUITE_P( diff --git a/tools/sunshinesvc.cpp b/tools/sunshinesvc.cpp index cbc9d597450..e23ecf9add5 100644 --- a/tools/sunshinesvc.cpp +++ b/tools/sunshinesvc.cpp @@ -8,6 +8,9 @@ #include #include +// local includes +#include "src/logging.h" + // PROC_THREAD_ATTRIBUTE_JOB_LIST is currently missing from MinGW headers #ifndef PROC_THREAD_ATTRIBUTE_JOB_LIST #define PROC_THREAD_ATTRIBUTE_JOB_LIST ProcThreadAttributeValue(13, FALSE, TRUE, FALSE) @@ -123,10 +126,13 @@ HANDLE OpenLogFileHandle() { GetTempPathW(_countof(log_file_name), log_file_name); wcscat_s(log_file_name, L"sunshine.log"); + // Preserve previous service output before opening the current log. + logging::rotate_log_file(log_file_name); + // The file handle must be inheritable for our child process to use it SECURITY_ATTRIBUTES security_attributes = {sizeof(security_attributes), nullptr, TRUE}; - // Overwrite the old sunshine.log + // Create the current sunshine.log return CreateFileW(log_file_name, GENERIC_WRITE, FILE_SHARE_READ, &security_attributes, CREATE_ALWAYS, 0, nullptr); } From d76b9ef3e1256fe9dcd927a29c85b86a73af45d9 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:18:25 -0400 Subject: [PATCH 2/2] fix(tray): use qt logging callback on all platforms --- src/system_tray.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/system_tray.cpp b/src/system_tray.cpp index 5f5287883d1..55fca243ea8 100644 --- a/src/system_tray.cpp +++ b/src/system_tray.cpp @@ -84,7 +84,6 @@ namespace system_tray { platf::open_url("https://www.paypal.com/paypalme/ReenigneArcher"); } - #if defined(__linux__) || defined(linux) || defined(__linux) || defined(__FreeBSD__) /** * @brief Forwards Qt log messages to Sunshine's BOOST_LOG logger. * @param level Log level: 0=debug, 1=info, 2=warning, 3=error. @@ -109,7 +108,6 @@ namespace system_tray { break; } } - #endif void tray_reset_display_device_config_cb([[maybe_unused]] struct tray_menu *item) { BOOST_LOG(info) << "Resetting display device config from system tray"sv; @@ -318,9 +316,7 @@ namespace system_tray { tray.icon = tray.allIconPaths[0]; #endif - #if defined(__linux__) || defined(linux) || defined(__linux) || defined(__FreeBSD__) tray_set_log_callback(qt_log_to_boost); - #endif tray_set_app_info(PROJECT_NAME, PROJECT_NAME, PROJECT_FQDN);