From 0d10cae1b91cc75dd1df46e17f353b0e8e192ca4 Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Sun, 16 Aug 2026 17:43:33 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(ds5):=20sidecar=20=E6=A0=A1=E9=AA=8C=20?= =?UTF-8?q?owner=20=E6=8F=90=E6=9D=83=E5=B9=B6=E4=BF=AE=E6=AD=A3=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E4=BD=8D=E4=B8=8E=E6=B5=81=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 连接建立时校验管道客户端进程已提权;同用户非提权进程即使抢到 单实例管道也无法驱动 elevated sidecar 安装驱动或创建虚拟 HID - HelloReply 仅在 composite profile 与 usbip 后端实际可用时宣告 四声道/authored haptics 能力位 - StreamStart 标记先于 streaming 状态置位,消除首包漏标竞态 - OnAudioFrames 缓存不足一帧的残余字节,不再静默丢样本 --- .../sunshine-ds5-sidecar/ControllerSession.cs | 29 +++++++- .../sunshine-ds5-sidecar/OwnerVerification.cs | 66 +++++++++++++++++++ tools/sunshine-ds5-sidecar/README.md | 4 +- tools/sunshine-ds5-sidecar/SidecarServer.cs | 16 ++++- 4 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 tools/sunshine-ds5-sidecar/OwnerVerification.cs diff --git a/tools/sunshine-ds5-sidecar/ControllerSession.cs b/tools/sunshine-ds5-sidecar/ControllerSession.cs index e0628bed..48a52b59 100644 --- a/tools/sunshine-ds5-sidecar/ControllerSession.cs +++ b/tools/sunshine-ds5-sidecar/ControllerSession.cs @@ -42,6 +42,7 @@ internal sealed class ControllerSession : IDisposable private int _hapticsStreaming; private int _hapticsNeedsStart; private int _disposed; + private byte[] _audioResidual = Array.Empty(); internal ControllerSession(byte deviceId, byte clientControllerNumber, @@ -224,17 +225,41 @@ private void OnOutputDecoded(object? sender, HMOutputDecodedEventArgs output) private void OnAudioStreamingChanged(object? sender, bool streaming) { - Interlocked.Exchange(ref _hapticsStreaming, streaming ? 1 : 0); if (streaming) + { + // Arm the start marker before publishing the streaming flag, or a + // frame racing this callback could be emitted mid-stream without + // the StreamStart marker the client resets on. Interlocked.Exchange(ref _hapticsNeedsStart, 1); + Interlocked.Exchange(ref _hapticsStreaming, 1); + } else + { + Interlocked.Exchange(ref _hapticsStreaming, 0); EmitHaptics(ReadOnlySpan.Empty, 0, Protocol.HapticsFlags.StreamEnd); + } } private void OnAudioFrames(object? sender, ReadOnlyMemory pcm) { - var source = pcm.Span; + // Only the USB audio output thread raises this callback, so the + // residual carry is not guarded by a lock. + byte[] combined; + if (_audioResidual.Length == 0) + { + combined = pcm.ToArray(); + } + else + { + combined = new byte[_audioResidual.Length + pcm.Length]; + _audioResidual.AsSpan().CopyTo(combined); + pcm.Span.CopyTo(combined.AsSpan(_audioResidual.Length)); + } var sourceFrameBytes = AudioInputChannels * BytesPerSample; + var usableBytes = combined.Length - combined.Length % sourceFrameBytes; + _audioResidual = usableBytes == combined.Length ? Array.Empty() : combined[usableBytes..]; + + var source = combined.AsSpan(0, usableBytes); var frameCount = source.Length / sourceFrameBytes; var offsetFrames = 0; while (offsetFrames < frameCount) diff --git a/tools/sunshine-ds5-sidecar/OwnerVerification.cs b/tools/sunshine-ds5-sidecar/OwnerVerification.cs new file mode 100644 index 00000000..7eb77ffd --- /dev/null +++ b/tools/sunshine-ds5-sidecar/OwnerVerification.cs @@ -0,0 +1,66 @@ +using System.IO.Pipes; +using System.Runtime.InteropServices; + +namespace Sunshine.Ds5Sidecar; + +/// +/// The pipe ACL already restricts callers to the creating user. This check +/// additionally requires an elevated client so a non-elevated process of the +/// same user cannot drive the elevated sidecar (driver install, virtual HID +/// creation) even if it wins the single-instance pipe race. +/// +internal static class OwnerVerification +{ + private const uint ProcessQueryLimitedInformation = 0x1000; + private const uint TokenQuery = 0x0008; + private const int TokenElevation = 20; + + internal static bool ClientIsElevated(NamedPipeServerStream pipe) + { + if (!GetNamedPipeClientProcessId(pipe.SafePipeHandle.DangerousGetHandle(), out var clientId)) + return false; + // The protocol self-test connects from inside this process; Program + // refuses to run it unelevated. + if (clientId == (uint)Environment.ProcessId) + return true; + + var process = OpenProcess(ProcessQueryLimitedInformation, false, clientId); + if (process == IntPtr.Zero) + return false; + try + { + if (!OpenProcessToken(process, TokenQuery, out var token) || token == IntPtr.Zero) + return false; + try + { + var elevation = new byte[4]; + return GetTokenInformation(token, TokenElevation, elevation, (uint)elevation.Length, out _) && + BitConverter.ToInt32(elevation, 0) != 0; + } + finally + { + CloseHandle(token); + } + } + finally + { + CloseHandle(process); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetNamedPipeClientProcessId(IntPtr pipe, out uint clientProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool OpenProcessToken(IntPtr process, uint desiredAccess, out IntPtr token); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetTokenInformation(IntPtr token, int informationClass, + byte[] information, uint informationLength, out uint returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/tools/sunshine-ds5-sidecar/README.md b/tools/sunshine-ds5-sidecar/README.md index c23f3bc2..a77a9cdb 100644 --- a/tools/sunshine-ds5-sidecar/README.md +++ b/tools/sunshine-ds5-sidecar/README.md @@ -18,6 +18,8 @@ dotnet Sunshine.Ds5Sidecar.dll --probe ``` The production process must be launched elevated and placed in the Sunshine -Job Object. Disconnecting the owning pipe disposes every device created by +Job Object. The pipe accepts a single connection from an elevated client of +the creating user; non-elevated callers are rejected at connect time. +Disconnecting the owning pipe disposes every device created by that connection. Standard `dualsense` uses UMDF2; `dualsense-composite` enables the USB composite HID/audio profile and authored haptics PCM. diff --git a/tools/sunshine-ds5-sidecar/SidecarServer.cs b/tools/sunshine-ds5-sidecar/SidecarServer.cs index 1b4af9f4..539b3fa2 100644 --- a/tools/sunshine-ds5-sidecar/SidecarServer.cs +++ b/tools/sunshine-ds5-sidecar/SidecarServer.cs @@ -10,6 +10,7 @@ internal sealed class SidecarServer : IAsyncDisposable private readonly string _pipeName; private readonly HMContext _context = new(); private readonly Dictionary _controllers = new(); + private readonly bool _authoredHapticsAvailable; private readonly Channel _controlOutgoing; private readonly Channel _realtimeOutgoing; private readonly SemaphoreSlim _outgoingSignal = new(0, 1); @@ -20,6 +21,8 @@ internal SidecarServer(string pipeName) { _pipeName = pipeName; _context.LoadDefaultProfiles(); + _authoredHapticsAvailable = _context.GetProfile("dualsense-composite") is not null && + HMContext.IsUsbipBackendAvailable; _controlOutgoing = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, @@ -46,6 +49,11 @@ internal async Task RunAsync(CancellationToken stoppingToken) 64 * 1024); _pipe = pipe; await pipe.WaitForConnectionAsync(stoppingToken); + if (!OwnerVerification.ClientIsElevated(pipe)) + { + Console.Error.WriteLine("Rejected a non-elevated DualSense sidecar pipe client"); + return; + } using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); _sessionCancellation = linked; var writer = WriteLoopAsync(pipe, linked.Token); @@ -121,12 +129,14 @@ private async Task ReadLoopAsync(Stream pipe, CancellationToken cancellationToke header.RequestId, Protocol.UInt32((uint)(Protocol.Capability.Hid | Protocol.Capability.Output | - Protocol.Capability.AudioFourChannel | - Protocol.Capability.AuthoredHapticsPcm | Protocol.Capability.Touchpad | Protocol.Capability.Motion | Protocol.Capability.Battery | - Protocol.Capability.AdaptiveTriggers)))); + Protocol.Capability.AdaptiveTriggers | + (_authoredHapticsAvailable + ? Protocol.Capability.AudioFourChannel | + Protocol.Capability.AuthoredHapticsPcm + : 0)))); break; case Protocol.MessageType.Attach: Attach(header.RequestId, payload); From 5f014ba7d437e3875a9d24315c0853b273c95e5e Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Sun, 16 Aug 2026 17:43:39 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(ds5):=20Core=20=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E5=86=99=E5=81=9C=E6=BB=9E=E4=B8=8A=E9=99=90=E4=B8=8E=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E5=A4=9A=E8=B7=AF=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据面写等待加 5 秒上限;写停滞时取消 reader 挂起读取并进入既有 单次恢复路径,sidecar 读循环阻塞不再冻结 Sunshine 输入线程 - transact 等待期间把乱序到达的 rumble/LED/自适应扳机/异步错误交给 正常分发而不是误判为回复失败;attach 事务前声明所有权,使期间 到达的反馈可投递到反馈队列 - fake sidecar 增加 attach 回复前插入异步 rumble 的回归场景 - 补 MAX_GAMEPADS 与单字节设备号的编译期断言,同步设计文档 --- docs/windows_dualsense_component_lifecycle.md | 4 +- .../windows/ds5/ds5_sidecar_client.cpp | 150 +++++++++++------- tests/tools/ds5_fake_sidecar.cpp | 8 + .../windows/test_ds5_sidecar_client.cpp | 35 ++++ 4 files changed, 141 insertions(+), 56 deletions(-) diff --git a/docs/windows_dualsense_component_lifecycle.md b/docs/windows_dualsense_component_lifecycle.md index 068dd038..2be95363 100644 --- a/docs/windows_dualsense_component_lifecycle.md +++ b/docs/windows_dualsense_component_lifecycle.md @@ -358,6 +358,8 @@ shutdown(owner_token) - `attach`、`update_input`、`subscribe_output` 和 `get_status` 只接受当前连接 owner;Core 持有 owner token,GUI 测试使用独立、低权限 test token。 - Sidecar 拒绝非所有者 detach 和 shutdown;连接断开会清理该 owner 创建的全部设备。 - 输出报告和音频数据使用有界队列;控制消息不得被高频数据饿死。 +- 已实现的 owner 校验(v1):管道 ACL 限定当前用户 + Sidecar 在连接建立时校验客户端进程的提权状态,非提权客户端拒绝并退出;同用户非提权进程即使抢到单实例管道也无法驱动 elevated Sidecar。GUI 低权限 test token 仍属后续工作。 +- 已实现的停滞保护(v1):Core 对数据面写操作设置 5 秒停滞上限;写停滞会取消 reader 的挂起读取并进入既有的单次恢复路径,sidecar 读循环阻塞不再冻结 Sunshine 输入线程。 高频四声道音频数据不应经 Tauri 或 JSON 传输。后续实现使用共享内存环形缓冲区或专用本地数据通道;Named Pipe 只负责控制和状态。 @@ -492,7 +494,7 @@ Core 维护引用计数,按 session ID 管理多客户端。第一阶段可以 3. 安装器只操作固定组件根目录,删除前解析并验证绝对路径仍位于该根目录。 4. Sidecar 启动路径必须来自已验证 active manifest。 5. 不把管理员权限传给常驻 Sidecar;提权 helper 只执行单个、结构化的驱动操作。 -6. Named Pipe ACL 仅允许当前用户、Sunshine 服务身份和管理员访问。 +6. Named Pipe ACL 仅允许当前用户、Sunshine 服务身份和管理员访问;Sidecar 在连接建立时校验客户端进程已提权,拒绝非提权连接。 7. 不按可执行文件名全局终止进程。 8. 每个异步操作只有一个 writer,并有 operation ID、取消状态和可恢复 snapshot。 9. GUI 收到的错误文本视为不可信数据,显示时转义;用户文案由稳定错误码映射。 diff --git a/src/platform/windows/ds5/ds5_sidecar_client.cpp b/src/platform/windows/ds5/ds5_sidecar_client.cpp index 932f9924..75c7aabe 100644 --- a/src/platform/windows/ds5/ds5_sidecar_client.cpp +++ b/src/platform/windows/ds5/ds5_sidecar_client.cpp @@ -40,6 +40,8 @@ namespace platf::ds5 { constexpr std::uint16_t VERSION = 1; constexpr std::size_t HEADER_SIZE = 16; constexpr std::uint32_t MAX_PAYLOAD = 1024 * 1024; + // The sidecar protocol identifies devices with a single byte. + static_assert(platf::MAX_GAMEPADS <= 256, "DS5 device ids must fit the wire format"); enum class message_e: std::uint16_t { hello = 1, @@ -93,7 +95,13 @@ namespace platf::ds5 { p[3] = static_cast(value >> 24); } - bool transfer_exact(HANDLE pipe, HANDLE stop_event, void *buffer, std::size_t size, bool write) { + // A data-plane write that cannot drain within this bound means the sidecar + // stopped reading (for example a blocked HIDMaestro call on its read loop); + // failing the write lets the caller bail out instead of freezing. + constexpr DWORD write_stall_timeout_ms = 5000; + + bool transfer_exact(HANDLE pipe, HANDLE stop_event, void *buffer, std::size_t size, bool write, + DWORD wait_timeout = INFINITE) { std::size_t offset = 0; while (offset < size) { if (WaitForSingleObject(stop_event, 0) == WAIT_OBJECT_0) { @@ -131,7 +139,7 @@ namespace platf::ds5 { #endif const std::array waits { overlapped.hEvent, stop_event }; const auto wait_result = WaitForMultipleObjects( - static_cast(waits.size()), waits.data(), FALSE, INFINITE); + static_cast(waits.size()), waits.data(), FALSE, wait_timeout); if (wait_result == WAIT_OBJECT_0) { completed = GetOverlappedResult(pipe, &overlapped, &count, FALSE) != FALSE; } @@ -159,7 +167,8 @@ namespace platf::ds5 { bool write_exact(HANDLE pipe, HANDLE stop_event, std::span source) { return transfer_exact( - pipe, stop_event, const_cast(source.data()), source.size(), true); + pipe, stop_event, const_cast(source.data()), source.size(), true, + write_stall_timeout_ms); } #ifndef SUNSHINE_DS5_SIDECAR_TEST_HOOK @@ -287,7 +296,17 @@ namespace platf::ds5 { write_u32(frame.data() + 12, request_id); std::copy(payload.begin(), payload.end(), frame.begin() + HEADER_SIZE); std::lock_guard lock(write_mutex); - return !stopping && pipe != INVALID_HANDLE_VALUE && write_exact(pipe, stop_event, frame); + if (stopping || pipe == INVALID_HANDLE_VALUE) { + return false; + } + if (write_exact(pipe, stop_event, frame)) { + return true; + } + // The write stalled or the pipe broke: cancel the reader's pending read + // so read_loop's recovery path tears the transport down, instead of + // letting later senders block on a pipe that will never drain. + CancelIoEx(pipe, nullptr); + return false; } bool receive(message_t &message) { @@ -306,22 +325,81 @@ namespace platf::ds5 { return read_exact(pipe, stop_event, message.payload); } + void dispatch(message_t &message) { + const auto &p = message.payload; + const auto owned_index = global_index.load(); + switch (message.type) { + case message_e::rumble: + if (p.size() == 6 && p[0] == owned_index) { + feedback_queue->raise(gamepad_feedback_msg_t::make_rumble( + p[1], read_u16(p.data() + 2), read_u16(p.data() + 4))); + } + break; + case message_e::adaptive_triggers: + if (p.size() == 26 && p[0] == owned_index) { + std::array left, right; + std::copy_n(p.data() + 6, 10, left.begin()); + std::copy_n(p.data() + 16, 10, right.begin()); + feedback_queue->raise(gamepad_feedback_msg_t::make_adaptive_triggers( + p[1], p[2], p[3], p[4], left, right)); + } + break; + case message_e::led: + if (p.size() == 5 && p[0] == owned_index) { + feedback_queue->raise(gamepad_feedback_msg_t::make_rgb_led(p[1], p[2], p[3], p[4])); + } + break; + case message_e::haptics_pcm: + if (p.size() >= 24 && p[0] == owned_index) { + const auto frames = read_u16(p.data() + 4); + const auto pcm_size = static_cast(frames) * 4; + if (p[3] == 2 && p[6] == 16 && read_u32(p.data() + 20) == 48000 && + frames <= 240 && p.size() == 24 + pcm_size) { + feedback_queue->raise(gamepad_feedback_msg_t::make_ds5_haptics_pcm( + p[1], p[2], frames, read_u32(p.data() + 8), read_u64(p.data() + 12), + p.data() + 24, pcm_size)); + } + } + break; + case message_e::error: + BOOST_LOG(warning) << "DualSense sidecar reported an asynchronous error"sv; + break; + default: + break; + } + } + bool transact(message_e request_type, std::span payload, message_e reply_type, message_t &reply) { const auto request_id = next_request_id++; - if (!send(request_type, request_id, payload) || !receive(reply)) { + if (!send(request_type, request_id, payload)) { return false; } - if (reply.type == message_e::error) { - std::string reason; - if (reply.payload.size() >= 8) { - const auto length = std::min(read_u32(reply.payload.data() + 4), reply.payload.size() - 8); - reason.assign(reinterpret_cast(reply.payload.data() + 8), length); + while (!stopping) { + // Rumble, LEDs and adaptive triggers share the control channel and are + // emitted from a different sidecar thread, so they can legitimately + // arrive ahead of the reply. Dispatch them instead of misreading the + // first message as the reply. + message_t message; + if (!receive(message)) { + return false; } - BOOST_LOG(error) << "DualSense sidecar rejected request: "sv << reason; - return false; + if (message.request_id == request_id && message.type == reply_type) { + reply = std::move(message); + return true; + } + if (message.type == message_e::error && message.request_id == request_id) { + std::string reason; + if (message.payload.size() >= 8) { + const auto length = std::min(read_u32(message.payload.data() + 4), message.payload.size() - 8); + reason.assign(reinterpret_cast(message.payload.data() + 8), length); + } + BOOST_LOG(error) << "DualSense sidecar rejected request: "sv << reason; + return false; + } + dispatch(message); } - return reply.type == reply_type && reply.request_id == request_id; + return false; } bool launch_and_connect() { @@ -417,6 +495,9 @@ namespace platf::ds5 { static_cast(audio_haptics ? 1 : 0), 0, }; + // Claim ownership before the transaction so feedback interleaved ahead + // of the attach reply is routed to the queue instead of dropped. + global_index = id.globalIndex; if (!transact(message_e::attach, attach_payload, message_e::attach_reply, reply) || reply.payload.size() != 8 || reply.payload[0] != attach_payload[0]) { return false; @@ -426,7 +507,6 @@ namespace platf::ds5 { return false; } - global_index = id.globalIndex; client_index = id.clientRelativeIndex; audio_haptics_requested = audio_haptics; online = true; @@ -477,47 +557,7 @@ namespace platf::ds5 { while (!stopping) { message_t message; while (!stopping && receive(message)) { - const auto &p = message.payload; - const auto owned_index = global_index.load(); - switch (message.type) { - case message_e::rumble: - if (p.size() == 6 && p[0] == owned_index) { - feedback_queue->raise(gamepad_feedback_msg_t::make_rumble( - p[1], read_u16(p.data() + 2), read_u16(p.data() + 4))); - } - break; - case message_e::adaptive_triggers: - if (p.size() == 26 && p[0] == owned_index) { - std::array left, right; - std::copy_n(p.data() + 6, 10, left.begin()); - std::copy_n(p.data() + 16, 10, right.begin()); - feedback_queue->raise(gamepad_feedback_msg_t::make_adaptive_triggers( - p[1], p[2], p[3], p[4], left, right)); - } - break; - case message_e::led: - if (p.size() == 5 && p[0] == owned_index) { - feedback_queue->raise(gamepad_feedback_msg_t::make_rgb_led(p[1], p[2], p[3], p[4])); - } - break; - case message_e::haptics_pcm: - if (p.size() >= 24 && p[0] == owned_index) { - const auto frames = read_u16(p.data() + 4); - const auto pcm_size = static_cast(frames) * 4; - if (p[3] == 2 && p[6] == 16 && read_u32(p.data() + 20) == 48000 && - frames <= 240 && p.size() == 24 + pcm_size) { - feedback_queue->raise(gamepad_feedback_msg_t::make_ds5_haptics_pcm( - p[1], p[2], frames, read_u32(p.data() + 8), read_u64(p.data() + 12), - p.data() + 24, pcm_size)); - } - } - break; - case message_e::error: - BOOST_LOG(warning) << "DualSense sidecar reported an asynchronous error"sv; - break; - default: - break; - } + dispatch(message); } online = false; if (stopping) { diff --git a/tests/tools/ds5_fake_sidecar.cpp b/tests/tools/ds5_fake_sidecar.cpp index 1def2379..4cf1bc80 100644 --- a/tests/tools/ds5_fake_sidecar.cpp +++ b/tests/tools/ds5_fake_sidecar.cpp @@ -77,6 +77,9 @@ int main(int argc, char **argv) { const auto continue_name = L"Local\\sunshine-ds5-test-continue-" + event_suffix; const auto continue_event = OpenEventW(SYNCHRONIZE, FALSE, continue_name.c_str()); if (!continue_event) return 2; + // The test process opts this peer into emitting async feedback ahead of the + // attach reply, exercising the Core client's transaction multiplexing. + const auto interleave = GetEnvironmentVariableW(L"SUNSHINE_DS5_TEST_INTERLEAVE", nullptr, 0) > 0; const auto crash_once_name = L"Local\\sunshine-ds5-test-crash-once-" + event_suffix; const auto crash_once_event = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, crash_once_name.c_str()); @@ -108,6 +111,11 @@ int main(int argc, char **argv) { if (type == 1) { if (!reply(pipe, 2, request_id, std::vector(4))) break; } else if (type == 3 && payload.size() == 4) { + if (interleave) { + std::vector early(6); + early[0] = payload[0]; + if (!reply(pipe, 101, 0, early)) break; + } std::vector response(8); response[0] = payload[0]; if (!reply(pipe, 4, request_id, response)) break; diff --git a/tests/unit/platform/windows/test_ds5_sidecar_client.cpp b/tests/unit/platform/windows/test_ds5_sidecar_client.cpp index 5919afdc..3dab0332 100644 --- a/tests/unit/platform/windows/test_ds5_sidecar_client.cpp +++ b/tests/unit/platform/windows/test_ds5_sidecar_client.cpp @@ -89,6 +89,41 @@ TEST(Ds5SidecarClientTests, AllocThenFreeCancelsBlockedReader) { EXPECT_LT(elapsed, std::chrono::seconds(2)); } +TEST(Ds5SidecarClientTests, AttachSurvivesInterleavedAsyncFeedback) { + config_scope_t restore_config; + config::input.ds5_enabled = true; + config::input.ds5_sidecar_path = SUNSHINE_DS5_FAKE_SIDECAR_PATH; + + event_namespace_scope_t events(L"interleaved-feedback"); + const auto continue_name = L"Local\\sunshine-ds5-test-continue-" + events.suffix; + const auto marker_name = L"Local\\sunshine-ds5-test-marker-" + events.suffix; + handle_scope_t continue_event(CreateEventW(nullptr, FALSE, FALSE, continue_name.c_str())); + handle_scope_t marker_event(CreateEventW(nullptr, FALSE, FALSE, marker_name.c_str())); + ASSERT_NE(continue_event.handle, nullptr); + ASSERT_NE(marker_event.handle, nullptr); + ASSERT_NE(SetEnvironmentVariableW(L"SUNSHINE_DS5_TEST_INTERLEAVE", L"1"), 0); + + auto mail = std::make_shared(); + auto feedback = mail->queue("ds5-interleave-test"); + auto feedback_for_test = feedback; + platf::ds5::sidecar_client_t client; + // The fake peer emits an async rumble ahead of the attach reply. The + // transaction must dispatch it and still match the reply; before the + // multiplexing fix the rumble was misread as the reply and alloc failed. + EXPECT_EQ(client.alloc({ 0, 0 }, std::move(feedback), false), 0); + SetEnvironmentVariableW(L"SUNSHINE_DS5_TEST_INTERLEAVE", nullptr); + const auto early = feedback_for_test->pop(std::chrono::seconds(2)); + ASSERT_TRUE(early); + EXPECT_EQ(early->type, platf::gamepad_feedback_e::rumble); + + ASSERT_TRUE(SetEvent(continue_event.handle)); + ASSERT_EQ(WaitForSingleObject(marker_event.handle, 2000), WAIT_OBJECT_0); + const auto marker = feedback_for_test->pop(std::chrono::seconds(2)); + ASSERT_TRUE(marker); + EXPECT_EQ(marker->type, platf::gamepad_feedback_e::rumble); + client.free(0); +} + TEST(Ds5SidecarClientTests, RejectsCompositeAttachWithoutAudioEndpoint) { config_scope_t restore_config; config::input.ds5_enabled = true; From 30d60db5c6257eb8d942bf2c1cb50ed3126b0e91 Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Sun, 16 Aug 2026 18:28:34 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(ds5):=20=E6=B8=85=E7=A9=BA=E6=B5=81?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E8=A7=A6=E8=A7=89=E6=AE=8B=E4=BD=99=E5=B9=B6?= =?UTF-8?q?=E8=AE=A9=20sidecar=20=E6=8B=92=E8=BF=9E=E5=90=8E=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E7=AD=89=E5=BE=85=20owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StreamingChanged(false) 清空 _audioResidual,旧流不足一帧的尾巴不再 拼进新流首帧造成帧边界错位 - owner 校验拒绝非提权客户端后断开并重新等待连接,而不是退出进程; Core 的 alloc 在会话内不重试,单次抢连被拒不应烧掉整个 DS5 分配, 且 Core 的 10 秒连接窗内可在下次重试中接管管道 --- docs/windows_dualsense_component_lifecycle.md | 2 +- .../sunshine-ds5-sidecar/ControllerSession.cs | 3 + tools/sunshine-ds5-sidecar/README.md | 5 +- tools/sunshine-ds5-sidecar/SidecarServer.cs | 112 ++++++++++-------- 4 files changed, 68 insertions(+), 54 deletions(-) diff --git a/docs/windows_dualsense_component_lifecycle.md b/docs/windows_dualsense_component_lifecycle.md index 2be95363..a9c7567a 100644 --- a/docs/windows_dualsense_component_lifecycle.md +++ b/docs/windows_dualsense_component_lifecycle.md @@ -358,7 +358,7 @@ shutdown(owner_token) - `attach`、`update_input`、`subscribe_output` 和 `get_status` 只接受当前连接 owner;Core 持有 owner token,GUI 测试使用独立、低权限 test token。 - Sidecar 拒绝非所有者 detach 和 shutdown;连接断开会清理该 owner 创建的全部设备。 - 输出报告和音频数据使用有界队列;控制消息不得被高频数据饿死。 -- 已实现的 owner 校验(v1):管道 ACL 限定当前用户 + Sidecar 在连接建立时校验客户端进程的提权状态,非提权客户端拒绝并退出;同用户非提权进程即使抢到单实例管道也无法驱动 elevated Sidecar。GUI 低权限 test token 仍属后续工作。 +- 已实现的 owner 校验(v1):管道 ACL 限定当前用户 + Sidecar 在连接建立时校验客户端进程的提权状态,非提权客户端拒绝并断开、继续等待真正的 owner(不因被抢连而退出,避免单次抢连导致该会话分配失败);同用户非提权进程即使抢到单实例管道也无法驱动 elevated Sidecar。GUI 低权限 test token 仍属后续工作。 - 已实现的停滞保护(v1):Core 对数据面写操作设置 5 秒停滞上限;写停滞会取消 reader 的挂起读取并进入既有的单次恢复路径,sidecar 读循环阻塞不再冻结 Sunshine 输入线程。 高频四声道音频数据不应经 Tauri 或 JSON 传输。后续实现使用共享内存环形缓冲区或专用本地数据通道;Named Pipe 只负责控制和状态。 diff --git a/tools/sunshine-ds5-sidecar/ControllerSession.cs b/tools/sunshine-ds5-sidecar/ControllerSession.cs index 48a52b59..63d5e157 100644 --- a/tools/sunshine-ds5-sidecar/ControllerSession.cs +++ b/tools/sunshine-ds5-sidecar/ControllerSession.cs @@ -236,6 +236,9 @@ private void OnAudioStreamingChanged(object? sender, bool streaming) else { Interlocked.Exchange(ref _hapticsStreaming, 0); + // A stale sub-frame tail from the old stream must not splice into + // the first frame of the next stream. + _audioResidual = Array.Empty(); EmitHaptics(ReadOnlySpan.Empty, 0, Protocol.HapticsFlags.StreamEnd); } } diff --git a/tools/sunshine-ds5-sidecar/README.md b/tools/sunshine-ds5-sidecar/README.md index a77a9cdb..19646827 100644 --- a/tools/sunshine-ds5-sidecar/README.md +++ b/tools/sunshine-ds5-sidecar/README.md @@ -18,8 +18,9 @@ dotnet Sunshine.Ds5Sidecar.dll --probe ``` The production process must be launched elevated and placed in the Sunshine -Job Object. The pipe accepts a single connection from an elevated client of -the creating user; non-elevated callers are rejected at connect time. +Job Object. The pipe accepts a single elevated client of the creating user; +non-elevated callers are rejected at connect time and dropped without +ending the sidecar. Disconnecting the owning pipe disposes every device created by that connection. Standard `dualsense` uses UMDF2; `dualsense-composite` enables the USB composite HID/audio profile and authored haptics PCM. diff --git a/tools/sunshine-ds5-sidecar/SidecarServer.cs b/tools/sunshine-ds5-sidecar/SidecarServer.cs index 539b3fa2..a527c8d4 100644 --- a/tools/sunshine-ds5-sidecar/SidecarServer.cs +++ b/tools/sunshine-ds5-sidecar/SidecarServer.cs @@ -38,66 +38,76 @@ internal SidecarServer(string pipeName) internal async Task RunAsync(CancellationToken stoppingToken) { - await using var pipe = new NamedPipeServerStream( - _pipeName, - PipeDirection.InOut, - 1, - PipeTransmissionMode.Byte, - PipeOptions.Asynchronous | PipeOptions.WriteThrough | - PipeOptions.CurrentUserOnly | PipeOptions.FirstPipeInstance, - 64 * 1024, - 64 * 1024); - _pipe = pipe; - await pipe.WaitForConnectionAsync(stoppingToken); - if (!OwnerVerification.ClientIsElevated(pipe)) - { - Console.Error.WriteLine("Rejected a non-elevated DualSense sidecar pipe client"); - return; - } - using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); - _sessionCancellation = linked; - var writer = WriteLoopAsync(pipe, linked.Token); - try - { - await ReadLoopAsync(pipe, linked.Token); - } - catch (EndOfStreamException) - { - // The owning Sunshine process disconnected. The sidecar exits after - // destroying every device instead of becoming an orphan service. - } - catch (IOException) when (!pipe.IsConnected) - { - // Windows may surface a broken owner pipe as ERROR_BROKEN_PIPE or - // ERROR_NO_DATA instead of a zero-byte read. Treat both as EOF. - } - finally + while (!stoppingToken.IsCancellationRequested) { + await using var pipe = new NamedPipeServerStream( + _pipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.WriteThrough | + PipeOptions.CurrentUserOnly | PipeOptions.FirstPipeInstance, + 64 * 1024, + 64 * 1024); + _pipe = pipe; + await pipe.WaitForConnectionAsync(stoppingToken); + if (!OwnerVerification.ClientIsElevated(pipe)) + { + // Core does not retry a failed launch within a session, so a + // rejected client must not burn the sidecar: drop the connection + // and keep waiting for the real owner. + Console.Error.WriteLine("Rejected a non-elevated DualSense sidecar pipe client"); + _pipe = null; + continue; + } + using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + _sessionCancellation = linked; + var writer = WriteLoopAsync(pipe, linked.Token); try { - linked.Cancel(); - _controlOutgoing.Writer.TryComplete(); - _realtimeOutgoing.Writer.TryComplete(); + await ReadLoopAsync(pipe, linked.Token); + } + catch (EndOfStreamException) + { + // The owning Sunshine process disconnected. The sidecar exits after + // destroying every device instead of becoming an orphan service. + } + catch (IOException) when (!pipe.IsConnected) + { + // Windows may surface a broken owner pipe as ERROR_BROKEN_PIPE or + // ERROR_NO_DATA instead of a zero-byte read. Treat both as EOF. + } + finally + { try { - await writer; - } - catch (OperationCanceledException) - { - // Expected when the owner or host cancellation stops the writer. + linked.Cancel(); + _controlOutgoing.Writer.TryComplete(); + _realtimeOutgoing.Writer.TryComplete(); + try + { + await writer; + } + catch (OperationCanceledException) + { + // Expected when the owner or host cancellation stops the writer. + } + catch (IOException) when (linked.IsCancellationRequested || !pipe.IsConnected) + { + // A pending WriteAsync/FlushAsync reports a broken owner pipe as + // IOException on Windows. Cleanup must still destroy every device. + } } - catch (IOException) when (linked.IsCancellationRequested || !pipe.IsConnected) + finally { - // A pending WriteAsync/FlushAsync reports a broken owner pipe as - // IOException on Windows. Cleanup must still destroy every device. + DisposeControllers(); + _pipe = null; + _sessionCancellation = null; } } - finally - { - DisposeControllers(); - _pipe = null; - _sessionCancellation = null; - } + + // The owner session ended; exit instead of serving a second owner. + return; } } From e1ae833984f925cf3a62519e76202b41088a2861 Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Sun, 16 Aug 2026 19:44:29 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ds5):=20=E8=A1=A5=E9=BD=90=20HelloReply?= =?UTF-8?q?=20=E8=83=BD=E5=8A=9B=E4=BD=8D=E8=A1=A8=E8=BE=BE=E5=BC=8F?= =?UTF-8?q?=E7=9A=84=E5=8F=B3=E6=8B=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/sunshine-ds5-sidecar/SidecarServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/sunshine-ds5-sidecar/SidecarServer.cs b/tools/sunshine-ds5-sidecar/SidecarServer.cs index a527c8d4..5760a88f 100644 --- a/tools/sunshine-ds5-sidecar/SidecarServer.cs +++ b/tools/sunshine-ds5-sidecar/SidecarServer.cs @@ -146,7 +146,7 @@ private async Task ReadLoopAsync(Stream pipe, CancellationToken cancellationToke (_authoredHapticsAvailable ? Protocol.Capability.AudioFourChannel | Protocol.Capability.AuthoredHapticsPcm - : 0)))); + : 0))))); break; case Protocol.MessageType.Attach: Attach(header.RequestId, payload);