From c63a77695a8c180e73ae25cfe4a1a3b9b1d055c5 Mon Sep 17 00:00:00 2001 From: ripper Date: Thu, 12 Nov 2026 23:20:08 -0500 Subject: [PATCH 1/5] Add Input Only streaming mode (no video capture, keeps input+audio) --- docs/configuration.md | 26 ++++++ src/config.cpp | 2 + src/config.h | 2 + src/nvhttp.cpp | 36 +++++++- src/process.cpp | 32 +++++++ src/process.h | 3 + src/rtsp.cpp | 1 + src/rtsp.h | 1 + src/video.cpp | 87 ++++++++++++++++++- src/video.h | 2 + .../assets/web/configs/config_tabs.json | 1 + .../common/assets/web/configs/tabs/Inputs.vue | 8 ++ .../assets/web/public/assets/locale/en.json | 2 + tests/unit/test_process.cpp | 84 ++++++++++++++++++ 14 files changed, 280 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2d81d8c0dd0..fca1b85ec16 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -753,6 +753,32 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin +### enable_input_only_mode + + + + + + + + + + + + + + +
Description + When enabled, Sunshine adds a special "Input Only" app to its application list. +
+ Launching it starts a session that forwards input and audio but performs no screen capture: + the video pipeline sends a single blank frame and then idles, minimizing CPU/GPU usage on the host. +
Default@code{} + disabled + @endcode
Example@code{} + enable_input_only_mode = enabled + @endcode
+ ### keybindings diff --git a/src/config.cpp b/src/config.cpp index de28fe97c6f..9c277ae73d0 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -858,6 +858,7 @@ namespace config { true, // always send scancodes true, // high resolution scrolling true, // native pen/touch support + false, // enable input only mode }; /** @@ -1854,6 +1855,7 @@ namespace config { bool_f(vars, "high_resolution_scrolling", input.high_resolution_scrolling); bool_f(vars, "native_pen_touch", input.native_pen_touch); + bool_f(vars, "enable_input_only_mode", input.enable_input_only_mode); bool_f(vars, "notify_pre_releases", sunshine.notify_pre_releases); bool_f(vars, "system_tray", sunshine.system_tray); diff --git a/src/config.h b/src/config.h index ead9e9812db..9e77d683851 100644 --- a/src/config.h +++ b/src/config.h @@ -318,6 +318,8 @@ namespace config { bool high_resolution_scrolling; ///< Enable high-resolution mouse-wheel events. bool native_pen_touch; ///< Enable native pen and touch injection. + + bool enable_input_only_mode; ///< Advertise an "Input Only" app that streams no video capture while keeping input and audio active. }; namespace flag { diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index bf1bf6a6c80..bd37da3d513 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -493,6 +493,7 @@ namespace nvhttp { std::copy(rikey.cbegin(), rikey.cend(), std::back_inserter(launch_session->gcm_key)); launch_session->host_audio = host_audio; + launch_session->input_only = false; // Overridden by /launch for the Input Only app auto mode = std::stringstream(get_arg(args, "mode", "0x0x0")); // Split mode by the char "x", to populate width/height/fps int x = 0; @@ -1298,6 +1299,13 @@ namespace nvhttp { } auto current_appid = proc::proc.running(); + + // An active placeholder "Input Only" app should not show up as a running game + // to other clients (they would try to resume a session with no real app behind it). + if (!proc::input_only_app_id.empty() && current_appid == (int) util::from_view(proc::input_only_app_id)) { + current_appid = 0; + } + tree.put("root.PairStatus", pair_status); tree.put("root.currentgame", current_appid); tree.put("root.state", current_appid > 0 ? "SUNSHINE_SERVER_BUSY" : "SUNSHINE_SERVER_FREE"); @@ -1400,10 +1408,16 @@ namespace nvhttp { return; } - auto appid = util::from_view(get_arg(args, "appid")); + auto appid_str = get_arg(args, "appid"); + auto appid = util::from_view(appid_str); auto current_appid = proc::proc.running(); - if (current_appid > 0) { + + const bool has_input_only = !proc::input_only_app_id.empty(); + const bool is_input_only = config::input.enable_input_only_mode && has_input_only && appid_str == proc::input_only_app_id; + const bool current_is_input_only = has_input_only && current_appid == (int) util::from_view(proc::input_only_app_id); + + if (current_appid > 0 && !is_input_only && !current_is_input_only) { tree.put("root.resume", 0); tree.put("root..status_code", 400); tree.put("root..status_message", "An app is already running on this host"); @@ -1413,6 +1427,7 @@ namespace nvhttp { host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); auto launch_session = make_launch_session(host_audio, args); + launch_session->input_only = is_input_only; if (rtsp_stream::session_count() == 0) { // The display should be restored in case something fails as there are no other sessions. @@ -1447,7 +1462,22 @@ namespace nvhttp { return; } - if (appid > 0) { + if (is_input_only) { + BOOST_LOG(info) << "Launching input only session"sv; + + // An input-only session does not conflict with an already running app. Only + // launch the placeholder app when nothing is running yet. + if (current_appid == 0) { + auto err = proc::proc.execute((int) appid, launch_session); + if (err) { + tree.put("root..status_code", err); + tree.put("root..status_message", "Failed to start the specified application"); + tree.put("root.gamesession", 0); + + return; + } + } + } else if (appid > 0) { auto err = proc::proc.execute((int) appid, launch_session); if (err) { tree.put("root..status_code", err); diff --git a/src/process.cpp b/src/process.cpp index 90c79ae3891..7b5c291dd90 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -50,6 +50,9 @@ namespace proc { proc_t proc; ///< Global process registry used to track and terminate child processes. + /// Application ID of the synthetic "Input Only" app entry, or empty when input-only mode is disabled. + std::string input_only_app_id; + /** * @brief RAII helper that runs shutdown cleanup when destroyed. */ @@ -848,6 +851,35 @@ namespace proc { apps.emplace_back(std::move(ctx)); } + // Synthetic "Input Only" entry. Launching it starts a session where video capture is + // skipped (a single blank frame is sent to keep the stream alive) while input and + // audio keep working normally. Added last so enabling the mode never changes the + // generated app IDs of existing apps. + if (config::input.enable_input_only_mode) { + proc::ctx_t ctx; + ctx.name = "Input Only"; + ctx.elevated = false; + ctx.auto_detach = true; + ctx.wait_all = true; + ctx.exit_timeout = std::chrono::seconds {5}; + + auto possible_ids = calculate_app_id(ctx.name, ctx.image_path, i++); + if (ids.count(std::get<0>(possible_ids)) == 0) { + // Avoid using index to generate id if possible + ctx.id = std::get<0>(possible_ids); + } else { + // Fallback to include index on collision + ctx.id = std::get<1>(possible_ids); + } + ids.insert(ctx.id); + + input_only_app_id = ctx.id; + + apps.emplace_back(std::move(ctx)); + } else { + input_only_app_id.clear(); + } + return proc::proc_t { this_env, std::move(apps) diff --git a/src/process.h b/src/process.h index 774b0bacd26..b3e5502962f 100644 --- a/src/process.h +++ b/src/process.h @@ -243,4 +243,7 @@ namespace proc { void terminate_process_group(boost::process::v1::child &child_process, boost::process::v1::group &group, std::chrono::seconds exit_timeout); extern proc_t proc; + + /// Application ID of the synthetic "Input Only" app entry, or empty when input-only mode is disabled. + extern std::string input_only_app_id; } // namespace proc diff --git a/src/rtsp.cpp b/src/rtsp.cpp index 4532c2b09c5..b810cde7695 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -1154,6 +1154,7 @@ namespace rtsp_stream { config.monitor.dynamicRange = (int) util::from_view(args.at("x-nv-video[0].dynamicRangeMode"sv)); config.monitor.chromaSamplingType = (int) util::from_view(args.at("x-ss-video[0].chromaSamplingType"sv)); config.monitor.enableIntraRefresh = (int) util::from_view(args.at("x-ss-video[0].intraRefresh"sv)); + config.monitor.input_only = session.input_only; configuredBitrateKbps = util::from_view(args.at("x-ml-video.configuredBitrateKbps"sv)); } catch (std::out_of_range &) { diff --git a/src/rtsp.h b/src/rtsp.h index 108c881e200..f22fbbe2adf 100644 --- a/src/rtsp.h +++ b/src/rtsp.h @@ -82,6 +82,7 @@ namespace rtsp_stream { uint32_t control_connect_data; ///< Client-provided token used when connecting the control channel. bool host_audio; ///< Whether host audio should be played locally. + bool input_only; ///< Whether this is an input-only session with no video capture. std::string unique_id; ///< Moonlight client unique identifier for this launch request. int width; ///< Frame or display width in pixels. int height; ///< Frame or display height in pixels. diff --git a/src/video.cpp b/src/video.cpp index 995e8a73a1e..f793a140ccf 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -1753,7 +1753,7 @@ namespace video { continue; } - if (frame_captured) { + if (frame_captured && !capture_ctx->config.input_only) { capture_ctx->images->raise(img); } @@ -1776,7 +1776,24 @@ namespace video { return true; }; - auto status = disp->capture(push_captured_image_callback, pull_free_image_callback, &display_cursor); + // Only run the capture backend when at least one active session needs video frames. + // Input-only sessions receive a single blank frame and then idle. + bool capture_needed = std::any_of(std::begin(capture_ctxs), std::end(capture_ctxs), [](const capture_ctx_t &capture_ctx) { + return capture_ctx.images->running() && !capture_ctx.config.input_only; + }); + + platf::capture_e status; + if (!capture_needed) { + std::this_thread::sleep_for(50ms); + + while (capture_ctx_queue->peek()) { + capture_ctxs.emplace_back(std::move(*capture_ctx_queue->pop())); + } + + status = switch_display_event->peek() ? platf::capture_e::reinit : platf::capture_e::ok; + } else { + status = disp->capture(push_captured_image_callback, pull_free_image_callback, &display_cursor); + } if (artificial_reinit && status != platf::capture_e::capture_error) { status = platf::capture_e::reinit; @@ -2560,15 +2577,70 @@ namespace video { { // Load a dummy image into the AVFrame to ensure we have something to encode - // even if we timeout waiting on the first frame. This is a relatively large + // even if we timeout waiting for the first frame. This is a relatively large // allocation which can be freed immediately after convert(), so we do this // in a separate scope. auto dummy_img = disp->alloc_img(); - if (!dummy_img || disp->dummy_img(dummy_img.get()) || session->convert(*dummy_img)) { + if (!dummy_img || disp->dummy_img(dummy_img.get())) { + return; + } + + // Input-only sessions never capture, so this dummy frame is the only video + // the client will ever receive. Force it to opaque black: some backends + // leave the buffer uninitialized (or zero the alpha channel), which decodes + // as a green screen on some clients. + if (config.input_only && dummy_img->data != nullptr && dummy_img->pixel_pitch == 4) { + for (int y = 0; y < dummy_img->height; ++y) { + auto row = dummy_img->data + static_cast(y) * dummy_img->row_pitch; + for (int x = 0; x < dummy_img->width; ++x) { + row[x * 4 + 0] = 0x00; // B + row[x * 4 + 1] = 0x00; // G + row[x * 4 + 2] = 0x00; // R + row[x * 4 + 3] = 0xFF; // A + } + } + } + + if (session->convert(*dummy_img)) { return; } } + if (config.input_only) { + BOOST_LOG(info) << "Input only session: idling without video capture; serving blank IDR frames on request"sv; + + // The client's first-frame checks need at least one complete frame, and the + // client may request additional IDR frames if that frame was missed (for + // example, if it arrived before the client's video socket was bound) or if + // the client asks for a refresh. Keep serving the blank frame until the + // session ends instead of blocking forever on the first one. + while (!shutdown_event->peek()) { + if (idr_events->peek()) { + idr_events->pop(); + session->request_idr_frame(); + if (encode(frame_nr++, *session, packets, channel_data, {})) { + BOOST_LOG(error) << "Could not encode dummy video packet"sv; + return; + } + continue; + } + + if (frame_nr == 1) { + // Guarantee the very first frame goes out even if the initial IDR + // request was already consumed elsewhere. + session->request_idr_frame(); + if (encode(frame_nr++, *session, packets, channel_data, {})) { + BOOST_LOG(error) << "Could not encode dummy video packet"sv; + return; + } + continue; + } + + std::this_thread::sleep_for(100ms); + } + return; + } + while (true) { bool requested_idr_frame = false; @@ -2891,6 +2963,13 @@ namespace video { ctx->idr_events->pop(); } + if (ctx->config.input_only && ctx->frame_nr > 1) { + // The single blank IDR frame has already been sent. Keep the session + // alive, but stop converting and encoding further frames. + ++pos; + continue; + } + if (frame_captured && pos->session->convert(*captured_image)) { BOOST_LOG(error) << "Could not convert image"sv; ctx->shutdown_event->raise(true); diff --git a/src/video.h b/src/video.h index 8b2158b6137..641fd2a3a23 100644 --- a/src/video.h +++ b/src/video.h @@ -51,6 +51,8 @@ namespace video { int dynamicRange; ///< Encoding color depth: 0 = 8-bit, 1 = 10-bit. int chromaSamplingType; ///< Chroma sampling type: 0 = 4:2:0, 1 = 4:4:4. int enableIntraRefresh; ///< Intra refresh setting: 0 = disabled, 1 = enabled. + + bool input_only; ///< Input-only session: encode one blank IDR frame, then idle without capturing video. }; /** diff --git a/src_assets/common/assets/web/configs/config_tabs.json b/src_assets/common/assets/web/configs/config_tabs.json index b1940b33d82..a7209dd385f 100644 --- a/src_assets/common/assets/web/configs/config_tabs.json +++ b/src_assets/common/assets/web/configs/config_tabs.json @@ -31,6 +31,7 @@ "mouse": "enabled", "high_resolution_scrolling": "enabled", "native_pen_touch": "enabled", + "enable_input_only_mode": "disabled", "keybindings": "[0x10,0xA0,0x11,0xA2,0x12,0xA4]" } }, diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue index 0bf664555ff..a03248c8a2f 100644 --- a/src_assets/common/assets/web/configs/tabs/Inputs.vue +++ b/src_assets/common/assets/web/configs/tabs/Inputs.vue @@ -348,6 +348,14 @@ watch( v-model="config.native_pen_touch" default="true" > + + + 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 23f5baa943c..571b11cb32c 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -249,6 +249,8 @@ "encoder_desc": "Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you specify a hardware encoder on Windows, it must match the GPU where the display is connected.", "encoder_software": "Software", "encoders": "Encoders", + "enable_input_only_mode": "Enable Input Only Mode", + "enable_input_only_mode_desc": "Adds a special \"Input Only\" app that streams input and audio without screen capture. The video pipeline sends a single blank frame and then idles, minimizing CPU/GPU usage on the host.", "external_audio": "Externally Managed Audio", "external_audio_desc": "Capture only the configured Audio Sink without changing Windows defaults or device formats. Requires an explicit Audio Sink. Virtual Sink selection, automatic Steam audio driver installation, and Moonlight's host-playback toggle are ignored; manage playback and application routing in your audio mixer.", "external_ip": "External IP", diff --git a/tests/unit/test_process.cpp b/tests/unit/test_process.cpp index 68269cee9e5..eb80651dba9 100644 --- a/tests/unit/test_process.cpp +++ b/tests/unit/test_process.cpp @@ -27,6 +27,7 @@ #endif // local includes +#include #include #include @@ -842,3 +843,86 @@ TEST_F(ProcessRefreshTest, FileTimeType_DefaultValueComparison) { // if that test passes, the optional fix works regardless of platform behavior. SUCCEED(); } + +/** + * @brief Test fixture for the synthetic "Input Only" app entry. + */ +class ProcessInputOnlyTest: public ProcessRefreshTest { // NOSONAR(cpp:S3656): protected members are intentional for test fixture subclassing +protected: + bool saved_enable_input_only_mode; + std::string saved_input_only_app_id; + + void SetUp() override { + ProcessRefreshTest::SetUp(); + saved_enable_input_only_mode = config::input.enable_input_only_mode; + saved_input_only_app_id = proc::input_only_app_id; + } + + void TearDown() override { + config::input.enable_input_only_mode = saved_enable_input_only_mode; + proc::input_only_app_id = saved_input_only_app_id; + ProcessRefreshTest::TearDown(); + } +}; + +TEST_F(ProcessInputOnlyTest, Parse_AddsInputOnlyEntryWhenEnabled) { + config::input.enable_input_only_mode = true; + proc::input_only_app_id.clear(); + + const auto path = test_dir / "apps_input_only_enabled.json"; + writeAppsJson(path, {"Game One", "Game Two"}); + + auto parsed = proc::parse(path.string()); + ASSERT_TRUE(parsed.has_value()); + + const auto &apps = parsed->get_apps(); + ASSERT_EQ(apps.size(), 3U); + + const auto &entry = apps.back(); + EXPECT_EQ(entry.name, "Input Only"); + EXPECT_TRUE(entry.cmd.empty()); + EXPECT_FALSE(entry.id.empty()); + EXPECT_EQ(proc::input_only_app_id, entry.id); + + // Existing apps keep stable IDs regardless of the input-only flag + EXPECT_EQ(apps[0].name, "Game One"); + EXPECT_EQ(apps[1].name, "Game Two"); +} + +TEST_F(ProcessInputOnlyTest, Parse_OmitsInputOnlyEntryWhenDisabled) { + config::input.enable_input_only_mode = false; + proc::input_only_app_id = "stale-id"; + + const auto path = test_dir / "apps_input_only_disabled.json"; + writeAppsJson(path, {"Game One", "Game Two"}); + + auto parsed = proc::parse(path.string()); + ASSERT_TRUE(parsed.has_value()); + + const auto &apps = parsed->get_apps(); + ASSERT_EQ(apps.size(), 2U); + for (const auto &app: apps) { + EXPECT_NE(app.name, "Input Only"); + } + EXPECT_TRUE(proc::input_only_app_id.empty()); +} + +TEST_F(ProcessInputOnlyTest, Parse_KeepsExistingAppIdsStableWhenToggled) { + const auto path = test_dir / "apps_input_only_toggle.json"; + writeAppsJson(path, {"Game One", "Game Two"}); + + config::input.enable_input_only_mode = false; + auto disabled = proc::parse(path.string()); + ASSERT_TRUE(disabled.has_value()); + + config::input.enable_input_only_mode = true; + auto enabled = proc::parse(path.string()); + ASSERT_TRUE(enabled.has_value()); + + const auto &disabled_apps = disabled->get_apps(); + const auto &enabled_apps = enabled->get_apps(); + ASSERT_EQ(enabled_apps.size(), disabled_apps.size() + 1); + for (size_t i = 0; i < disabled_apps.size(); ++i) { + EXPECT_EQ(enabled_apps[i].id, disabled_apps[i].id); + } +} From 971244a85564fa00419c48d74a95dfa96d987ad1 Mon Sep 17 00:00:00 2001 From: ripper Date: Fri, 13 Nov 2026 02:17:00 -0500 Subject: [PATCH 2/5] input-only: fix review findings (capture-thread idle lifecycle, reinit deadlock, sync-path frame leak, resume, placeholder cleanup, id race) --- docs/configuration.md | 2 +- src/nvhttp.cpp | 27 ++- src/process.cpp | 14 +- src/process.h | 14 ++ src/stream.cpp | 8 + src/video.cpp | 218 ++++++++++++------ .../assets/web/public/assets/locale/en.json | 4 +- tests/unit/test_process.cpp | 2 +- 8 files changed, 207 insertions(+), 82 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index fca1b85ec16..291a1d6950d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -762,7 +762,7 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin When enabled, Sunshine adds a special "Input Only" app to its application list.
Launching it starts a session that forwards input and audio but performs no screen capture: - the video pipeline sends a single blank frame and then idles, minimizing CPU/GPU usage on the host. + the video pipeline serves blank frames on request and otherwise idles, minimizing CPU/GPU usage on the host.
diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index bd37da3d513..d77e4453c88 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -1302,7 +1302,8 @@ namespace nvhttp { // An active placeholder "Input Only" app should not show up as a running game // to other clients (they would try to resume a session with no real app behind it). - if (!proc::input_only_app_id.empty() && current_appid == (int) util::from_view(proc::input_only_app_id)) { + const std::string input_only_id_for_state = proc::get_input_only_app_id(); + if (!input_only_id_for_state.empty() && current_appid == (int) util::from_view(input_only_id_for_state)) { current_appid = 0; } @@ -1413,9 +1414,13 @@ namespace nvhttp { auto current_appid = proc::proc.running(); - const bool has_input_only = !proc::input_only_app_id.empty(); - const bool is_input_only = config::input.enable_input_only_mode && has_input_only && appid_str == proc::input_only_app_id; - const bool current_is_input_only = has_input_only && current_appid == (int) util::from_view(proc::input_only_app_id); + // Compare numerically so zero-padded or otherwise formatted appids from quirky + // clients still resolve to the placeholder entry. + const std::string input_only_id = proc::get_input_only_app_id(); + const bool has_input_only = !input_only_id.empty(); + const int input_only_id_int = has_input_only ? (int) util::from_view(input_only_id) : 0; + const bool is_input_only = config::input.enable_input_only_mode && has_input_only && (int) appid == input_only_id_int; + const bool current_is_input_only = has_input_only && current_appid == input_only_id_int; if (current_appid > 0 && !is_input_only && !current_is_input_only) { tree.put("root.resume", 0); @@ -1435,8 +1440,11 @@ namespace nvhttp { // We want to prepare display only if there are no active sessions at // the moment. This should be done before probing encoders as it could - // change the active displays. - display_device::configure_display(config::video, *launch_session); + // change the active displays. Input-only sessions must not change the + // physical display configuration since they show no video. + if (!is_input_only) { + display_device::configure_display(config::video, *launch_session); + } // Probe encoders again before streaming to ensure our chosen // encoder matches the active GPU (which could have changed @@ -1559,6 +1567,13 @@ namespace nvhttp { } const auto launch_session = make_launch_session(host_audio, args); + // If only the synthetic "Input Only" placeholder is running, a resume must not + // silently upgrade into a full desktop-capture session. + { + const std::string input_only_id = proc::get_input_only_app_id(); + launch_session->input_only = !input_only_id.empty() && current_appid == (int) util::from_view(input_only_id); + } + if (no_active_sessions) { // We want to prepare display only if there are no active sessions at // the moment. This should be done before probing encoders as it could diff --git a/src/process.cpp b/src/process.cpp index 7b5c291dd90..900677fee58 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -53,6 +53,14 @@ namespace proc { /// Application ID of the synthetic "Input Only" app entry, or empty when input-only mode is disabled. std::string input_only_app_id; + std::mutex input_only_app_id_mutex; + + std::string + get_input_only_app_id() { + std::lock_guard lock(input_only_app_id_mutex); + return input_only_app_id; + } + /** * @brief RAII helper that runs shutdown cleanup when destroyed. */ @@ -873,10 +881,14 @@ namespace proc { } ids.insert(ctx.id); - input_only_app_id = ctx.id; + { + std::lock_guard lock(input_only_app_id_mutex); + input_only_app_id = ctx.id; + } apps.emplace_back(std::move(ctx)); } else { + std::lock_guard lock(input_only_app_id_mutex); input_only_app_id.clear(); } diff --git a/src/process.h b/src/process.h index b3e5502962f..5048266292a 100644 --- a/src/process.h +++ b/src/process.h @@ -13,7 +13,9 @@ #endif // standard includes +#include #include +#include #include // local includes @@ -244,6 +246,18 @@ namespace proc { extern proc_t proc; + /// Guards input_only_app_id, which is written on config refresh and read from request handlers. + extern std::mutex input_only_app_id_mutex; + /// Application ID of the synthetic "Input Only" app entry, or empty when input-only mode is disabled. + /// Must be accessed while holding input_only_app_id_mutex, or via get_input_only_app_id(). extern std::string input_only_app_id; + + /** + * @brief Get a consistent copy of the synthetic "Input Only" application ID. + * + * @return The ID string, or an empty string when input-only mode is disabled. + */ + std::string + get_input_only_app_id(); } // namespace proc diff --git a/src/stream.cpp b/src/stream.cpp index a222b4045e2..2d9c8e925c4 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -2318,6 +2318,14 @@ namespace stream { // If this is the last session, invoke the platform callbacks if (--running_sessions == 0) { + // If the only thing running was the synthetic "Input Only" placeholder, there is + // no real app to keep alive once the last client disconnects. Terminate it so the + // host does not stay stuck reporting a phantom running app. + const std::string input_only_id = proc::get_input_only_app_id(); + if (!input_only_id.empty() && proc::proc.running() == (int) util::from_view(input_only_id)) { + proc::proc.terminate(); + } + bool revert_display_config {config::video.dd.config_revert_on_disconnect}; if (proc::proc.running()) { #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 diff --git a/src/video.cpp b/src/video.cpp index f793a140ccf..58ff7b02479 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -1773,28 +1773,125 @@ namespace video { return false; } + // If every remaining session is input-only, unwind the capture backend so the + // outer loop can switch to its idle path instead of grabbing frames forever. + if (!capture_ctxs.empty() && !std::any_of(std::begin(capture_ctxs), std::end(capture_ctxs), [](const capture_ctx_t &capture_ctx) { + return capture_ctx.images->running() && !capture_ctx.config.input_only; + })) { + artificial_reinit = true; + return false; + } + + return true; + }; + + // Shared display reinitialization sequence. Returns false when the capture thread must exit. + auto do_reinit = [&]() -> bool { + reinit_event.raise(true); + + // Some classes of images contain references to the display --> display won't delete unless img is deleted + for (auto &img : imgs) { + img.reset(); + } + + // display_wp is modified in this thread only + // Wait for the other shared_ptr's of display to be destroyed. + // New displays will only be created in this thread. + while (display_wp->use_count() != 1) { + // Free images that weren't consumed by the encoders. These can reference the display and prevent + // the ref count from reaching 1. We do this here rather than on the encoder thread to avoid race + // conditions where the encoding loop might free a good frame after reinitializing if we capture + // a new frame here before the encoder has finished reinitializing. + KITTY_WHILE_LOOP(auto capture_ctx = std::begin(capture_ctxs), capture_ctx != std::end(capture_ctxs), { + if (!capture_ctx->images->running()) { + capture_ctx = capture_ctxs.erase(capture_ctx); + continue; + } + + while (capture_ctx->images->try_pop()) { + } + + ++capture_ctx; + }); + + std::this_thread::sleep_for(20ms); + } + + if (capture_ctxs.empty()) { + // Every session went away while we were reinitializing. Refcount-driven shutdown + // of the shared capture thread will take care of the rest. + return false; + } + + while (capture_ctx_queue->running()) { + // Release the display before reenumerating displays, since some capture backends + // only support a single display session per device/application. + disp.reset(); + + // Refresh display names since a display removal might have caused the reinitialization + refresh_displays(encoder.platform_formats->dev_type, display_names, display_p); + + // Process any pending display switch with the new list of displays + if (switch_display_event->peek()) { + display_p = std::clamp(*switch_display_event->pop(), 0, static_cast(display_names.size()) - 1); + } + + // reset_display() will sleep between retries + reset_display(disp, encoder.platform_formats->dev_type, display_names[display_p], capture_ctxs.front().config); + if (disp) { + break; + } + } + if (!disp) { + return false; + } + + display_wp = disp; + + reinit_event.reset(); return true; }; // Only run the capture backend when at least one active session needs video frames. - // Input-only sessions receive a single blank frame and then idle. + // Input-only sessions serve blank frames and never consume captured images. bool capture_needed = std::any_of(std::begin(capture_ctxs), std::end(capture_ctxs), [](const capture_ctx_t &capture_ctx) { return capture_ctx.images->running() && !capture_ctx.config.input_only; }); - platf::capture_e status; if (!capture_needed) { std::this_thread::sleep_for(50ms); + // Prune sessions that have stopped while we were idling + KITTY_WHILE_LOOP(auto capture_ctx = std::begin(capture_ctxs), capture_ctx != std::end(capture_ctxs), { + if (!capture_ctx->images->running()) { + capture_ctx = capture_ctxs.erase(capture_ctx); + + continue; + } + + ++capture_ctx; + }) + while (capture_ctx_queue->peek()) { capture_ctxs.emplace_back(std::move(*capture_ctx_queue->pop())); } - status = switch_display_event->peek() ? platf::capture_e::reinit : platf::capture_e::ok; - } else { - status = disp->capture(push_captured_image_callback, pull_free_image_callback, &display_cursor); + if (switch_display_event->peek()) { + if (!do_reinit()) { + return; + } + + continue; + } + + // Keep idling or pick up newly queued sessions by re-evaluating capture_needed at the top + // of the loop. Note: we must not fall through to the status switch with a non-terminal + // status because capture_e::ok is handled as end-of-capture and would kill this shared thread. + continue; } + auto status = disp->capture(push_captured_image_callback, pull_free_image_callback, &display_cursor); + if (artificial_reinit && status != platf::capture_e::capture_error) { status = platf::capture_e::reinit; @@ -1803,65 +1900,11 @@ namespace video { switch (status) { case platf::capture_e::reinit: - { - reinit_event.raise(true); - - // Some classes of images contain references to the display --> display won't delete unless img is deleted - for (auto &img : imgs) { - img.reset(); - } - - // display_wp is modified in this thread only - // Wait for the other shared_ptr's of display to be destroyed. - // New displays will only be created in this thread. - while (display_wp->use_count() != 1) { - // Free images that weren't consumed by the encoders. These can reference the display and prevent - // the ref count from reaching 1. We do this here rather than on the encoder thread to avoid race - // conditions where the encoding loop might free a good frame after reinitializing if we capture - // a new frame here before the encoder has finished reinitializing. - KITTY_WHILE_LOOP(auto capture_ctx = std::begin(capture_ctxs), capture_ctx != std::end(capture_ctxs), { - if (!capture_ctx->images->running()) { - capture_ctx = capture_ctxs.erase(capture_ctx); - continue; - } - - while (capture_ctx->images->try_pop()) { - } - - ++capture_ctx; - }); - - std::this_thread::sleep_for(20ms); - } - - while (capture_ctx_queue->running()) { - // Release the display before reenumerating displays, since some capture backends - // only support a single display session per device/application. - disp.reset(); - - // Refresh display names since a display removal might have caused the reinitialization - refresh_displays(encoder.platform_formats->dev_type, display_names, display_p); - - // Process any pending display switch with the new list of displays - if (switch_display_event->peek()) { - display_p = std::clamp(*switch_display_event->pop(), 0, static_cast(display_names.size()) - 1); - } - - // reset_display() will sleep between retries - reset_display(disp, encoder.platform_formats->dev_type, display_names[display_p], capture_ctxs.front().config); - if (disp) { - break; - } - } - if (!disp) { - return; - } - - display_wp = disp; - - reinit_event.reset(); - continue; + if (!do_reinit()) { + return; } + + continue; case platf::capture_e::capture_error: case platf::capture_e::ok: case platf::capture_e::timeout: @@ -2614,7 +2657,19 @@ namespace video { // example, if it arrived before the client's video socket was bound) or if // the client asks for a refresh. Keep serving the blank frame until the // session ends instead of blocking forever on the first one. - while (!shutdown_event->peek()) { + while (!shutdown_event->peek() && images->running()) { + while (invalidate_ref_frames_events->peek()) { + if (auto frames = invalidate_ref_frames_events->pop(0ms)) { + session->invalidate_ref_frames(frames->first, frames->second); + } + } + + if (reinit_event.peek() && frame_nr > 1) { + // The display is being reinitialized. Return so capture_async can recreate + // the encode session, releasing our reference to the old display in the process. + break; + } + if (idr_events->peek()) { idr_events->pop(); session->request_idr_frame(); @@ -2958,18 +3013,39 @@ namespace video { continue; } - if (ctx->idr_events->peek()) { - pos->session->request_idr_frame(); - ctx->idr_events->pop(); - } + if (ctx->config.input_only) { + // Input-only sessions must never encode a real captured frame. The synced session + // already converted the black dummy image at setup time, so encode that on the + // first pass and again whenever the client requests an IDR, and otherwise idle. + bool serve_idr = ctx->idr_events->peek() != 0; + if (serve_idr) { + ctx->idr_events->pop(); + } + + if (serve_idr || ctx->frame_nr == 1) { + if (serve_idr) { + pos->session->request_idr_frame(); + } + + if (encode(ctx->frame_nr++, *pos->session, ctx->packets, ctx->channel_data, {})) { + BOOST_LOG(error) << "Could not encode dummy video packet"sv; + ctx->shutdown_event->raise(true); + + continue; + } + + pos->session->request_normal_frame(); + } - if (ctx->config.input_only && ctx->frame_nr > 1) { - // The single blank IDR frame has already been sent. Keep the session - // alive, but stop converting and encoding further frames. ++pos; continue; } + if (ctx->idr_events->peek()) { + pos->session->request_idr_frame(); + ctx->idr_events->pop(); + } + if (frame_captured && pos->session->convert(*captured_image)) { BOOST_LOG(error) << "Could not convert image"sv; ctx->shutdown_event->raise(true); 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 571b11cb32c..edb5a9d76d4 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -245,12 +245,12 @@ "dscp_tagging_desc": "Mark audio and video packets with DSCP priority tags when requested by the client. Disabling this may help if audio crackles or stutters on Wi-Fi, as some routers handle tagged traffic poorly.", "virtualhid_randomize_mac": "Randomize virtual controller MAC", "virtualhid_randomize_mac_desc": "Use a random MAC address for PlayStation-style virtual controllers instead of one based on the controller index. This avoids mixing per-controller settings when controllers are swapped on the client.", + "enable_input_only_mode": "Enable Input Only Mode", + "enable_input_only_mode_desc": "Adds a special \"Input Only\" app that streams input and audio without screen capture. The video pipeline serves blank frames on request and otherwise idles, minimizing CPU/GPU usage on the host.", "encoder": "Force a Specific Encoder", "encoder_desc": "Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you specify a hardware encoder on Windows, it must match the GPU where the display is connected.", "encoder_software": "Software", "encoders": "Encoders", - "enable_input_only_mode": "Enable Input Only Mode", - "enable_input_only_mode_desc": "Adds a special \"Input Only\" app that streams input and audio without screen capture. The video pipeline sends a single blank frame and then idles, minimizing CPU/GPU usage on the host.", "external_audio": "Externally Managed Audio", "external_audio_desc": "Capture only the configured Audio Sink without changing Windows defaults or device formats. Requires an explicit Audio Sink. Virtual Sink selection, automatic Steam audio driver installation, and Moonlight's host-playback toggle are ignored; manage playback and application routing in your audio mixer.", "external_ip": "External IP", diff --git a/tests/unit/test_process.cpp b/tests/unit/test_process.cpp index eb80651dba9..c16757f88c4 100644 --- a/tests/unit/test_process.cpp +++ b/tests/unit/test_process.cpp @@ -901,7 +901,7 @@ TEST_F(ProcessInputOnlyTest, Parse_OmitsInputOnlyEntryWhenDisabled) { const auto &apps = parsed->get_apps(); ASSERT_EQ(apps.size(), 2U); - for (const auto &app: apps) { + for (const auto &app : apps) { EXPECT_NE(app.name, "Input Only"); } EXPECT_TRUE(proc::input_only_app_id.empty()); From ae9ae1849bdf80acecdbc0116efefebbaa893c7f Mon Sep 17 00:00:00 2001 From: ripper Date: Fri, 13 Nov 2026 20:59:06 -0500 Subject: [PATCH 3/5] input-only: fix sync/async capture lifecycle, blank frame seeding, display config, and placeholder state --- src/nvhttp.cpp | 35 ++-- src/process.cpp | 24 ++- src/process.h | 13 +- src/stream.cpp | 13 +- src/video.cpp | 322 ++++++++++++++++++++++++-------- src/video.h | 16 ++ tests/unit/test_config.cpp | 22 +++ tests/unit/test_process.cpp | 112 ++++++++++- tests/unit/test_video.cpp | 138 ++++++++++++++ tests/web/Configuration.test.js | 40 ++++ 10 files changed, 630 insertions(+), 105 deletions(-) diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index d77e4453c88..7c2e63f3af2 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -1302,8 +1302,7 @@ namespace nvhttp { // An active placeholder "Input Only" app should not show up as a running game // to other clients (they would try to resume a session with no real app behind it). - const std::string input_only_id_for_state = proc::get_input_only_app_id(); - if (!input_only_id_for_state.empty() && current_appid == (int) util::from_view(input_only_id_for_state)) { + if (proc::proc.is_input_only_running()) { current_appid = 0; } @@ -1417,10 +1416,10 @@ namespace nvhttp { // Compare numerically so zero-padded or otherwise formatted appids from quirky // clients still resolve to the placeholder entry. const std::string input_only_id = proc::get_input_only_app_id(); - const bool has_input_only = !input_only_id.empty(); - const int input_only_id_int = has_input_only ? (int) util::from_view(input_only_id) : 0; - const bool is_input_only = config::input.enable_input_only_mode && has_input_only && (int) appid == input_only_id_int; - const bool current_is_input_only = has_input_only && current_appid == input_only_id_int; + const bool is_input_only = config::input.enable_input_only_mode && !input_only_id.empty() && (int) appid == (int) util::from_view(input_only_id); + + // The running placeholder is recognized even if the mode was toggled off after it started. + const bool current_is_input_only = proc::proc.is_input_only_running(); if (current_appid > 0 && !is_input_only && !current_is_input_only) { tree.put("root.resume", 0); @@ -1434,9 +1433,12 @@ namespace nvhttp { auto launch_session = make_launch_session(host_audio, args); launch_session->input_only = is_input_only; - if (rtsp_stream::session_count() == 0) { - // The display should be restored in case something fails as there are no other sessions. - revert_display_configuration = true; + // A real app launched on top of a running input-only placeholder needs the display + // prepared, even though other sessions are still active. + if (rtsp_stream::session_count() == 0 || (current_is_input_only && !is_input_only)) { + // The display should be restored in case something fails as there are no other sessions + // that depend on it. An input-only session never changes it, so there is nothing to revert. + revert_display_configuration = !is_input_only; // We want to prepare display only if there are no active sessions at // the moment. This should be done before probing encoders as it could @@ -1568,17 +1570,18 @@ namespace nvhttp { const auto launch_session = make_launch_session(host_audio, args); // If only the synthetic "Input Only" placeholder is running, a resume must not - // silently upgrade into a full desktop-capture session. - { - const std::string input_only_id = proc::get_input_only_app_id(); - launch_session->input_only = !input_only_id.empty() && current_appid == (int) util::from_view(input_only_id); - } + // silently upgrade into a full desktop-capture session. The placeholder is recognized + // even if input-only mode was turned off after it started. + launch_session->input_only = proc::proc.is_input_only_running(); if (no_active_sessions) { // We want to prepare display only if there are no active sessions at // the moment. This should be done before probing encoders as it could - // change the active displays. - display_device::configure_display(config::video, *launch_session); + // change the active displays. Input-only sessions show no video, so they + // must not change the physical display configuration. + if (!launch_session->input_only) { + display_device::configure_display(config::video, *launch_session); + } // Probe encoders again before streaming to ensure our chosen // encoder matches the active GPU (which could have changed diff --git a/src/process.cpp b/src/process.cpp index 900677fee58..438717b2cac 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -56,7 +56,7 @@ namespace proc { std::mutex input_only_app_id_mutex; std::string - get_input_only_app_id() { + get_input_only_app_id() { std::lock_guard lock(input_only_app_id_mutex); return input_only_app_id; } @@ -379,8 +379,18 @@ namespace proc { return 0; } + bool proc_t::is_input_only_running() const { + return (_app_id > 0 || placebo) && _app.input_only; + } + void proc_t::terminate() { - input::terminate_gamepads(); + // Gamepads of an input-only client must survive the placeholder being replaced by a real + // app, but there is nothing left to keep once every session is gone. + const bool input_only = _app.input_only; + if (!input_only || rtsp_stream::session_count() == 0) { + input::terminate_gamepads(); + } + std::error_code ec; placebo = false; terminate_process_group(_process, _process_group, _app.exit_timeout); @@ -419,7 +429,9 @@ namespace proc { // Only show the Stopped notification if we actually have an app to stop // Since terminate() is always run when a new app has started - if (proc::proc.get_last_run_app_name().length() > 0 && has_run) { + // The input-only placeholder is not a real app: it never configured the display, so + // reverting here would clobber the configuration applied for an app launched over it. + if (proc::proc.get_last_run_app_name().length() > 0 && has_run && !input_only) { #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_stopped(proc::proc.get_last_run_app_name()); #endif @@ -428,6 +440,11 @@ namespace proc { } _app_id = -1; + _app = {}; + + // The cleared app owns the container these iterators pointed into + _app_prep_it = {}; + _app_prep_begin = {}; } const std::vector &proc_t::get_apps() const { @@ -870,6 +887,7 @@ namespace proc { ctx.auto_detach = true; ctx.wait_all = true; ctx.exit_timeout = std::chrono::seconds {5}; + ctx.input_only = true; auto possible_ids = calculate_app_id(ctx.name, ctx.image_path, i++); if (ids.count(std::get<0>(possible_ids)) == 0) { diff --git a/src/process.h b/src/process.h index 5048266292a..e3339da14e6 100644 --- a/src/process.h +++ b/src/process.h @@ -81,6 +81,7 @@ namespace proc { bool auto_detach; ///< Whether the process should detach automatically. bool wait_all; ///< Whether Sunshine waits for all child processes. std::chrono::seconds exit_timeout; ///< Exit timeout. + bool input_only = false; ///< Synthetic app that streams no captured video while keeping input and audio active. }; /** @@ -119,6 +120,16 @@ namespace proc { */ int running(); + /** + * @brief Check whether the running app is the synthetic "Input Only" placeholder. + * + * Unlike the configured app ID, this stays valid for the lifetime of the session even if + * `input.enable_input_only_mode` is turned off while the placeholder is still running. + * + * @return true if an input-only session is currently running. + */ + bool is_input_only_running() const; + ~proc_t(); /** @@ -259,5 +270,5 @@ namespace proc { * @return The ID string, or an empty string when input-only mode is disabled. */ std::string - get_input_only_app_id(); + get_input_only_app_id(); } // namespace proc diff --git a/src/stream.cpp b/src/stream.cpp index 2d9c8e925c4..ab8b91c7c6b 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -2321,8 +2321,8 @@ namespace stream { // If the only thing running was the synthetic "Input Only" placeholder, there is // no real app to keep alive once the last client disconnects. Terminate it so the // host does not stay stuck reporting a phantom running app. - const std::string input_only_id = proc::get_input_only_app_id(); - if (!input_only_id.empty() && proc::proc.running() == (int) util::from_view(input_only_id)) { + const bool was_input_only = proc::proc.is_input_only_running(); + if (was_input_only) { proc::proc.terminate(); } @@ -2331,6 +2331,10 @@ namespace stream { #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_pausing(proc::proc.get_last_run_app_name()); #endif + } else if (was_input_only) { + // The input-only placeholder never configured the display, so there is nothing to + // revert on its account. There are no sessions left, so gamepads can go away too. + input::terminate_gamepads(); } else { // We have no app running and also no clients anymore. revert_display_config = true; @@ -2385,7 +2389,10 @@ namespace stream { if (++running_sessions == 1) { platf::streaming_will_start(); #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 - system_tray::update_tray_playing(proc::proc.get_last_run_app_name()); + // The "Input Only" placeholder is not a real app, so don't announce it as playing. + if (!proc::proc.is_input_only_running()) { + system_tray::update_tray_playing(proc::proc.get_last_run_app_name()); + } #endif } diff --git a/src/video.cpp b/src/video.cpp index 58ff7b02479..5915fc7163e 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -708,6 +708,8 @@ namespace video { struct sync_session_t { sync_session_ctx_t *ctx; ///< Shared capture/encode synchronization context. std::unique_ptr session; ///< Active encoder session used by the capture thread. + + bool encoded_initial_frame {}; ///< Whether the current encoder session already emitted an initial IDR frame. }; /** @@ -1776,8 +1778,8 @@ namespace video { // If every remaining session is input-only, unwind the capture backend so the // outer loop can switch to its idle path instead of grabbing frames forever. if (!capture_ctxs.empty() && !std::any_of(std::begin(capture_ctxs), std::end(capture_ctxs), [](const capture_ctx_t &capture_ctx) { - return capture_ctx.images->running() && !capture_ctx.config.input_only; - })) { + return capture_ctx.images->running() && !capture_ctx.config.input_only; + })) { artificial_reinit = true; return false; } @@ -1794,6 +1796,23 @@ namespace video { img.reset(); } + // Sessions can stop at any time, including while the display is being reinitialized. + auto prune_stopped_sessions = [&capture_ctxs]() { + KITTY_WHILE_LOOP(auto capture_ctx = std::begin(capture_ctxs), capture_ctx != std::end(capture_ctxs), { + if (!capture_ctx->images->running()) { + capture_ctx = capture_ctxs.erase(capture_ctx); + continue; + } + + ++capture_ctx; + }); + }; + + // Prune up front: if the display refcount is already 1, the wait loop below never runs + // and stopped sessions would be left behind, including at the front of capture_ctxs + // whose config is used to create the new display. + prune_stopped_sessions(); + // display_wp is modified in this thread only // Wait for the other shared_ptr's of display to be destroyed. // New displays will only be created in this thread. @@ -1817,10 +1836,23 @@ namespace video { std::this_thread::sleep_for(20ms); } + // Sessions may have stopped while we were waiting for the display to be released. + prune_stopped_sessions(); + + // Pick up sessions that joined while the display was being reinitialized. + while (capture_ctx_queue->peek()) { + capture_ctxs.emplace_back(std::move(*capture_ctx_queue->pop())); + } + if (capture_ctxs.empty()) { - // Every session went away while we were reinitializing. Refcount-driven shutdown - // of the shared capture thread will take care of the rest. - return false; + // Every session went away while we were reinitializing. Wait for a replacement so a + // session joining now isn't left without a capture thread, and exit if the shared + // capture thread is being torn down instead. + if (auto next_ctx = capture_ctx_queue->pop()) { + capture_ctxs.emplace_back(std::move(*next_ctx)); + } else { + return false; + } } while (capture_ctx_queue->running()) { @@ -2562,6 +2594,41 @@ namespace video { return nullptr; } + void fill_blank_image(platf::img_t &img) { + if (img.data == nullptr || img.width <= 0 || img.height <= 0 || img.row_pitch <= 0) { + return; + } + + // Backends that don't fill in pixel_pitch can still be identified by the row pitch. + const auto pixel_pitch = img.pixel_pitch > 0 ? img.pixel_pitch : (img.row_pitch / img.width); + + // A row that is shorter than the pixels it is supposed to hold is malformed + if (static_cast(img.row_pitch) < static_cast(img.width) * pixel_pitch) { + return; + } + + if (pixel_pitch == 4) { + for (auto y = 0; y < img.height; ++y) { + auto row = img.data + static_cast(y) * img.row_pitch; + std::fill_n(row, static_cast(img.width) * 4, (std::uint8_t) 0x00); + + // Restore the alpha channel, which is the last byte of every BGRA/xRGB pixel + for (auto x = 0; x < img.width; ++x) { + row[x * 4 + 3] = 0xFF; + } + } + } else if (pixel_pitch == 1) { + for (auto y = 0; y < img.height; ++y) { + std::fill_n(img.data + static_cast(y) * img.row_pitch, img.width, (std::uint8_t) 0x00); + } + + auto uv_plane = img.data + static_cast(img.row_pitch) * img.height; + for (auto y = 0; y < img.height / 2; ++y) { + std::fill_n(uv_plane + static_cast(y) * img.row_pitch, img.width, (std::uint8_t) 0x80); + } + } + } + /** * @brief Run one encode loop for a display capture stream. * @@ -2632,16 +2699,8 @@ namespace video { // the client will ever receive. Force it to opaque black: some backends // leave the buffer uninitialized (or zero the alpha channel), which decodes // as a green screen on some clients. - if (config.input_only && dummy_img->data != nullptr && dummy_img->pixel_pitch == 4) { - for (int y = 0; y < dummy_img->height; ++y) { - auto row = dummy_img->data + static_cast(y) * dummy_img->row_pitch; - for (int x = 0; x < dummy_img->width; ++x) { - row[x * 4 + 0] = 0x00; // B - row[x * 4 + 1] = 0x00; // G - row[x * 4 + 2] = 0x00; // R - row[x * 4 + 3] = 0xFF; // A - } - } + if (config.input_only) { + fill_blank_image(*dummy_img); } if (session->convert(*dummy_img)) { @@ -2652,42 +2711,44 @@ namespace video { if (config.input_only) { BOOST_LOG(info) << "Input only session: idling without video capture; serving blank IDR frames on request"sv; + // The display (and with it this encoder session) can be recreated while the stream + // stays alive, so remember whether the *current* encoder has produced a frame. The + // frame counter is shared across reinits and can't be used for that purpose. + bool encoded_initial_frame = false; + // The client's first-frame checks need at least one complete frame, and the // client may request additional IDR frames if that frame was missed (for // example, if it arrived before the client's video socket was bound) or if // the client asks for a refresh. Keep serving the blank frame until the // session ends instead of blocking forever on the first one. while (!shutdown_event->peek() && images->running()) { - while (invalidate_ref_frames_events->peek()) { - if (auto frames = invalidate_ref_frames_events->pop(0ms)) { - session->invalidate_ref_frames(frames->first, frames->second); - } + // There are no reference frames to recover from in a blank stream, so a request to + // invalidate reference frames is served by emitting a fresh IDR frame instead. + bool rfi_requested = false; + while (invalidate_ref_frames_events->pop(0ms)) { + rfi_requested = true; } - if (reinit_event.peek() && frame_nr > 1) { + if (reinit_event.peek() && encoded_initial_frame) { // The display is being reinitialized. Return so capture_async can recreate // the encode session, releasing our reference to the old display in the process. break; } - if (idr_events->peek()) { - idr_events->pop(); - session->request_idr_frame(); - if (encode(frame_nr++, *session, packets, channel_data, {})) { - BOOST_LOG(error) << "Could not encode dummy video packet"sv; - return; + bool idr_requested = idr_events->peek(); + if (!encoded_initial_frame || idr_requested || rfi_requested) { + if (idr_requested) { + idr_events->pop(); } - continue; - } - if (frame_nr == 1) { - // Guarantee the very first frame goes out even if the initial IDR - // request was already consumed elsewhere. session->request_idr_frame(); if (encode(frame_nr++, *session, packets, channel_data, {})) { BOOST_LOG(error) << "Could not encode dummy video packet"sv; return; } + + session->request_normal_frame(); + encoded_initial_frame = true; continue; } @@ -2866,7 +2927,8 @@ namespace video { * * @param disp Display connection or display handle. * @param encoder Encoder configuration or encoder instance. - * @param img Image or frame object to read from or populate. + * @param img Frame used to seed the encoder. Input-only sessions ignore it and seed + * themselves with a blank frame instead. * @param ctx Native context object used by the operation or callback. * @return Constructed synced session object. */ @@ -2900,7 +2962,22 @@ namespace video { } // Load the initial image to prepare for encoding - if (session->convert(img)) { + if (ctx.config.input_only) { + // Input-only sessions must never be seeded with captured content: the shared image can + // hold a live desktop frame (or be uninitialized), so convert a dedicated blank frame. + auto dummy_img = disp->alloc_img(); + if (!dummy_img || disp->dummy_img(dummy_img.get())) { + BOOST_LOG(error) << "Could not get dummy image for input only session"sv; + return std::nullopt; + } + + fill_blank_image(*dummy_img); + + if (session->convert(*dummy_img)) { + BOOST_LOG(error) << "Could not convert initial image"sv; + return std::nullopt; + } + } else if (session->convert(img)) { BOOST_LOG(error) << "Could not convert initial image"sv; return std::nullopt; } @@ -2975,68 +3052,133 @@ namespace video { synced_sessions.emplace_back(std::move(*synced_session)); } + // Status returned to the caller when the capture backend unwinds. auto ec = platf::capture_e::ok; - while (encode_session_ctx_queue.running()) { - auto push_captured_image_callback = [&](std::shared_ptr &&captured_image, bool frame_captured) { - while (encode_session_ctx_queue.peek()) { - auto encode_session_ctx = encode_session_ctx_queue.pop(); - if (!encode_session_ctx) { - return false; - } - synced_session_ctxs.emplace_back(std::make_unique(std::move(*encode_session_ctx))); + // True while at least one active session needs captured video frames. Input-only sessions + // are served blank frames and must not keep the capture backend running. + auto capture_needed = [&synced_sessions]() { + return std::any_of(std::begin(synced_sessions), std::end(synced_sessions), [](const sync_session_t &synced_session) { + return !synced_session.ctx->config.input_only; + }); + }; - auto encode_session = make_synced_session(disp.get(), encoder, *captured_image, *synced_session_ctxs.back()); - if (!encode_session) { - ec = platf::capture_e::capture_error; - return false; - } + // Creates encoder sessions for clients that joined since the last pass. + // Returns false when the queue was stopped or a session could not be created. + auto accept_pending_sessions = [&](platf::img_t &seed_img) -> bool { + while (encode_session_ctx_queue.peek()) { + auto encode_session_ctx = encode_session_ctx_queue.pop(); + if (!encode_session_ctx) { + return false; + } - synced_sessions.emplace_back(std::move(*encode_session)); + synced_session_ctxs.emplace_back(std::make_unique(std::move(*encode_session_ctx))); + + auto encode_session = make_synced_session(disp.get(), encoder, seed_img, *synced_session_ctxs.back()); + if (!encode_session) { + ec = platf::capture_e::capture_error; + return false; } - KITTY_WHILE_LOOP(auto pos = std::begin(synced_sessions), pos != std::end(synced_sessions), { - auto ctx = pos->ctx; - if (ctx->shutdown_event->peek()) { - // Let waiting thread know it can delete shutdown_event - ctx->join_event->raise(true); + synced_sessions.emplace_back(std::move(*encode_session)); + } - pos = synced_sessions.erase(pos); - synced_session_ctxs.erase(std::find_if(std::begin(synced_session_ctxs), std::end(synced_session_ctxs), [&ctx_p = ctx](auto &ctx) { - return ctx.get() == ctx_p; - })); + return true; + }; - if (synced_sessions.empty()) { - return false; - } + // Drops sessions whose stream has shut down. Returns false when the last session went away. + auto prune_shutdown_sessions = [&]() -> bool { + KITTY_WHILE_LOOP(auto pos = std::begin(synced_sessions), pos != std::end(synced_sessions), { + auto ctx = pos->ctx; + if (!ctx->shutdown_event->peek()) { + ++pos; + continue; + } - continue; - } + // Let waiting thread know it can delete shutdown_event + ctx->join_event->raise(true); - if (ctx->config.input_only) { - // Input-only sessions must never encode a real captured frame. The synced session - // already converted the black dummy image at setup time, so encode that on the - // first pass and again whenever the client requests an IDR, and otherwise idle. - bool serve_idr = ctx->idr_events->peek() != 0; - if (serve_idr) { - ctx->idr_events->pop(); - } + pos = synced_sessions.erase(pos); + synced_session_ctxs.erase(std::find_if(std::begin(synced_session_ctxs), std::end(synced_session_ctxs), [&ctx_p = ctx](auto &ctx) { + return ctx.get() == ctx_p; + })); + }) - if (serve_idr || ctx->frame_nr == 1) { - if (serve_idr) { - pos->session->request_idr_frame(); - } + return !synced_sessions.empty(); + }; - if (encode(ctx->frame_nr++, *pos->session, ctx->packets, ctx->channel_data, {})) { - BOOST_LOG(error) << "Could not encode dummy video packet"sv; - ctx->shutdown_event->raise(true); + // Emits the initial frame, or a requested refresh, for every input-only session. The frame + // content is the blank image converted when the session was created, so no captured video + // can leak to an input-only client. + auto serve_input_only_sessions = [&]() { + for (auto &synced_session : synced_sessions) { + auto ctx = synced_session.ctx; + if (!ctx->config.input_only) { + continue; + } - continue; - } + bool idr_requested = ctx->idr_events->peek(); + if (idr_requested) { + ctx->idr_events->pop(); + } - pos->session->request_normal_frame(); - } + if (!idr_requested && synced_session.encoded_initial_frame) { + continue; + } + + synced_session.session->request_idr_frame(); + if (encode(ctx->frame_nr++, *synced_session.session, ctx->packets, ctx->channel_data, {})) { + BOOST_LOG(error) << "Could not encode dummy video packet"sv; + ctx->shutdown_event->raise(true); + + continue; + } + + // Track it on the synced session, not on the context: the context survives display + // reinitialization, while a recreated encoder session needs a fresh initial IDR frame. + synced_session.encoded_initial_frame = true; + synced_session.session->request_normal_frame(); + } + }; + + while (encode_session_ctx_queue.running()) { + // Nothing to capture: keep input-only clients fed with blank frames instead of running + // the capture backend at full framerate. + if (!capture_needed()) { + if (!accept_pending_sessions(*img) || !prune_shutdown_sessions()) { + return ec != platf::capture_e::ok ? ec : encode_e::ok; + } + + serve_input_only_sessions(); + + // A failed blank-frame encode raises shutdown_event; prune right away so the + // session's join_event is released instead of after the next idle interval. + if (!prune_shutdown_sessions()) { + return ec != platf::capture_e::ok ? ec : encode_e::ok; + } + + if (switch_display_event->peek()) { + return encode_e::reinit; + } + + std::this_thread::sleep_for(50ms); + continue; + } + + // Set when the capture backend was unwound because no session needs video anymore. + bool unwind_for_idle = false; + + auto push_captured_image_callback = [&](std::shared_ptr &&captured_image, bool frame_captured) { + auto &seed_img = captured_image ? *captured_image : *img; + if (!accept_pending_sessions(seed_img) || !prune_shutdown_sessions()) { + return false; + } + + serve_input_only_sessions(); + KITTY_WHILE_LOOP(auto pos = std::begin(synced_sessions), pos != std::end(synced_sessions), { + auto ctx = pos->ctx; + if (ctx->config.input_only) { ++pos; continue; } @@ -3050,6 +3192,7 @@ namespace video { BOOST_LOG(error) << "Could not convert image"sv; ctx->shutdown_event->raise(true); + ++pos; continue; } @@ -3062,6 +3205,7 @@ namespace video { BOOST_LOG(error) << "Could not encode video packet"sv; ctx->shutdown_event->raise(true); + ++pos; continue; } @@ -3070,11 +3214,22 @@ namespace video { ++pos; }) + // Sessions whose convert() or encode() failed above are dropped here so their + // join_event is released without waiting for the next captured frame. + if (!prune_shutdown_sessions()) { + return false; + } + if (switch_display_event->peek()) { ec = platf::capture_e::reinit; return false; } + if (!capture_needed()) { + unwind_for_idle = true; + return false; + } + return true; }; @@ -3085,6 +3240,13 @@ namespace video { }; auto status = disp->capture(push_captured_image_callback, pull_free_image_callback, &display_cursor); + + if (unwind_for_idle) { + // Only input-only sessions are left. Reinitialize so capture backends that tear down + // their state when capture() returns are in a clean state before we start idling. + return encode_e::reinit; + } + switch (status) { case platf::capture_e::reinit: case platf::capture_e::capture_error: diff --git a/src/video.h b/src/video.h index 641fd2a3a23..6ef24b043a6 100644 --- a/src/video.h +++ b/src/video.h @@ -246,6 +246,22 @@ namespace video { */ std::optional match_frame_timestamp(frame_timestamp_queue_t ×tamps, int64_t pts); + /** + * @brief Fill an image buffer with opaque black pixels. + * + * Capture backends may hand out uninitialized buffers, or buffers with a zeroed alpha + * channel, for `dummy_img()`. Such a frame decodes as a green screen on some clients, so + * anything that is meant to be shown as a blank placeholder must be normalized first. + * + * Packed BGRA/xRGB images (`pixel_pitch == 4`) and planar NV12 images (`pixel_pitch == 1`, + * where the half-height interleaved UV plane directly follows the Y plane) are supported. + * Any other layout, or a descriptor that is unusable (no buffer, degenerate dimensions, or a + * row pitch too small to hold a row of pixels), is left untouched. + * + * @param img Image to fill in place. + */ + void fill_blank_image(platf::img_t &img); + /** * @brief Pixel formats supported by one encoder backend. */ diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp index 5cff436cf65..f578dcadfb2 100644 --- a/tests/unit/test_config.cpp +++ b/tests/unit/test_config.cpp @@ -235,6 +235,28 @@ TEST_F(ConfigPersistenceTest, ParsesDscpTaggingOption) { EXPECT_TRUE(config::stream.dscp_tagging); } +/** + * @brief Verify the input-only mode option parses enabled and disabled values. + */ +TEST_F(ConfigPersistenceTest, ParsesEnableInputOnlyModeOption) { + const auto original_enable_input_only_mode = config::input.enable_input_only_mode; + const auto restore_input_only_mode = util::fail_guard([original_enable_input_only_mode]() { + config::input.enable_input_only_mode = original_enable_input_only_mode; + }); + + const auto apps_file = config_file().parent_path() / "sunshine_test_config_apps.json"; + ASSERT_EQ(file_handler::write_file(apps_file.string().c_str(), "{}"), 0); + config::stream.file_apps = apps_file.string(); + + EXPECT_FALSE(config::input.enable_input_only_mode); + + config::apply_config_for_test("enable_input_only_mode = enabled\n"); + EXPECT_TRUE(config::input.enable_input_only_mode); + + config::apply_config_for_test("enable_input_only_mode = disabled\n"); + EXPECT_FALSE(config::input.enable_input_only_mode); +} + /** * @brief Verify mixed-case boolean values update configuration correctly. */ diff --git a/tests/unit/test_process.cpp b/tests/unit/test_process.cpp index c16757f88c4..e0d9e41e8d0 100644 --- a/tests/unit/test_process.cpp +++ b/tests/unit/test_process.cpp @@ -849,8 +849,8 @@ TEST_F(ProcessRefreshTest, FileTimeType_DefaultValueComparison) { */ class ProcessInputOnlyTest: public ProcessRefreshTest { // NOSONAR(cpp:S3656): protected members are intentional for test fixture subclassing protected: - bool saved_enable_input_only_mode; - std::string saved_input_only_app_id; + bool saved_enable_input_only_mode; ///< Input-only mode setting restored after each test. + std::string saved_input_only_app_id; ///< Cached placeholder app id restored after each test. void SetUp() override { ProcessRefreshTest::SetUp(); @@ -863,6 +863,47 @@ class ProcessInputOnlyTest: public ProcessRefreshTest { // NOSONAR(cpp:S3656): proc::input_only_app_id = saved_input_only_app_id; ProcessRefreshTest::TearDown(); } + + /** + * @brief Build a context matching the synthetic "Input Only" placeholder. + * + * @param id Identifier of the placeholder entry. + * @return Context that launches no command and streams no captured video. + */ + static proc::ctx_t make_placeholder_ctx(const std::string &id) { + proc::ctx_t ctx; + ctx.name = "Input Only"; + ctx.id = id; + ctx.auto_detach = true; + ctx.wait_all = true; + ctx.exit_timeout = std::chrono::seconds {5}; + ctx.input_only = true; + return ctx; + } + + /** + * @brief Build a context matching an ordinary application. + * + * @param id Identifier of the application entry. + * @return Context that launches no command and captures video normally. + */ + static proc::ctx_t make_regular_ctx(const std::string &id) { + proc::ctx_t ctx; + ctx.name = "Desktop"; + ctx.id = id; + return ctx; + } + + /** + * @brief Create a process manager owning only the supplied application contexts. + * + * @param apps Application contexts owned by the manager. + * @return Process manager using the current environment and the supplied apps. + */ + static proc::proc_t make_process(std::vector apps) { + boost::process::v1::environment env = boost::this_process::environment(); + return proc::proc_t {std::move(env), std::move(apps)}; + } }; TEST_F(ProcessInputOnlyTest, Parse_AddsInputOnlyEntryWhenEnabled) { @@ -882,11 +923,14 @@ TEST_F(ProcessInputOnlyTest, Parse_AddsInputOnlyEntryWhenEnabled) { EXPECT_EQ(entry.name, "Input Only"); EXPECT_TRUE(entry.cmd.empty()); EXPECT_FALSE(entry.id.empty()); + EXPECT_TRUE(entry.input_only); EXPECT_EQ(proc::input_only_app_id, entry.id); // Existing apps keep stable IDs regardless of the input-only flag EXPECT_EQ(apps[0].name, "Game One"); + EXPECT_FALSE(apps[0].input_only); EXPECT_EQ(apps[1].name, "Game Two"); + EXPECT_FALSE(apps[1].input_only); } TEST_F(ProcessInputOnlyTest, Parse_OmitsInputOnlyEntryWhenDisabled) { @@ -926,3 +970,67 @@ TEST_F(ProcessInputOnlyTest, Parse_KeepsExistingAppIdsStableWhenToggled) { EXPECT_EQ(enabled_apps[i].id, disabled_apps[i].id); } } + +TEST_F(ProcessInputOnlyTest, IsInputOnlyRunning_FollowsPlaceholderLifetime) { + auto process = make_process({make_placeholder_ctx("777")}); + + EXPECT_FALSE(process.is_input_only_running()); + + ASSERT_EQ(process.execute(777, std::make_shared()), 0); + + // The placeholder launches no command, so it stays running via the placebo flag + EXPECT_EQ(process.running(), 777); + EXPECT_TRUE(process.is_input_only_running()); + + process.terminate(); + + EXPECT_EQ(process.running(), 0); + EXPECT_FALSE(process.is_input_only_running()); + EXPECT_TRUE(process.get_last_run_app_name().empty()); +} + +TEST_F(ProcessInputOnlyTest, IsInputOnlyRunning_FalseForRegularApp) { + auto process = make_process({make_regular_ctx("778")}); + + ASSERT_EQ(process.execute(778, std::make_shared()), 0); + EXPECT_EQ(process.running(), 778); + EXPECT_FALSE(process.is_input_only_running()); + + process.terminate(); + EXPECT_FALSE(process.is_input_only_running()); +} + +TEST_F(ProcessInputOnlyTest, IsInputOnlyRunning_IgnoredWhenModeToggledOffMidSession) { + auto process = make_process({make_placeholder_ctx("779")}); + ASSERT_EQ(process.execute(779, std::make_shared()), 0); + ASSERT_TRUE(process.is_input_only_running()); + + // Disabling the mode clears the cached id, but a running placeholder must stay recognized + config::input.enable_input_only_mode = false; + proc::input_only_app_id.clear(); + + EXPECT_TRUE(process.is_input_only_running()); + EXPECT_TRUE(proc::get_input_only_app_id().empty()); + + process.terminate(); + EXPECT_FALSE(process.is_input_only_running()); +} + +TEST_F(ProcessInputOnlyTest, ExecuteRealAppOverPlaceholderClearsInputOnlyState) { + std::vector apps; + apps.push_back(make_placeholder_ctx("780")); + apps.push_back(make_regular_ctx("781")); + + auto process = make_process(std::move(apps)); + ASSERT_EQ(process.execute(780, std::make_shared()), 0); + ASSERT_TRUE(process.is_input_only_running()); + + // Launching a real app terminates the placeholder, which must not leave the + // input-only state behind for the newly launched app. + ASSERT_EQ(process.execute(781, std::make_shared()), 0); + EXPECT_EQ(process.running(), 781); + EXPECT_FALSE(process.is_input_only_running()); + + process.terminate(); + EXPECT_FALSE(process.is_input_only_running()); +} diff --git a/tests/unit/test_video.cpp b/tests/unit/test_video.cpp index 8bb2991d0da..685258d7ad4 100644 --- a/tests/unit/test_video.cpp +++ b/tests/unit/test_video.cpp @@ -655,3 +655,141 @@ TEST_F(FrameTimestampQueueTest, ConsumesEntriesOnce) { EXPECT_EQ(ts(1), video::match_frame_timestamp(queue, 1)); EXPECT_EQ(std::nullopt, video::match_frame_timestamp(queue, 1)); } + +namespace { + /** + * @brief Point an image descriptor at a caller-owned buffer. + * + * @param img Descriptor to populate. + * @param data Buffer holding the image data. + * @param width Image width in pixels. + * @param height Image height in pixels. + * @param pixel_pitch Bytes per pixel reported by the capture backend. + * @param row_pitch Bytes between consecutive image rows. + */ + void bind_image(platf::img_t &img, std::uint8_t *data, int width, int height, int pixel_pitch, int row_pitch) { + img.data = data; + img.width = width; + img.height = height; + img.pixel_pitch = pixel_pitch; + img.row_pitch = row_pitch; + } +} // namespace + +TEST(FillBlankImageTest, PacksOpaqueBlackForBgra) { + std::array buffer; + buffer.fill(0x11); + + platf::img_t img; + bind_image(img, buffer.data(), 2, 2, 4, 2 * 4); + + video::fill_blank_image(img); + + for (size_t pixel = 0; pixel < 4; ++pixel) { + EXPECT_EQ(buffer[pixel * 4 + 0], 0x00); + EXPECT_EQ(buffer[pixel * 4 + 1], 0x00); + EXPECT_EQ(buffer[pixel * 4 + 2], 0x00); + EXPECT_EQ(buffer[pixel * 4 + 3], 0xFF); + } +} + +TEST(FillBlankImageTest, PreservesPaddingBytesAfterEachBgraRow) { + // Two rows of two BGRA pixels padded to 12 bytes: the trailing 4 bytes must stay untouched. + std::array buffer; + buffer.fill(0x5A); + + platf::img_t img; + bind_image(img, buffer.data(), 2, 2, 4, 12); + + video::fill_blank_image(img); + + for (int row = 0; row < 2; ++row) { + EXPECT_EQ(buffer[row * 12 + 0], 0x00); + EXPECT_EQ(buffer[row * 12 + 7], 0xFF); + EXPECT_EQ(buffer[row * 12 + 8], 0x5A); + EXPECT_EQ(buffer[row * 12 + 11], 0x5A); + } +} + +TEST(FillBlankImageTest, UsesNeutralChromaForNv12) { + // 2x2 NV12: a 4-byte Y plane followed by a 2-byte interleaved UV plane. + std::array buffer; + buffer.fill(0x33); + + platf::img_t img; + bind_image(img, buffer.data(), 2, 2, 1, 2); + + video::fill_blank_image(img); + + EXPECT_EQ(buffer[0], 0x00); + EXPECT_EQ(buffer[1], 0x00); + EXPECT_EQ(buffer[2], 0x00); + EXPECT_EQ(buffer[3], 0x00); + EXPECT_EQ(buffer[4], 0x80); + EXPECT_EQ(buffer[5], 0x80); +} + +TEST(FillBlankImageTest, InfersPixelPitchFromRowPitchWhenBackendOmitsIt) { + std::array buffer; + buffer.fill(0x77); + + platf::img_t img; + bind_image(img, buffer.data(), 2, 1, 0, 8); + + video::fill_blank_image(img); + + EXPECT_EQ(buffer[0], 0x00); + EXPECT_EQ(buffer[3], 0xFF); + EXPECT_EQ(buffer[4], 0x00); + EXPECT_EQ(buffer[7], 0xFF); +} + +TEST(FillBlankImageTest, LeavesUnusableDescriptorsAndUnknownLayoutsAlone) { + // An unsupported pixel pitch must not be guessed at. + std::array packed; + packed.fill(0x42); + + platf::img_t img; + bind_image(img, packed.data(), 2, 2, 2, 4); + + video::fill_blank_image(img); + + EXPECT_EQ(packed[0], 0x42); + EXPECT_EQ(packed[3], 0x42); + + // A descriptor without a buffer must be ignored rather than dereferenced. + platf::img_t without_data; + bind_image(without_data, nullptr, 2, 2, 4, 8); + video::fill_blank_image(without_data); + + // Degenerate dimensions must not divide by zero or run off the row. + std::array guard; + guard.fill(0x42); + + platf::img_t zero_width; + bind_image(zero_width, guard.data(), 0, 2, 0, 0); + video::fill_blank_image(zero_width); + + platf::img_t zero_height; + bind_image(zero_height, guard.data(), 2, 0, 4, 8); + video::fill_blank_image(zero_height); + + EXPECT_EQ(guard[0], 0x42); + EXPECT_EQ(guard[3], 0x42); + + // A row pitch smaller than width * pixel_pitch would run past the buffer. + std::array tight; + tight.fill(0x42); + + platf::img_t tight_bgra; + bind_image(tight_bgra, tight.data(), 2, 2, 4, 4); + video::fill_blank_image(tight_bgra); + + platf::img_t tight_nv12; + bind_image(tight_nv12, tight.data(), 2, 2, 1, 1); + video::fill_blank_image(tight_nv12); + + for (const auto byte : tight) { + EXPECT_EQ(byte, 0x42); + } +} diff --git a/tests/web/Configuration.test.js b/tests/web/Configuration.test.js index 04f7a2aa127..b4bcf29f912 100644 --- a/tests/web/Configuration.test.js +++ b/tests/web/Configuration.test.js @@ -237,4 +237,44 @@ describe('gamepad input configuration', () => { ]) expect(wrapper.get('#gamepad').element.value).toBe('auto') }) + + it('toggles input only mode between enabled and disabled', async () => { + const config = { + controller: 'enabled', + gamepad: 'auto', + gamepad_driver: '', + keybindings: '[]', + motion_as_ds4: 'enabled', + touchpad_as_ds4: 'enabled', + ds4_back_as_touchpad_click: 'enabled', + virtualhid_randomize_mac: 'enabled', + keyboard: 'enabled', + key_repeat_delay: 500, + key_repeat_frequency: 24.9, + always_send_scancodes: 'enabled', + key_rightalt_to_key_win: 'disabled', + mouse: 'enabled', + high_resolution_scrolling: 'enabled', + native_pen_touch: 'enabled', + enable_input_only_mode: 'disabled', + } + + const wrapper = mount(Inputs, { + props: { platform: 'linux', config }, + global: { + mocks: { $t: key => key }, + stubs: { Checkbox: false, VirtualKeyCodeSelect: true }, + }, + }) + + const input = wrapper.get('#enable_input_only_mode') + + expect(input.element.checked).toBe(false) + + await input.setValue(true) + expect(config.enable_input_only_mode).toBe('enabled') + + await input.setValue(false) + expect(config.enable_input_only_mode).toBe('disabled') + }) }) From e542f5acac5260e80b0c7f1afef1c5754680f07d Mon Sep 17 00:00:00 2001 From: sramteare <9433749+sramteare@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:28:22 -0500 Subject: [PATCH 4/5] input-only: avoid session teardown deadlock in placeholder cleanup proc_t::terminate() called rtsp_stream::session_count() to decide whether gamepads still have a live session to serve. That helper joins every stopping session, so reaching it from stream::session::join() (the last client of the input-only placeholder) made the teardown thread wait on itself until the 10 second hang watchdog aborted Sunshine. Use the non-blocking stream::session::running_session_count() snapshot instead. The counter is decremented before the placeholder cleanup runs, so gamepads are still terminated exactly when the last session is gone, while app-swap termination keeps them alive. --- src/process.cpp | 6 +++++- src/stream.cpp | 7 +++++++ src/stream.h | 10 ++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/process.cpp b/src/process.cpp index 438717b2cac..8ecc1a97f91 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -33,6 +33,7 @@ #include "logging.h" #include "platform/common.h" #include "process.h" +#include "stream.h" #include "system_tray.h" #include "utility.h" @@ -386,8 +387,11 @@ namespace proc { void proc_t::terminate() { // Gamepads of an input-only client must survive the placeholder being replaced by a real // app, but there is nothing left to keep once every session is gone. + // + // This must not use rtsp_stream::session_count(): it joins stopping sessions, which + // deadlocks when terminate() is reached from a session teardown thread. const bool input_only = _app.input_only; - if (!input_only || rtsp_stream::session_count() == 0) { + if (!input_only || stream::session::running_session_count() == 0) { input::terminate_gamepads(); } diff --git a/src/stream.cpp b/src/stream.cpp index ab8b91c7c6b..a797f951616 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -2274,6 +2274,13 @@ namespace stream { return session.client_cert; } + /** + * @brief Return the number of streaming sessions that have not finished teardown. + */ + std::uint32_t running_session_count() { + return running_sessions; + } + /** * @brief Stop the active streaming session and prevent new packets from being queued. */ diff --git a/src/stream.h b/src/stream.h index c0b0f962378..9e3a7a56ddb 100644 --- a/src/stream.h +++ b/src/stream.h @@ -5,6 +5,7 @@ #pragma once // standard includes +#include #include // lib includes @@ -94,5 +95,14 @@ namespace stream { * @return PEM certificate associated with the session's client. */ const std::string &client_cert(session_t &session); + /** + * @brief Return the number of streaming sessions that have not finished teardown. + * + * This is a non-blocking snapshot that is safe to call from a session teardown + * thread, unlike rtsp_stream::session_count() which joins stopping sessions. + * + * @return Number of live streaming sessions. + */ + std::uint32_t running_session_count(); } // namespace session } // namespace stream From aedaf68c8358952deace87742ee4b2136f779647 Mon Sep 17 00:00:00 2001 From: sramteare <9433749+sramteare@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:03:28 -0500 Subject: [PATCH 5/5] input-only: skip CPU fills on GPU images and snapshot resume state after cleanup fill_blank_image() now returns early when img_t::is_cpu_buffer() is false. On the Windows VRAM path data is an ID3D11Texture2D pointer, so the previous fill corrupted the texture object instead of writing pixels. Blank VRAM frames are already rendered black by the encoder conversion path. /resume now resolves the app and input-only snapshots after joining stopping sessions. The last input-only session's teardown can terminate the placeholder, and resuming the pre-cleanup state fell back to desktop capture. Adds regression coverage for opaque GPU images and for the resume snapshot taken after placeholder cleanup. --- src/nvhttp.cpp | 35 ++++++++++++++++++--------- src/nvhttp.h | 20 +++++++++++++++ src/platform/common.h | 14 +++++++++++ src/platform/windows/display_vram.cpp | 9 +++++++ src/video.cpp | 7 ++++++ src/video.h | 4 +++ tests/unit/test_process.cpp | 24 ++++++++++++++++++ tests/unit/test_video.cpp | 30 +++++++++++++++++++++++ 8 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index 7c2e63f3af2..210cb407774 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -1539,15 +1539,6 @@ namespace nvhttp { response->close_connection_after_response = true; }); - auto current_appid = proc::proc.running(); - if (current_appid == 0) { - tree.put("root.resume", 0); - tree.put("root..status_code", 503); - tree.put("root..status_message", "No running app to resume"); - - return; - } - auto args = request->parse_query_string(); if ( args.find("rikey"s) == std::end(args) || @@ -1560,10 +1551,24 @@ namespace nvhttp { return; } + // Clean up stopping sessions before taking the app and mode snapshots. The cleanup + // joins them synchronously, and the last input-only session's teardown terminates the + // placeholder. Reading the state afterwards keeps a reconnect from resuming an app + // that no longer exists and silently falling back to desktop capture. + const auto resume_state = resolve_resume_state(); + + if (resume_state.app_id == 0) { + tree.put("root.resume", 0); + tree.put("root..status_code", 503); + tree.put("root..status_message", "No running app to resume"); + + return; + } + // Newer Moonlight clients send localAudioPlayMode on /resume too, // so we should use it if it's present in the args and there are // no active sessions we could be interfering with. - const bool no_active_sessions {rtsp_stream::session_count() == 0}; + const bool no_active_sessions {resume_state.no_active_sessions}; if (no_active_sessions && args.find("localAudioPlayMode"s) != std::end(args)) { host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); } @@ -1572,7 +1577,7 @@ namespace nvhttp { // If only the synthetic "Input Only" placeholder is running, a resume must not // silently upgrade into a full desktop-capture session. The placeholder is recognized // even if input-only mode was turned off after it started. - launch_session->input_only = proc::proc.is_input_only_running(); + launch_session->input_only = resume_state.input_only; if (no_active_sessions) { // We want to prepare display only if there are no active sessions at @@ -1677,6 +1682,14 @@ namespace nvhttp { conf_intern.servercert = cert; } + resume_state_t resolve_resume_state() { + // Joining stopping sessions can terminate the last input-only placeholder, so the + // app and mode snapshots must be taken after the cleanup. + const bool no_active_sessions {rtsp_stream::session_count() == 0}; + + return {proc::proc.running(), proc::proc.is_input_only_running(), no_active_sessions}; + } + /** * @brief Check whether a paired client certificate is allowed to connect and retrieve its friendly name. * diff --git a/src/nvhttp.h b/src/nvhttp.h index a7c0fc9ad1a..8d9d9b97b4a 100644 --- a/src/nvhttp.h +++ b/src/nvhttp.h @@ -87,6 +87,26 @@ namespace nvhttp { */ void setup(const std::string &pkey, const std::string &cert); + /** + * @brief Snapshot of the host app state used to handle a resume request. + */ + struct resume_state_t { + int app_id {}; ///< Running app id, or 0 when no app is running. + bool input_only {}; ///< Whether the running app is the input-only placeholder. + bool no_active_sessions {}; ///< Whether no streaming sessions were active after cleanup. + }; + + /** + * @brief Resolve the app state for a resume request. + * + * Stopping sessions are joined first, because the last input-only session's teardown + * can terminate the placeholder. The snapshots must be taken afterwards so a reconnect + * cannot resume an app that no longer exists and fall back to desktop capture. + * + * @return Resolved app id, input-only mode, and active session state. + */ + resume_state_t resolve_resume_state(); + /** * @brief Simple-Web-Server HTTPS backend configured for Sunshine certificate handling. */ diff --git a/src/platform/common.h b/src/platform/common.h index b2616887be3..64fb5731ccf 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -547,6 +547,20 @@ namespace platf { std::optional frame_timestamp; ///< Capture timestamp associated with the frame. + /** + * @brief Report whether `data` points to a CPU-writable image buffer. + * + * Backends that expose an opaque GPU resource through `data` (for example the + * D3D11 VRAM capture path, where `data` is an `ID3D11Texture2D *`) return false + * so CPU-side pixel writes are skipped. Such backends produce blank frames + * through their own conversion path instead. + * + * @return True when pixels can be written directly through `data`. + */ + virtual bool is_cpu_buffer() const { + return true; + } + /** * @brief Destroy the image. */ diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp index 917c5c390ad..7aa615d01d0 100644 --- a/src/platform/windows/display_vram.cpp +++ b/src/platform/windows/display_vram.cpp @@ -189,6 +189,15 @@ namespace platf::dxgi { // DXGI format of this image texture DXGI_FORMAT format; ///< DXGI format of the captured texture. + /** + * @brief Report that `data` is an opaque D3D11 texture rather than CPU pixels. + * + * @return Always false; blank frames are produced by the conversion path. + */ + bool is_cpu_buffer() const override { + return false; + } + virtual ~img_d3d_t() override { if (encoder_texture_handle) { CloseHandle(encoder_texture_handle); diff --git a/src/video.cpp b/src/video.cpp index 5915fc7163e..09c47091ff8 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -2595,6 +2595,13 @@ namespace video { } void fill_blank_image(platf::img_t &img) { + // GPU-backed images expose an opaque resource through `data` (on Windows VRAM it + // is an ID3D11Texture2D pointer), so writing pixels here would corrupt it. Those + // backends convert their blank/dummy images to black through the encoder path. + if (!img.is_cpu_buffer()) { + return; + } + if (img.data == nullptr || img.width <= 0 || img.height <= 0 || img.row_pitch <= 0) { return; } diff --git a/src/video.h b/src/video.h index 6ef24b043a6..b2752c705ee 100644 --- a/src/video.h +++ b/src/video.h @@ -258,6 +258,10 @@ namespace video { * Any other layout, or a descriptor that is unusable (no buffer, degenerate dimensions, or a * row pitch too small to hold a row of pixels), is left untouched. * + * Images whose `data` is not a CPU-writable buffer (`img_t::is_cpu_buffer()` returns false) + * are skipped, because writing through an opaque GPU resource would corrupt it. Those + * backends render blank frames as black in their own conversion path. + * * @param img Image to fill in place. */ void fill_blank_image(platf::img_t &img); diff --git a/tests/unit/test_process.cpp b/tests/unit/test_process.cpp index e0d9e41e8d0..5ad58c24b5c 100644 --- a/tests/unit/test_process.cpp +++ b/tests/unit/test_process.cpp @@ -28,6 +28,7 @@ // local includes #include +#include #include #include @@ -1034,3 +1035,26 @@ TEST_F(ProcessInputOnlyTest, ExecuteRealAppOverPlaceholderClearsInputOnlyState) process.terminate(); EXPECT_FALSE(process.is_input_only_running()); } + +TEST_F(ProcessInputOnlyTest, ResolveResumeState_SnapshotsAfterPlaceholderCleanup) { + auto saved_global_proc = std::move(proc::proc); + auto restore_global_proc = util::fail_guard([&saved_global_proc]() { + proc::proc = std::move(saved_global_proc); + }); + + auto process = make_process({make_placeholder_ctx("901")}); + ASSERT_EQ(process.execute(901, std::make_shared()), 0); + proc::proc = std::move(process); + + auto state = nvhttp::resolve_resume_state(); + EXPECT_EQ(state.app_id, 901); + EXPECT_TRUE(state.input_only); + + // The last input-only session's teardown terminates the placeholder. A resume taken + // before that cleanup would still see it and fall back to desktop capture. + proc::proc.terminate(); + + state = nvhttp::resolve_resume_state(); + EXPECT_EQ(state.app_id, 0); + EXPECT_FALSE(state.input_only); +} diff --git a/tests/unit/test_video.cpp b/tests/unit/test_video.cpp index 685258d7ad4..3621cd7fa48 100644 --- a/tests/unit/test_video.cpp +++ b/tests/unit/test_video.cpp @@ -674,6 +674,20 @@ namespace { img.pixel_pitch = pixel_pitch; img.row_pitch = row_pitch; } + + /** + * @brief Image descriptor that exposes an opaque GPU resource through `data`. + */ + struct gpu_backed_img_t: platf::img_t { + /** + * @brief Report that `data` is not a CPU-writable buffer. + * + * @return Always false. + */ + bool is_cpu_buffer() const override { + return false; + } + }; } // namespace TEST(FillBlankImageTest, PacksOpaqueBlackForBgra) { @@ -793,3 +807,19 @@ TEST(FillBlankImageTest, LeavesUnusableDescriptorsAndUnknownLayoutsAlone) { EXPECT_EQ(byte, 0x42); } } + +TEST(FillBlankImageTest, SkipsOpaqueGpuBackedImages) { + // On the Windows VRAM path `data` is an ID3D11Texture2D pointer, so writing pixels + // through it would corrupt the texture object instead of filling the frame. + std::array buffer; + buffer.fill(0x3C); + + gpu_backed_img_t img; + bind_image(img, buffer.data(), 2, 2, 4, 2 * 4); + + video::fill_blank_image(img); + + for (const auto byte : buffer) { + EXPECT_EQ(byte, 0x3C); + } +}