diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 8b9468e3482..67e510ce123 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -33,6 +33,7 @@ #include "confighttp.h" #include "crypto.h" #include "display_device.h" +#include "entry_handler.h" #include "file_handler.h" #include "globals.h" #include "httpcommon.h" @@ -1833,6 +1834,9 @@ namespace confighttp { platf::set_thread_name("confighttp::tcp"); server->start([&display_addr](const unsigned short port) { BOOST_LOG(info) << "Configuration UI available at [https://"sv << display_addr << ":" << port << "]"; +#ifdef _WIN32 + service_ctrl::signal_ready(); +#endif }); } catch (boost::system::system_error &err) { // It's possible the exception gets thrown after calling server->stop() from a different thread diff --git a/src/entry_handler.cpp b/src/entry_handler.cpp index e5e18920b6b..0d248a035da 100644 --- a/src/entry_handler.cpp +++ b/src/entry_handler.cpp @@ -3,9 +3,13 @@ * @brief Definitions for entry handling functions. */ // standard includes +#include #include +#include +#include #include #include +#include #include // local includes @@ -121,6 +125,37 @@ bool is_gamestream_enabled() { } namespace service_ctrl { + /// Environment variable used to pass the inherited service readiness event handle. + constexpr auto SERVICE_READY_EVENT_ENV = L"SUNSHINE_SERVICE_READY_EVENT"; + + void signal_ready() { + std::wstring event_handle_text(32, L'\0'); + auto length = GetEnvironmentVariableW(SERVICE_READY_EVENT_ENV, event_handle_text.data(), static_cast(event_handle_text.size())); + if (length == 0 || length >= event_handle_text.size()) { + return; + } + + event_handle_text.resize(length); + wchar_t *end = nullptr; + auto event_handle_value = std::wcstoull(event_handle_text.c_str(), &end, 10); + if (event_handle_value == 0 || *end != L'\0') { + BOOST_LOG(warning) << "Ignoring invalid service ready event handle"; + return; + } + + auto ready_event_value = static_cast(event_handle_value); + auto ready_event = std::bit_cast(ready_event_value); + if (!SetEvent(ready_event)) { + auto winerr = GetLastError(); + BOOST_LOG(warning) << "Failed to signal service ready event: "sv << winerr; + SetEnvironmentVariableW(SERVICE_READY_EVENT_ENV, nullptr); + return; + } + + CloseHandle(ready_event); + SetEnvironmentVariableW(SERVICE_READY_EVENT_ENV, nullptr); + } + /** * @brief Owns Windows service-manager handles for the Sunshine service. */ diff --git a/src/entry_handler.h b/src/entry_handler.h index 32aba38e601..b3f9c6e95f0 100644 --- a/src/entry_handler.h +++ b/src/entry_handler.h @@ -119,6 +119,14 @@ bool is_gamestream_enabled(); * @brief Namespace for controlling the Sunshine service model on Windows. */ namespace service_ctrl { + /** + * @brief Signal the Windows service wrapper that Sunshine is ready. + * @examples + * signal_ready(); + * @examples_end + */ + void signal_ready(); + /** * @brief Check if the service is running. * @examples diff --git a/tools/sunshinesvc.cpp b/tools/sunshinesvc.cpp index 73d871b86cf..13595aa0e6b 100644 --- a/tools/sunshinesvc.cpp +++ b/tools/sunshinesvc.cpp @@ -3,6 +3,9 @@ * @brief Handles launching Sunshine.exe into user sessions as SYSTEM */ #define WIN32_LEAN_AND_MEAN +#include +#include +#include #include #include #include @@ -19,6 +22,59 @@ HANDLE stop_event; HANDLE session_change_event; constexpr auto SERVICE_NAME = "SunshineService"; +constexpr auto SERVICE_READY_EVENT_ENV = L"SUNSHINE_SERVICE_READY_EVENT"; +constexpr DWORD SERVICE_START_WAIT_HINT = 30 * 1000; +constexpr DWORD SUNSHINE_READY_TIMEOUT = 60 * 1000; +constexpr DWORD SUNSHINE_STARTUP_RETRY_LIMIT = 6; + +/** + * @brief Report to the Windows Service Control Manager that startup is still in progress. + */ +void ReportServiceStartPending() { + service_status.dwCurrentState = SERVICE_START_PENDING; + service_status.dwControlsAccepted = 0; + service_status.dwWin32ExitCode = NO_ERROR; + service_status.dwWaitHint = SERVICE_START_WAIT_HINT; + ++service_status.dwCheckPoint; + SetServiceStatus(service_status_handle, &service_status); +} + +/** + * @brief Report to the Windows Service Control Manager that the service is running. + */ +void ReportServiceRunning() { + service_status.dwControlsAccepted = SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_PRESHUTDOWN | SERVICE_ACCEPT_SESSIONCHANGE; + service_status.dwCurrentState = SERVICE_RUNNING; + service_status.dwWin32ExitCode = NO_ERROR; + service_status.dwWaitHint = 0; + service_status.dwCheckPoint = 0; + SetServiceStatus(service_status_handle, &service_status); +} + +/** + * @brief Report to the Windows Service Control Manager that the service has stopped. + * + * @param win32_exit_code Exit code reported for the stopped service. + */ +void ReportServiceStopped(DWORD win32_exit_code = NO_ERROR) { + service_status.dwControlsAccepted = 0; + service_status.dwCurrentState = SERVICE_STOPPED; + service_status.dwWin32ExitCode = win32_exit_code; + service_status.dwWaitHint = 0; + service_status.dwCheckPoint = 0; + SetServiceStatus(service_status_handle, &service_status); +} + +/** + * @brief State observed while waiting for Sunshine.exe to signal readiness. + */ +enum class SunshineReadyResult { + ready, ///< Sunshine.exe signaled that its Web UI is accepting connections. + stop, ///< The service received a stop request. + exited, ///< Sunshine.exe exited before signaling readiness. + session_changed, ///< The active console session changed before readiness. + timed_out ///< Sunshine.exe did not signal readiness before the timeout. +}; DWORD WINAPI HandlerEx(DWORD dwControl, DWORD dwEventType, LPVOID lpEventData, LPVOID lpContext) { switch (dwControl) { @@ -28,7 +84,7 @@ DWORD WINAPI HandlerEx(DWORD dwControl, DWORD dwEventType, LPVOID lpEventData, L case SERVICE_CONTROL_SESSIONCHANGE: // If a new session connects to the console, restart Sunshine // to allow it to spawn inside the new console session. - if (dwEventType == WTS_CONSOLE_CONNECT) { + if (dwEventType == WTS_CONSOLE_CONNECT && session_change_event != nullptr) { SetEvent(session_change_event); } return NO_ERROR; @@ -43,7 +99,9 @@ DWORD WINAPI HandlerEx(DWORD dwControl, DWORD dwEventType, LPVOID lpEventData, L SetServiceStatus(service_status_handle, &service_status); // Trigger ServiceMain() to start cleanup - SetEvent(stop_event); + if (stop_event != nullptr) { + SetEvent(stop_event); + } return NO_ERROR; default: @@ -166,6 +224,134 @@ bool RunTerminationHelper(HANDLE console_token, DWORD pid) { return exit_code == 0; } +/** + * @brief Gracefully terminate Sunshine.exe, falling back to forced termination. + * + * @param console_token Token for the console session where Sunshine.exe is running. + * @param process_info Process information for the Sunshine.exe child process. + */ +void TerminateSunshineProcess(HANDLE console_token, PROCESS_INFORMATION &process_info) { + // Try to gracefully terminate Sunshine.exe. If it doesn't terminate in 20 seconds, forcefully terminate it. + if (!RunTerminationHelper(console_token, process_info.dwProcessId) || WaitForSingleObject(process_info.hProcess, 20000) != WAIT_OBJECT_0) { + TerminateProcess(process_info.hProcess, ERROR_PROCESS_ABORTED); + } +} + +/** + * @brief Wait for Sunshine.exe to signal that it has completed startup. + * + * @param process_handle Process handle for the Sunshine.exe child process. + * @param ready_event Event signaled by Sunshine.exe after the Web UI starts listening. + * @param console_session_id Console session ID where Sunshine.exe was launched. + * @param report_start_pending Whether to keep reporting SERVICE_START_PENDING while waiting. + * @return Observed child startup state. + */ +SunshineReadyResult WaitForSunshineReady(HANDLE process_handle, HANDLE ready_event, DWORD console_session_id, bool report_start_pending) { + const auto started_at = GetTickCount64(); + using enum SunshineReadyResult; + + while (true) { + if (report_start_pending) { + ReportServiceStartPending(); + } + + const auto elapsed = GetTickCount64() - started_at; + if (elapsed >= SUNSHINE_READY_TIMEOUT) { + return SunshineReadyResult::timed_out; + } + + const auto remaining = SUNSHINE_READY_TIMEOUT - elapsed; + const auto wait_time = static_cast(remaining > 3000 ? 3000 : remaining); + const std::array wait_objects {stop_event, process_handle, session_change_event, ready_event}; + + switch (WaitForMultipleObjects(static_cast(wait_objects.size()), wait_objects.data(), FALSE, wait_time)) { + case WAIT_OBJECT_0: + return stop; + + case WAIT_OBJECT_0 + 1: + return exited; + + case WAIT_OBJECT_0 + 2: + if (WTSGetActiveConsoleSessionId() == console_session_id) { + continue; + } + return session_changed; + + case WAIT_OBJECT_0 + 3: + return ready; + + case WAIT_TIMEOUT: + continue; + + default: + return timed_out; + } + } +} + +/** + * @brief Release service-wide handles and the process attribute list. + * + * @param startup_info Startup info containing the optional process attribute list. + * @param ready_event Event handle passed to Sunshine.exe for readiness signaling. + * @param log_file_handle Log file handle inherited by Sunshine.exe. + */ +void CleanupServiceResources(STARTUPINFOEXW &startup_info, HANDLE &ready_event, HANDLE &log_file_handle) { + if (startup_info.lpAttributeList != nullptr) { + DeleteProcThreadAttributeList(startup_info.lpAttributeList); + HeapFree(GetProcessHeap(), 0, startup_info.lpAttributeList); + startup_info.lpAttributeList = nullptr; + } + + if (ready_event != nullptr) { + CloseHandle(ready_event); + ready_event = nullptr; + } + + if (log_file_handle != INVALID_HANDLE_VALUE) { + CloseHandle(log_file_handle); + log_file_handle = INVALID_HANDLE_VALUE; + } + + if (session_change_event != nullptr) { + CloseHandle(session_change_event); + session_change_event = nullptr; + } + + if (stop_event != nullptr) { + CloseHandle(stop_event); + stop_event = nullptr; + } +} + +/** + * @brief Clean up service resources and report startup failure to the Service Control Manager. + * + * @param startup_info Startup info containing the optional process attribute list. + * @param ready_event Event handle passed to Sunshine.exe for readiness signaling. + * @param log_file_handle Log file handle inherited by Sunshine.exe. + * @param win32_exit_code Exit code reported for the stopped service. + */ +void FailServiceStart(STARTUPINFOEXW &startup_info, HANDLE &ready_event, HANDLE &log_file_handle, DWORD win32_exit_code) { + CleanupServiceResources(startup_info, ready_event, log_file_handle); + ReportServiceStopped(win32_exit_code); +} + +/** + * @brief Convert a child process startup exit state into a service failure code. + * + * @param process_handle Process handle for the Sunshine.exe child process. + * @return Exit code to report for the failed startup attempt. + */ +DWORD GetSunshineStartupFailureCode(HANDLE process_handle) { + DWORD exit_code = ERROR_PROCESS_ABORTED; + if (GetExitCodeProcess(process_handle, &exit_code) && exit_code == ERROR_SHUTDOWN_IN_PROGRESS) { + SetEvent(stop_event); + } + + return exit_code == NO_ERROR ? ERROR_PROCESS_ABORTED : exit_code; +} + VOID WINAPI ServiceMain(DWORD dwArgc, LPTSTR *lpszArgv) { service_status_handle = RegisterServiceCtrlHandlerEx(SERVICE_NAME, HandlerEx, nullptr); if (service_status_handle == nullptr) { @@ -174,47 +360,49 @@ VOID WINAPI ServiceMain(DWORD dwArgc, LPTSTR *lpszArgv) { return; } + auto log_file_handle = INVALID_HANDLE_VALUE; + auto ready_event = static_cast(nullptr); + std::array inherited_handles {}; + + STARTUPINFOEXW startup_info = {}; + // Tell SCM we're starting service_status.dwServiceType = SERVICE_WIN32_OWN_PROCESS; service_status.dwServiceSpecificExitCode = 0; service_status.dwWin32ExitCode = NO_ERROR; - service_status.dwWaitHint = 0; + service_status.dwWaitHint = SERVICE_START_WAIT_HINT; service_status.dwControlsAccepted = 0; service_status.dwCheckPoint = 0; - service_status.dwCurrentState = SERVICE_START_PENDING; - SetServiceStatus(service_status_handle, &service_status); + ReportServiceStartPending(); // Create a manual-reset stop event stop_event = CreateEventA(nullptr, TRUE, FALSE, nullptr); if (stop_event == nullptr) { - // Tell SCM we failed to start - service_status.dwWin32ExitCode = GetLastError(); - service_status.dwCurrentState = SERVICE_STOPPED; - SetServiceStatus(service_status_handle, &service_status); + FailServiceStart(startup_info, ready_event, log_file_handle, GetLastError()); return; } // Create an auto-reset session change event session_change_event = CreateEventA(nullptr, FALSE, FALSE, nullptr); if (session_change_event == nullptr) { - // Tell SCM we failed to start - service_status.dwWin32ExitCode = GetLastError(); - service_status.dwCurrentState = SERVICE_STOPPED; - SetServiceStatus(service_status_handle, &service_status); + FailServiceStart(startup_info, ready_event, log_file_handle, GetLastError()); return; } - auto log_file_handle = OpenLogFileHandle(); + log_file_handle = OpenLogFileHandle(); if (log_file_handle == INVALID_HANDLE_VALUE) { - // Tell SCM we failed to start - service_status.dwWin32ExitCode = GetLastError(); - service_status.dwCurrentState = SERVICE_STOPPED; - SetServiceStatus(service_status_handle, &service_status); + FailServiceStart(startup_info, ready_event, log_file_handle, GetLastError()); return; } - // We can use a single STARTUPINFOEXW for all the processes that we launch - STARTUPINFOEXW startup_info = {}; + SECURITY_ATTRIBUTES ready_event_security_attributes = {sizeof(ready_event_security_attributes), nullptr, TRUE}; + ready_event = CreateEventW(&ready_event_security_attributes, TRUE, FALSE, nullptr); + if (ready_event == nullptr) { + FailServiceStart(startup_info, ready_event, log_file_handle, GetLastError()); + return; + } + + // We can use a single STARTUPINFOEXW for all the processes that we launch. startup_info.StartupInfo.cb = sizeof(startup_info); startup_info.StartupInfo.lpDesktop = (LPWSTR) L"winsta0\\default"; startup_info.StartupInfo.dwFlags = STARTF_USESTDHANDLES; @@ -225,23 +413,29 @@ VOID WINAPI ServiceMain(DWORD dwArgc, LPTSTR *lpszArgv) { // Allocate an attribute list with space for 2 entries startup_info.lpAttributeList = AllocateProcThreadAttributeList(2); if (startup_info.lpAttributeList == nullptr) { - // Tell SCM we failed to start - service_status.dwWin32ExitCode = GetLastError(); - service_status.dwCurrentState = SERVICE_STOPPED; - SetServiceStatus(service_status_handle, &service_status); + FailServiceStart(startup_info, ready_event, log_file_handle, GetLastError()); + return; + } + + // Only allow Sunshine.exe to inherit the log file and ready event handles, not all inheritable handles. + inherited_handles = {log_file_handle, ready_event}; + if (!UpdateProcThreadAttribute(startup_info.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited_handles.data(), inherited_handles.size() * sizeof(HANDLE), nullptr, nullptr)) { + FailServiceStart(startup_info, ready_event, log_file_handle, GetLastError()); return; } - // Only allow Sunshine.exe to inherit the log file handle, not all inheritable handles - UpdateProcThreadAttribute(startup_info.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &log_file_handle, sizeof(log_file_handle), nullptr, nullptr); + bool service_running = false; + bool service_should_stop = false; + DWORD startup_failures = 0; + DWORD startup_failure_code = NO_ERROR; + using enum SunshineReadyResult; - // Tell SCM we're running (and stoppable now) - service_status.dwControlsAccepted = SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_PRESHUTDOWN | SERVICE_ACCEPT_SESSIONCHANGE; - service_status.dwCurrentState = SERVICE_RUNNING; - SetServiceStatus(service_status_handle, &service_status); + // Loop every 3 seconds until the stop event is set or Sunshine.exe is running. + while (!service_should_stop && WaitForSingleObject(stop_event, 3000) != WAIT_OBJECT_0) { + if (!service_running) { + ReportServiceStartPending(); + } - // Loop every 3 seconds until the stop event is set or Sunshine.exe is running - while (WaitForSingleObject(stop_event, 3000) != WAIT_OBJECT_0) { auto console_session_id = WTSGetActiveConsoleSessionId(); if (console_session_id == 0xFFFFFFFF) { // No console session yet @@ -261,15 +455,89 @@ VOID WINAPI ServiceMain(DWORD dwArgc, LPTSTR *lpszArgv) { } // Start Sunshine.exe inside our job object - UpdateProcThreadAttribute(startup_info.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job_handle, sizeof(job_handle), nullptr, nullptr); + if (!UpdateProcThreadAttribute(startup_info.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job_handle, sizeof(job_handle), nullptr, nullptr)) { + startup_failure_code = GetLastError(); + CloseHandle(console_token); + CloseHandle(job_handle); + service_should_stop = true; + continue; + } + + ResetEvent(ready_event); + + if (const auto ready_event_handle_text = std::format(L"{}", std::bit_cast(ready_event)); !SetEnvironmentVariableW(SERVICE_READY_EVENT_ENV, ready_event_handle_text.c_str())) { + startup_failure_code = GetLastError(); + CloseHandle(console_token); + CloseHandle(job_handle); + service_should_stop = true; + continue; + } - PROCESS_INFORMATION process_info; + PROCESS_INFORMATION process_info = {}; if (!CreateProcessAsUserW(console_token, L"Sunshine.exe", nullptr, nullptr, nullptr, TRUE, CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, nullptr, nullptr, (LPSTARTUPINFOW) &startup_info, &process_info)) { + startup_failure_code = GetLastError(); + SetEnvironmentVariableW(SERVICE_READY_EVENT_ENV, nullptr); + CloseHandle(console_token); + CloseHandle(job_handle); + if (++startup_failures >= SUNSHINE_STARTUP_RETRY_LIMIT) { + service_should_stop = true; + } + continue; + } + + SetEnvironmentVariableW(SERVICE_READY_EVENT_ENV, nullptr); + + if (auto ready_result = WaitForSunshineReady(process_info.hProcess, ready_event, console_session_id, !service_running); ready_result != ready) { + if (ready_result == stop || ready_result == session_changed || ready_result == timed_out) { + TerminateSunshineProcess(console_token, process_info); + } + + if (ready_result == stop) { + CloseHandle(process_info.hThread); + CloseHandle(process_info.hProcess); + CloseHandle(console_token); + CloseHandle(job_handle); + service_should_stop = true; + continue; + } + + if (ready_result == session_changed) { + startup_failures = 0; + CloseHandle(process_info.hThread); + CloseHandle(process_info.hProcess); + CloseHandle(console_token); + CloseHandle(job_handle); + continue; + } + + startup_failure_code = ready_result == timed_out ? ERROR_TIMEOUT : GetSunshineStartupFailureCode(process_info.hProcess); + + CloseHandle(process_info.hThread); + CloseHandle(process_info.hProcess); CloseHandle(console_token); CloseHandle(job_handle); + + if (WaitForSingleObject(stop_event, 0) == WAIT_OBJECT_0) { + service_should_stop = true; + } + + if (!service_should_stop) { + ++startup_failures; + } + + if (startup_failures >= SUNSHINE_STARTUP_RETRY_LIMIT) { + service_should_stop = true; + } continue; } + startup_failures = 0; + startup_failure_code = NO_ERROR; + if (!service_running) { + ReportServiceRunning(); + service_running = true; + } + bool still_running; do { // Wait for the stop event to be set, Sunshine.exe to terminate, or the console session to change @@ -284,12 +552,7 @@ VOID WINAPI ServiceMain(DWORD dwArgc, LPTSTR *lpszArgv) { // Fall-through to terminate Sunshine.exe and start it again. case WAIT_OBJECT_0: // The service is shutting down, so try to gracefully terminate Sunshine.exe. - // If it doesn't terminate in 20 seconds, we will forcefully terminate it. - if (!RunTerminationHelper(console_token, process_info.dwProcessId) || - WaitForSingleObject(process_info.hProcess, 20000) != WAIT_OBJECT_0) { - // If it won't terminate gracefully, kill it now - TerminateProcess(process_info.hProcess, ERROR_PROCESS_ABORTED); - } + TerminateSunshineProcess(console_token, process_info); still_running = false; break; @@ -314,9 +577,9 @@ VOID WINAPI ServiceMain(DWORD dwArgc, LPTSTR *lpszArgv) { CloseHandle(job_handle); } - // Let SCM know we've stopped - service_status.dwCurrentState = SERVICE_STOPPED; - SetServiceStatus(service_status_handle, &service_status); + // Let SCM know we've stopped. + CleanupServiceResources(startup_info, ready_event, log_file_handle); + ReportServiceStopped(startup_failure_code); } // This will run in a child process in the user session