diff --git a/docs/en/development.md b/docs/en/development.md index 4e8742c30..76b032712 100644 --- a/docs/en/development.md +++ b/docs/en/development.md @@ -70,6 +70,9 @@ Regular Windows builds download an available GUI release bundle. For testing an ## Verify the change +For configuration API mode-list semantics and XML failure handling, see +[saving legacy VDD settings](vdd-settings-persistence.md). + | Change | What to confirm | | --- | --- | | Capture or encoding | Actual capture method, encoder, format, and fallback during a real connection; frame rate and latency when relevant | diff --git a/docs/en/vdd-settings-persistence.md b/docs/en/vdd-settings-persistence.md new file mode 100644 index 000000000..bd81693fa --- /dev/null +++ b/docs/en/vdd-settings-persistence.md @@ -0,0 +1,41 @@ +# Saving legacy VDD settings + +[Development](development.md) · [简体中文](../zh-CN/vdd-settings-persistence.md) + +The configuration API saves advertised resolutions, refresh rates and the GPU +selection in Sunshine's configuration and the existing VDD mode/GPU XML. +Previously an XML failure could be ignored, or a later host-file failure could +leave only the XML updated. Concurrent requests could interleave those writes. + +The save now stages XML and writes the host file under the existing configuration +mutex. It reports an XML failure to the caller without committing the host file. +If the host write fails, it restores the original XML bytes before releasing the +mutex. Unrelated XML settings are retained. Missing XML is a no-op: the API still +saves Sunshine settings without creating a driver configuration file. + +This is best-effort error recovery, not a crash-safe transaction across two files. +Rollback failures are logged. Inspect the host log if the API reports a save +failure; resolve the file-access problem and retry. The change does not restart +the host, reinstall a driver or recreate a display. + +## Mode-list values + +For `resolutions` and `fps`, an omitted field, an empty string, JSON null, or the +string `"null"` retains the saved list. If that list is also absent/empty/null on +disk, the host's current advertised runtime list is used. Both configuration +files receive the same effective values. An explicit bracketed empty list, +`"[]"`, clears a list and leaves the driver to use its existing fallback modes. +Later omitted/blank/null values preserve that intentionally empty list. + +These list fields remain strings, for example: + +```json +{"resolutions":"[1920x1080,3840x2160]","fps":"[60,120]"} +``` + +This is a field fragment, not a complete API request. `POST /api/config` still +replaces other unprotected settings; send the complete configuration to retain +those fields. Existing protected/client-field rules remain unchanged. + +This save-path change has no EDID preference, monitor-creation protocol or signed +driver dependency. It can be reviewed independently of the EDID-native-size option. diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md index 70e56bb8b..76d38b198 100644 --- a/docs/zh-CN/development.md +++ b/docs/zh-CN/development.md @@ -70,6 +70,8 @@ npm run dev ## 如何验证修改 +配置 API 的模式列表语义和 XML 失败处理见[保存旧版 VDD 设置](vdd-settings-persistence.md)。 + | 修改类型 | 应确认的结果 | | --- | --- | | 捕获或编码 | 实际连接的捕获方法、编码器、格式和异常回退;必要时比较帧率与延迟 | diff --git a/docs/zh-CN/vdd-settings-persistence.md b/docs/zh-CN/vdd-settings-persistence.md new file mode 100644 index 000000000..c37062268 --- /dev/null +++ b/docs/zh-CN/vdd-settings-persistence.md @@ -0,0 +1,33 @@ +# 保存旧版 VDD 设置 + +[开发](development.md) · [English](../en/vdd-settings-persistence.md) + +配置 API 会将公布的分辨率、刷新率和 GPU 选择保存到 Sunshine 配置和现有 VDD 模式/GPU XML。 +以前 XML 写入失败可能被忽略,或后续主机配置写入失败导致只有 XML 更新。并发请求也可能交错写入。 + +现在保存操作在现有配置互斥锁内暂存 XML 并写入主机配置。 +XML 写入失败会返回给调用方,且不会提交主机文件。 +主机写入失败时,会在释放锁之前恢复原始 XML 字节。无关 XML 设置会保留。 +XML 不存在时不执行 XML 保存:API 仍保存 Sunshine 设置,不创建驱动配置文件。 + +这是尽力而为的错误恢复,不是可应对崩溃的跨文件事务。回滚失败会记录日志。 +API 报告保存失败时,请检查主机日志,解决文件访问问题后重试。 +此变更不会重启主机、重装驱动或重新创建显示器。 + +## 模式列表值 + +对于 `resolutions` 和 `fps`,省略字段、空字符串、JSON null 或字符串 `"null"` 都会保留已保存的列表。 +磁盘上的列表也不存在、为空或 null 时,使用主机当前公布的运行时列表。两个配置文件会保存相同的有效值。 +明确提交带方括号的空列表 `"[]"` 会清空列表,由驱动使用现有后备模式。 +后续省略、空或 null 值会保留这个明确清空的列表。 + +列表字段仍为字符串,例如: + +```json +{"resolutions":"[1920x1080,3840x2160]","fps":"[60,120]"} +``` + +这只是字段片段,不是完整 API 请求。`POST /api/config` 仍会替换其他非受保护设置;请提交完整配置以保留这些字段。 +现有受保护字段和客户端字段规则保持不变。 + +此保存路径变更不包含 EDID 首选项、显示器创建协议或签名驱动依赖,可以独立于 EDID 原生尺寸选项审查。 diff --git a/src/config.cpp b/src/config.cpp index 6373275b9..479f68970 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,9 @@ #include #include "config.h" +#ifdef SUNSHINE_TESTS + #include "config_test_support.h" +#endif #include "entry_handler.h" #include "file_handler.h" #include "logging.h" @@ -31,6 +35,8 @@ #include "hdr/calibration.h" #include "display_device/parsed_config.h" +#include "display_device/vdd_mode_lists.h" +#include "display_device/vdd_settings.h" #include "platform/common.h" #ifdef _WIN32 @@ -55,6 +61,10 @@ namespace config { namespace { std::mutex config_file_mutex; +#ifdef SUNSHINE_TESTS + std::mutex save_hook_mutex; + std::function save_lock_contended_hook; +#endif std::unordered_map command_line_config_overrides; std::optional @@ -1977,9 +1987,36 @@ namespace config { return snapshot; } +#ifdef SUNSHINE_TESTS + namespace test_support { + /** @brief Install a callback invoked after a full-config update encounters the held save mutex. */ + void + set_save_lock_contended_hook(std::function hook) { + std::lock_guard lock { save_hook_mutex }; + save_lock_contended_hook = std::move(hook); + } + + } // namespace test_support +#endif + + /** Validate, stage related XML, and commit while excluding all configuration writers. */ bool - update_full_config(const std::map &fullConfig) { + update_full_config(const std::map &fullConfig, bool save_vdd) { +#ifdef SUNSHINE_TESTS + // Signal actual contention, not merely that the worker thread was scheduled. + std::unique_lock lock { config_file_mutex, std::defer_lock }; + if (!lock.try_lock()) { + std::function hook; + { + std::lock_guard hook_lock { save_hook_mutex }; + hook = save_lock_contended_hook; + } + if (hook) hook(); + lock.lock(); + } +#else std::lock_guard lock { config_file_mutex }; +#endif try { // 不需要保存到配置文件的只读字段(API响应字段,不是配置项) const std::set readonlyFields = { @@ -2011,6 +2048,7 @@ namespace config { // 使用 std::map 保证按字母顺序保存 std::map resultMap; + const auto existing_modes = display_device::vdd_modes::with_mode_list_defaults(originalMap, nvhttp.resolutions, nvhttp.fps); // 收集传入的配置(跳过只读字段和受保护字段) for (const auto &[key, value] : fullConfig) { @@ -2043,6 +2081,16 @@ namespace config { } } + // Omitted/empty/null mode lists retain saved values, using advertised + // runtime defaults when absent from disk. Explicit lists, including [], + // are never replaced. + for (const auto key : { "resolutions", "fps" }) { + resultMap.try_emplace(key, existing_modes.at(key)); + } + const auto value = [&](const char *key, const char *fallback) { + const auto found = resultMap.find(key); + return found == resultMap.end() ? std::string { fallback } : found->second; + }; // 添加受保护字段(使用本地配置文件中的原有值) for (const auto &field : protectedFields) { auto it = originalMap.find(field); @@ -2051,6 +2099,18 @@ namespace config { } } + // Stage XML under the same lock as the host write. Roll back before + // releasing the lock so an error cannot overwrite a later successful save. + std::unique_ptr vdd_update; + if (save_vdd) { + vdd_update = std::make_unique(); + if (vdd_update->save(value("resolutions", "[]"), value("fps", "[]"), value("adapter_name", "")) == + display_device::vdd_settings::save_result::failed) { + BOOST_LOG(warning) << "Failed to save virtual display settings; Sunshine configuration was not saved"; + return false; + } + } + // 对比新配置与原始配置,相同则跳过写入 if (resultMap != originalMap) { // 按字母顺序写入配置文件 @@ -2068,6 +2128,8 @@ namespace config { BOOST_LOG(info) << "Config unchanged, skip writing"; } + if (vdd_update) vdd_update->commit(); + if (fullConfig.contains("clients")) sync_clients_config_locked(resultMap); const auto gamepad = resultMap.find("gamepad"); platf::set_global_gamepad_mode(gamepad == resultMap.end() ? "auto"sv : std::string_view {gamepad->second}); diff --git a/src/config.h b/src/config.h index 1ce534525..3b0ce1f05 100644 --- a/src/config.h +++ b/src/config.h @@ -332,8 +332,12 @@ namespace config { std::optional> get_effective_config_snapshot(); + /** + * @brief Persist host configuration, optionally staging legacy VDD modes/GPU under the same lock. + * @param save_vdd Restore original XML bytes if host persistence fails; missing XML is a no-op. + */ bool - update_full_config(const std::map &fullConfig); + update_full_config(const std::map &fullConfig, bool save_vdd = false); /** * Return a synchronized snapshot of the serialized per-client settings. diff --git a/src/config_test_support.h b/src/config_test_support.h new file mode 100644 index 000000000..5a5cf666e --- /dev/null +++ b/src/config_test_support.h @@ -0,0 +1,16 @@ +/** + * @file src/config_test_support.h + * @brief Test-only controls for configuration-save contention. + */ +#pragma once + +#ifdef SUNSHINE_TESTS + #include + +namespace config::test_support { + /** Observe an update only after its attempt to acquire the save mutex failed. */ + void + set_save_lock_contended_hook(std::function hook); + +} // namespace config::test_support +#endif diff --git a/src/confighttp/configuration.cpp b/src/confighttp/configuration.cpp index c0228461c..0516db42d 100644 --- a/src/confighttp/configuration.cpp +++ b/src/confighttp/configuration.cpp @@ -11,7 +11,6 @@ #include "src/confighttp/common.h" #include "src/display_device/session.h" #include "src/display_device/to_string.h" -#include "src/display_device/vdd_settings.h" #include "src/file_handler.h" #include "src/logging.h" #include "src/nvenc/frame_budget.h" @@ -263,6 +262,7 @@ namespace confighttp::configuration { } } + /** @brief Save legacy VDD and host settings under one lock, reporting persistence failures. */ void saveConfig(resp_https_t response, req_https_t request) { if (!check_content_type(response, request, "application/json")) return; @@ -295,8 +295,6 @@ namespace confighttp::configuration { if (const auto upnp = inputTree.get_optional("upnp"); upnp && *upnp != "enabled" && *upnp != "disabled" && *upnp != "true" && *upnp != "false") { throw std::invalid_argument("invalid upnp value"); } - std::string resArray = inputTree.get("resolutions", "[]"); - std::string fpsArray = inputTree.get("fps", "[]"); std::string gpu_name = inputTree.get("adapter_name", ""); // Validate config field lengths to prevent abuse @@ -312,8 +310,6 @@ namespace confighttp::configuration { return; } - display_device::vdd_settings::save(resArray, fpsArray, gpu_name); - // 将 inputTree 转换为 std::map(保证有序) std::map fullConfig; for (const auto &kv : inputTree) { @@ -323,9 +319,9 @@ namespace confighttp::configuration { } // 更新配置 - if (!config::update_full_config(fullConfig)) { + if (!config::update_full_config(fullConfig, true)) { outputTree.put("status", "false"); - outputTree.put("error", "failed to persist configuration"); + outputTree.put("error", "Failed to persist Sunshine or virtual display settings; see host log"); return; } } diff --git a/src/display_device/vdd_mode_lists.h b/src/display_device/vdd_mode_lists.h new file mode 100644 index 000000000..0b507de43 --- /dev/null +++ b/src/display_device/vdd_mode_lists.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +namespace display_device::vdd_modes { + /** @brief Treat missing, empty and null mode-list values as omitted; an explicit [] replaces the list. */ + inline std::string_view + mode_list_or(const std::map &values, const char *key, std::string_view fallback = "[]") { + const auto found = values.find(key); + return found == values.end() || found->second.empty() || found->second == "null" ? fallback : std::string_view { found->second }; + } + + /** + * @brief Fill omitted saved mode lists from the currently advertised runtime lists. + * @details Nonempty/non-null values, including explicit empty lists, retain their values. + * Locked persistence uses the same fallback for host and legacy XML lists. + */ + inline std::map + with_mode_list_defaults(std::map saved, + const std::vector &runtime_resolutions, const std::vector &runtime_fps) { + const auto fill = [&](const char *key, const auto &items) { + if (!mode_list_or(saved, key, "").empty()) return; + std::string list = "["; + for (const auto &item : items) { + if (list.size() > 1) list += ','; + list += item; + } + saved.insert_or_assign(key, list + ']'); + }; + fill("resolutions", runtime_resolutions); + fill("fps", runtime_fps); + return saved; + } + +} // namespace display_device::vdd_modes diff --git a/src/display_device/vdd_settings.cpp b/src/display_device/vdd_settings.cpp index 74fbd86fb..323a1e0cd 100644 --- a/src/display_device/vdd_settings.cpp +++ b/src/display_device/vdd_settings.cpp @@ -13,9 +13,15 @@ #include #include #include +#include #include +#include #include +#ifdef SUNSHINE_TESTS + #include +#endif + using namespace std::literals; namespace display_device::vdd_settings { @@ -23,6 +29,13 @@ namespace display_device::vdd_settings { namespace pt = boost::property_tree; namespace { +#ifdef SUNSHINE_TESTS + std::mutex settings_path_mutex; + std::optional test_settings_path; + std::function before_save_hook; +#endif + + /** Split legacy comma-separated mode lists without interpreting their values. */ std::vector split(const std::string &str, char delimiter) { std::vector tokens; @@ -37,8 +50,89 @@ namespace display_device::vdd_settings { } // namespace - bool - save(std::string resArray, std::string fpsArray, std::string gpu_name) { +#ifdef SUNSHINE_TESTS + namespace test_support { + /** @brief Restrict test XML access to the explicitly supplied fixture path. */ + void + set_settings_path(const std::optional &path) { + std::lock_guard lock { settings_path_mutex }; + test_settings_path = path; + } + /** @brief Install a test-only stage barrier without holding the hook mutex during callbacks. */ + void + set_before_save_hook(std::function hook) { + std::lock_guard lock { settings_path_mutex }; + before_save_hook = std::move(hook); + } + } // namespace test_support +#endif + + /** @brief Resolve an isolated/production path and snapshot readable XML without modifying it. */ + transaction::transaction() { +#ifdef SUNSHINE_TESTS + fs::path idd_option_path; + { + std::lock_guard lock { settings_path_mutex }; + if (!test_settings_path) { + BOOST_LOG(warning) << "VDD tests require an isolated settings path"; + return; + } + idd_option_path = *test_settings_path; + } +#else + const auto idd_option_path = platf::appdata() / "vdd_settings.xml"; +#endif + path_ = idd_option_path; + std::error_code error; + const bool exists = fs::exists(path_, error); + if (error) { + BOOST_LOG(warning) << "Unable to inspect VDD configuration: " << error.message(); + return; + } + if (!exists) { + state_ = save_result::missing_file; + return; + } + std::ifstream input(path_, std::ios::binary); + if (!input.is_open()) return; + original_.assign(std::istreambuf_iterator(input), std::istreambuf_iterator()); + if (input.bad()) return; + state_ = save_result::saved; + } + + /** @brief Roll back staged XML bytes before releasing the caller's configuration lock. */ + transaction::~transaction() { + if (!restore_) return; + try { + // A denied write did not alter the file and need not be retried as rollback. + std::ifstream current(path_, std::ios::binary); + const std::string contents { std::istreambuf_iterator(current), std::istreambuf_iterator() }; + if (current.is_open() && !current.bad() && contents == original_) return; + current.close(); + std::ofstream output(path_, std::ios::binary | std::ios::trunc); + output.write(original_.data(), static_cast(original_.size())); + output.flush(); + output.close(); + if (!output) BOOST_LOG(error) << "Failed to restore VDD settings after configuration save failure"; + } + catch (const std::exception &e) { + BOOST_LOG(error) << "Failed to restore VDD settings: " << e.what(); + } + } + + /** @brief Stage general modes/GPU while preserving unrelated XML; empty mode lists are valid. */ + save_result + transaction::save(std::string resArray, std::string fpsArray, std::string gpu_name) { + if (state_ != save_result::saved) return state_; + const auto &idd_option_path = path_; +#ifdef SUNSHINE_TESTS + std::function hook; + { + std::lock_guard lock { settings_path_mutex }; + hook = before_save_hook; + } + if (hook) hook(); +#endif pt::ptree iddOptionTree; pt::ptree global_node; pt::ptree resolutions_nodes; @@ -49,15 +143,16 @@ namespace display_device::vdd_settings { // 添加全局刷新率到global节点 for (const auto &fps : split(boost::regex_replace(fpsArray, pattern, ""), delimiter)) { - global_node.add("g_refresh_rate", fps); + if (!fps.empty()) global_node.add("g_refresh_rate", fps); } std::string str = boost::regex_replace(resArray, pattern, ""); boost::algorithm::trim(str); for (const auto &resolution : split(str, delimiter)) { + if (resolution.empty()) continue; auto index = resolution.find('x'); if (index == std::string::npos) { - return false; + return save_result::failed; } pt::ptree res_node; res_node.put("width", resolution.substr(0, index)); @@ -65,15 +160,8 @@ namespace display_device::vdd_settings { resolutions_nodes.push_back(std::make_pair("resolution"s, res_node)); } - // 类似于 config.cpp 中的 path_f 函数逻辑,使用相对路径 - std::filesystem::path idd_option_path = platf::appdata() / "vdd_settings.xml"; - BOOST_LOG(info) << "VDD配置文件路径: " << file_handler::path_to_utf8(idd_option_path); - if (!fs::exists(idd_option_path)) { - return false; - } - // 先读取现有配置文件 pt::ptree existing_root; pt::ptree root; @@ -146,6 +234,7 @@ namespace display_device::vdd_settings { boost::regex empty_lines_regex("\\n\\s*\\n"); xml_content = boost::regex_replace(xml_content, empty_lines_regex, "\n"); + restore_ = true; std::ofstream file(idd_option_path); if (!file.is_open()) { throw std::runtime_error("Unable to open VDD configuration file"); @@ -156,11 +245,11 @@ namespace display_device::vdd_settings { throw std::runtime_error("Unable to write VDD configuration file"); } - return true; + return save_result::saved; } catch (std::exception &e) { BOOST_LOG(warning) << "写入VDD配置失败: " << e.what(); - return false; + return save_result::failed; } } diff --git a/src/display_device/vdd_settings.h b/src/display_device/vdd_settings.h index f69121c7c..0b7512de3 100644 --- a/src/display_device/vdd_settings.h +++ b/src/display_device/vdd_settings.h @@ -1,12 +1,61 @@ /** * @file src/display_device/vdd_settings.h - * @brief Persist the virtual display resolution and refresh-rate settings. + * @brief Persist virtual display modes. */ #pragma once +#include +#include #include +#ifdef SUNSHINE_TESTS + #include +#endif + namespace display_device::vdd_settings { - bool - save(std::string resArray, std::string fpsArray, std::string gpu_name); + enum class save_result { + saved, + missing_file, + failed, + }; + + /** + * @brief Stage legacy VDD mode/GPU settings and restore their original bytes unless committed. + * @details The caller must hold the configuration save lock through destruction. + * Missing XML is a no-op. Rollback errors are logged; this is not crash recovery. + */ + class transaction { + public: + /** Snapshot the existing XML, refusing writes if it cannot be read safely. */ + transaction(); + /** Restore staged XML on a failed configuration save without throwing. */ + ~transaction(); + transaction(const transaction &) = delete; + transaction & + operator=(const transaction &) = delete; + + /** Write general modes/GPU only; an empty list uses the driver's fallback. */ + save_result + save(std::string resArray, std::string fpsArray, std::string gpu_name); + /** Keep the staged XML after the Sunshine configuration has been saved. */ + void + commit() noexcept { restore_ = false; } + + private: + std::filesystem::path path_; + std::string original_; + save_result state_ = save_result::failed; + bool restore_ = false; + }; + +#ifdef SUNSHINE_TESTS + namespace test_support { + /** Require an explicit fixture path; nullopt disables writes in test builds. */ + void + set_settings_path(const std::optional &path); + /** Pause a staged save for deterministic concurrency tests; absent in production. */ + void + set_before_save_hook(std::function hook); + } // namespace test_support +#endif } // namespace display_device::vdd_settings diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fd1a08e5d..771508901 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -206,6 +206,7 @@ add_test(NAME hdr_calibration_unit_tests COMMAND hdr_calibration_unit_tests) # state and run config::parse, so they cannot join the aggregate suite. add_executable(config_runtime_unit_tests ${CMAKE_SOURCE_DIR}/tests/unit/test_config_runtime.cpp + ${CMAKE_SOURCE_DIR}/src/display_device/vdd_settings.cpp ${CMAKE_SOURCE_DIR}/src/config.cpp ${CMAKE_SOURCE_DIR}/src/file_handler.cpp ${CMAKE_SOURCE_DIR}/src/display_device/parsed_config.cpp @@ -646,6 +647,7 @@ list(REMOVE_ITEM TEST_SOURCES ${CMAKE_SOURCE_DIR}/tests/unit/test_client_fingerprint.cpp ${CMAKE_SOURCE_DIR}/tests/unit/test_config_overrides.cpp ${CMAKE_SOURCE_DIR}/tests/unit/test_config_runtime.cpp + ${CMAKE_SOURCE_DIR}/src/display_device/vdd_settings.cpp ${CMAKE_SOURCE_DIR}/tests/unit/test_current_capture_target.cpp ${CMAKE_SOURCE_DIR}/tests/unit/test_display_scale_steps.cpp ${CMAKE_SOURCE_DIR}/tests/unit/test_frame_contract.cpp diff --git a/tests/unit/test_confighttp_routes.cpp b/tests/unit/test_confighttp_routes.cpp index 7c9972f49..c23928dc8 100644 --- a/tests/unit/test_confighttp_routes.cpp +++ b/tests/unit/test_confighttp_routes.cpp @@ -19,6 +19,12 @@ #include #include "src/config.h" +#include "src/config_test_support.h" +#include "src/display_device/vdd_settings.h" +#include "src/file_handler.h" +#ifdef _WIN32 + #include +#endif #include "src/confighttp/auth.h" #include "src/confighttp/routes.h" #include "src/crypto.h" @@ -307,6 +313,281 @@ TEST_F(ConfigHttpRoutes, EveryRegisteredRouteDispatchesItsMethodPathAndCapturesO } } +#ifdef _WIN32 +namespace { + class ConfigHttpVddSave: public ConfigHttpRoutes { + protected: + std::filesystem::path vdd_path; + const std::string original_config = "resolutions = [1920x1080,3840x2160]\nfps = [60,120]\n"; + const std::string original_xml = "true"; + + /** @brief Route both configuration writers to a unique fixture directory before accepting HTTPS requests. */ + void + SetUp() override { + ConfigHttpRoutes::SetUp(); + previous_config_ = config::sunshine.config_file; + previous_resolutions_ = config::nvhttp.resolutions; + previous_fps_ = config::nvhttp.fps; + config::nvhttp.resolutions = { "1920x1080", "2736x1824", "3840x2160" }; + config::nvhttp.fps = { "60", "120" }; + prepared_ = true; + vdd_path = directory / "vdd_settings.xml"; + display_device::vdd_settings::test_support::set_settings_path(vdd_path); + const auto config_path = directory / "sunshine.conf"; + config::sunshine.config_file = file_handler::path_to_utf8(config_path); + std::ofstream(config_path, std::ios::binary) << original_config; + std::ofstream(vdd_path, std::ios::binary | std::ios::trunc) << original_xml; + } + + /** @brief Stop requests and clear contention/staging hooks before restoring the host path. */ + void + TearDown() override { + ConfigHttpRoutes::TearDown(); + if (!prepared_) return; + config::test_support::set_save_lock_contended_hook({}); + display_device::vdd_settings::test_support::set_before_save_hook({}); + display_device::vdd_settings::test_support::set_settings_path(std::nullopt); + config::sunshine.config_file = previous_config_; + config::nvhttp.resolutions = std::move(previous_resolutions_); + config::nvhttp.fps = std::move(previous_fps_); + } + + private: + std::string previous_config_; + std::vector previous_resolutions_, previous_fps_; + bool prepared_ = false; + }; +} // namespace + +/** @brief A missing fixture path must fail closed without accessing production settings. */ +TEST_F(ConfigHttpVddSave, UnsetTestPathRejectsWritesWithoutFallingBackToAppdata) { + display_device::vdd_settings::test_support::set_settings_path(std::nullopt); + auto client = connect(); + const auto payload = nlohmann::json { { "adapter_name", "" }, + { "resolutions", "[1920x1080,3840x2160]" }, { "fps", "[60,120]" } }; + const auto response = client->request("POST", "/api/config", payload.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(response->status_code, "200 OK"); + const auto body = nlohmann::json::parse(response->content.string()); + EXPECT_EQ(body.at("status"), "false"); + EXPECT_EQ(file_handler::read_file(config::sunshine.config_file.c_str()), original_config); + EXPECT_EQ(file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()), original_xml); +} + +/** @brief Missing VDD XML does not prevent saving host configuration. */ +TEST_F(ConfigHttpVddSave, MissingVddXmlSavesOnlySunshine) { + ASSERT_TRUE(std::filesystem::remove(vdd_path)); + auto client = connect(); + const auto payload = nlohmann::json { { "adapter_name", "" }, + { "resolutions", "[1920x1080,3840x2160]" }, { "fps", "[60,120]" } }; + const auto response = client->request("POST", "/api/config", payload.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(response->status_code, "200 OK"); + EXPECT_EQ(nlohmann::json::parse(response->content.string()).at("status"), "true"); + const auto saved = config::get_config_snapshot(); + ASSERT_TRUE(saved); + EXPECT_FALSE(std::filesystem::exists(vdd_path)); +} + +/** @brief A real VDD file-sharing write denial leaves host settings unchanged. */ +TEST_F(ConfigHttpVddSave, XmlWriteFailureRejectsHostSave) { + // Permit readers but deny writes to the existing test XML without changing ACLs. + const auto blocker = CreateFileW(vdd_path.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + ASSERT_NE(blocker, INVALID_HANDLE_VALUE); + auto release = util::fail_guard([&] { CloseHandle(blocker); }); + auto client = connect(); + { + auto payload = nlohmann::json { { "resolutions", "[1920x1080,3840x2160]" }, { "fps", "[60,120]" } }; + const auto response = client->request("POST", "/api/config", payload.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(response->status_code, "200 OK"); + const auto body = nlohmann::json::parse(response->content.string()); + EXPECT_EQ(body.at("status"), "false"); + EXPECT_NE(body.at("error").get().find("Failed to persist Sunshine or virtual display settings"), std::string::npos); + EXPECT_EQ(file_handler::read_file(config::sunshine.config_file.c_str()), original_config); + EXPECT_EQ(file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()), original_xml); + } +} + +/** @brief Explicit empty lists save successfully while retaining unrelated driver settings. */ +TEST_F(ConfigHttpVddSave, EmptyModeListsPreserveUnrelatedXml) { + auto client = connect(); + for (const std::string empty_list : { "[]", "[ ]" }) { + const auto payload = nlohmann::json { { "adapter_name", "" }, + { "resolutions", empty_list }, { "fps", empty_list } }; + const auto response = client->request("POST", "/api/config", payload.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(response->status_code, "200 OK"); + EXPECT_EQ(nlohmann::json::parse(response->content.string()).at("status"), "true"); + const auto saved = config::get_config_snapshot(); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->at("resolutions"), empty_list); + const auto xml = file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()); + EXPECT_EQ(xml.find(""), std::string::npos); + EXPECT_EQ(xml.find(""), std::string::npos); + EXPECT_NE(xml.find("true"), std::string::npos); + } +} + +/** @brief A denied host write rolls staged mode XML back to its original binary content. */ +TEST_F(ConfigHttpVddSave, SunshineWriteFailureRestoresOriginalXmlBytes) { + const auto config_path = file_handler::path_from_utf8(config::sunshine.config_file); + // Reads/validation and the VDD write succeed; only the final host write is denied. + const auto blocker = CreateFileW(config_path.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + ASSERT_NE(blocker, INVALID_HANDLE_VALUE); + auto release = util::fail_guard([&] { CloseHandle(blocker); }); + auto client = connect(); + const auto payload = nlohmann::json { { "adapter_name", "" }, + { "resolutions", "[1920x1080]" }, { "fps", "[60]" } }; + const auto response = client->request("POST", "/api/config", payload.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(response->status_code, "200 OK"); + EXPECT_EQ(nlohmann::json::parse(response->content.string()).at("status"), "false"); + EXPECT_EQ(file_handler::read_file(config::sunshine.config_file.c_str()), original_config); + EXPECT_EQ(file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()), original_xml); +} + +/** @brief Prove actual mutex contention prevents a second VDD stage until the first commit finishes. */ +TEST_F(ConfigHttpVddSave, ConcurrentSavesSerializeXmlAndHostCommit) { + ASSERT_TRUE(config::update_full_config({ { "adapter_name", "" }, + { "resolutions", "[1920x1080,3840x2160]" }, { "fps", "[60]" } })); + std::promise staged, release, second_contended; + auto staged_future = staged.get_future(); + auto release_future = release.get_future().share(); + auto contended_future = second_contended.get_future(); + std::atomic calls = 0; + display_device::vdd_settings::test_support::set_before_save_hook([&] { + if (calls.fetch_add(1) != 0) return; + staged.set_value(); + if (release_future.wait_for(std::chrono::seconds(5)) != std::future_status::ready) { + throw std::runtime_error("test did not release staged VDD save"); + } + }); + auto first = std::async(std::launch::async, [] { + return config::update_full_config({ { "resolutions", "[1920x1080]" }, { "fps", "[60]" } }, true); + }); + EXPECT_EQ(staged_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + config::test_support::set_save_lock_contended_hook([&] { second_contended.set_value(); }); + auto second = std::async(std::launch::async, [] { + return config::update_full_config({ { "adapter_name", "" }, + { "resolutions", "[1920x1080,3840x2160]" }, { "fps", "[60]" } }, + true); + }); + EXPECT_EQ(contended_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + EXPECT_EQ(second.wait_for(std::chrono::milliseconds(100)), std::future_status::timeout); + EXPECT_EQ(calls.load(), 1u); + release.set_value(); + EXPECT_TRUE(first.get()); + EXPECT_TRUE(second.get()); + config::test_support::set_save_lock_contended_hook({}); + display_device::vdd_settings::test_support::set_before_save_hook({}); + const auto saved = config::get_config_snapshot(); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->at("resolutions"), "[1920x1080,3840x2160]"); + const auto xml = file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()); + EXPECT_NE(xml.find("3840"), std::string::npos); + EXPECT_NE(xml.find("true"), std::string::npos); + EXPECT_EQ(xml.find("EdidNativeResolution"), std::string::npos); +} + +/** @brief Partial API requests preserve omitted lists in both host configuration and legacy mode XML. */ +TEST_F(ConfigHttpVddSave, PartialApiSavesPreserveModeLists) { + auto client = connect(); + for (const auto &payload : { + nlohmann::json { { "sunshine_name", "partial-client" } }, + nlohmann::json { { "adapter_name", "" } }, + nlohmann::json { { "fps", "[60]" } } }) { + const auto response = client->request("POST", "/api/config", payload.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(response->status_code, "200 OK"); + EXPECT_EQ(nlohmann::json::parse(response->content.string()).at("status"), "true"); + const auto saved = config::get_config_snapshot(); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->at("resolutions"), "[1920x1080,3840x2160]"); + EXPECT_EQ(saved->at("fps"), payload.contains("fps") ? "[60]" : "[60,120]"); + const auto xml = file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()); + EXPECT_NE(xml.find("3840"), std::string::npos); + EXPECT_NE(xml.find("60"), std::string::npos); + EXPECT_EQ(xml.find("EdidNativeResolution"), std::string::npos); + } +} + +/** @brief Fresh/implicit configurations persist matching runtime mode defaults to both files. */ +TEST_F(ConfigHttpVddSave, AbsentSavedListsUseRuntimeValuesAndKeepExplicitEmptyLists) { + auto client = connect(); + for (const std::string saved_lists : { "", "resolutions = [3840x2160]\n", "fps = [60]\n", + "resolutions =\nfps = null\n", "resolutions = null\nfps =\n" }) { + SCOPED_TRACE(saved_lists); + std::ofstream(file_handler::path_from_utf8(config::sunshine.config_file), std::ios::binary | std::ios::trunc) + << saved_lists; + const auto response = client->request("POST", "/api/config", + nlohmann::json { { "adapter_name", "" }, { "resolutions", nullptr }, { "fps", "" } }.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(response->status_code, "200 OK"); + EXPECT_EQ(nlohmann::json::parse(response->content.string()).at("status"), "true"); + const auto saved = config::get_config_snapshot(); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->at("resolutions"), saved_lists.starts_with("resolutions = [") ? "[3840x2160]" : "[1920x1080,2736x1824,3840x2160]"); + EXPECT_EQ(saved->at("fps"), saved_lists.starts_with("fps = [") ? "[60]" : "[60,120]"); + const auto xml = file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()); + EXPECT_NE(xml.find("3840"), std::string::npos); + EXPECT_NE(xml.find("60"), std::string::npos); + } + const auto clear = client->request("POST", "/api/config", + nlohmann::json { { "adapter_name", "" }, { "resolutions", "[]" }, { "fps", "[]" } }.dump(), + { { "Content-Type", "application/json" } }); + EXPECT_EQ(nlohmann::json::parse(clear->content.string()).at("status"), "true"); + const auto saved = config::get_config_snapshot(); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->at("resolutions"), "[]"); + EXPECT_EQ(saved->at("fps"), "[]"); + const auto preserve_empty = client->request("POST", "/api/config", + nlohmann::json { { "resolutions", "" }, { "fps", nullptr } }.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(nlohmann::json::parse(preserve_empty->content.string()).at("status"), "true"); + EXPECT_EQ(config::get_config_snapshot(), saved); + const auto xml = file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()); + EXPECT_EQ(xml.find(""), std::string::npos); + EXPECT_EQ(xml.find(""), std::string::npos); +} + +/** @brief A successful VDD/host save retains upstream client-runtime synchronization. */ +TEST_F(ConfigHttpVddSave, SuccessfulVddSaveAlsoSynchronizesClientRuntime) { + const auto original_clients = config::get_clients_config(); + auto restore = util::fail_guard([&] { config::nvhttp.clients = original_clients; }); + config::nvhttp.clients = R"([{"uuid":"00000001-0002-0003-0405-060708090a0b","name":"merge-regression"}])"; + auto client = connect(); + const auto response = client->request("POST", "/api/config", + nlohmann::json { { "clients", "[]" }, { "adapter_name", "" } }.dump(), + { { "Content-Type", "application/json" } }); + ASSERT_EQ(response->status_code, "200 OK"); + EXPECT_EQ(nlohmann::json::parse(response->content.string()).at("status"), "true"); + EXPECT_EQ(config::get_clients_config(), "[]"); + const auto saved = config::get_config_snapshot(); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->at("clients"), "[]"); + const auto xml = file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()); + EXPECT_NE(xml.find("3840"), std::string::npos); + EXPECT_EQ(xml.find("EdidNativeResolution"), std::string::npos); +} /** @brief Blank/null values retain the same saved modes in the host file and legacy XML. */ +TEST_F(ConfigHttpVddSave, EmptyAndNullModeListsPreserveHostAndXmlModes) { + auto client = connect(); + for (const auto &blank : { nlohmann::json(""), nlohmann::json("null"), nlohmann::json(nullptr) }) { + for (const auto &key : { "both", "resolutions", "fps" }) { + auto payload = nlohmann::json::object(); + if (std::string_view(key) != "fps") payload["resolutions"] = blank; + if (std::string_view(key) != "resolutions") payload["fps"] = blank; + SCOPED_TRACE(payload.dump()); + std::ofstream(file_handler::path_from_utf8(config::sunshine.config_file), std::ios::binary | std::ios::trunc) << original_config; + const auto response = client->request("POST", "/api/config", payload.dump(), { { "Content-Type", "application/json" } }); + ASSERT_EQ(nlohmann::json::parse(response->content.string()).at("status"), "true"); + const auto saved = config::get_config_snapshot(); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->at("resolutions"), "[1920x1080,3840x2160]"); + EXPECT_EQ(saved->at("fps"), "[60,120]"); + const auto xml = file_handler::read_file(file_handler::path_to_utf8(vdd_path).c_str()); + EXPECT_NE(xml.find("3840"), std::string::npos); + EXPECT_NE(xml.find("120"), std::string::npos); + EXPECT_NE(xml.find("true"), std::string::npos); + } + } +} +#endif + TEST_F(ConfigHttpRoutes, BasicCredentialsStillAuthenticateRemoteRequestsAndRejectMalformedBase64) { const auto old_salt = config::sunshine.salt; const auto old_password = config::sunshine.password;